Merge pull request #1801 from hydralauncher/feat/reviews-and-commenting
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled

Feat/reviews and commenting
This commit is contained in:
Chubby Granny Chaser
2025-10-12 19:05:45 +01:00
committed by GitHub
70 changed files with 4270 additions and 278 deletions

View File

@@ -40,6 +40,12 @@
"@primer/octicons-react": "^19.9.0",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@reduxjs/toolkit": "^2.2.3",
"@tiptap/extension-bold": "^3.6.2",
"@tiptap/extension-italic": "^3.6.2",
"@tiptap/extension-link": "^3.6.2",
"@tiptap/extension-underline": "^3.6.2",
"@tiptap/react": "^3.6.2",
"@tiptap/starter-kit": "^3.6.2",
"auto-launch": "^5.0.6",
"axios": "^1.7.9",
"axios-cookiejar-support": "^5.0.5",
@@ -63,6 +69,7 @@
"jsdom": "^24.0.0",
"jsonwebtoken": "^9.0.2",
"lodash-es": "^4.17.21",
"lucide-react": "^0.544.0",
"parse-torrent": "^11.0.18",
"rc-virtual-list": "^3.18.3",
"react-dnd": "^16.0.1",

View File

@@ -76,7 +76,19 @@
"edit_game_modal_drop_hero_image_here": "Drop hero image here",
"edit_game_modal_drop_to_replace_icon": "Drop to replace icon",
"edit_game_modal_drop_to_replace_logo": "Drop to replace logo",
"edit_game_modal_drop_to_replace_hero": "Drop to replace hero"
"edit_game_modal_drop_to_replace_hero": "Drop to replace hero",
"install_decky_plugin": "Install Decky Plugin",
"update_decky_plugin": "Update Decky Plugin",
"decky_plugin_installed_version": "Decky Plugin (v{{version}})",
"install_decky_plugin_title": "Install Hydra Decky Plugin",
"install_decky_plugin_message": "This will download and install the Hydra plugin for Decky Loader. This may require elevated permissions. Continue?",
"update_decky_plugin_title": "Update Hydra Decky Plugin",
"update_decky_plugin_message": "A new version of the Hydra Decky plugin is available. Would you like to update it now?",
"decky_plugin_installed": "Decky plugin v{{version}} installed successfully",
"decky_plugin_installation_failed": "Failed to install Decky plugin: {{error}}",
"decky_plugin_installation_error": "Error installing Decky plugin: {{error}}",
"confirm": "Confirm",
"cancel": "Cancel"
},
"header": {
"search": "Search games",
@@ -200,6 +212,7 @@
"stats": "Stats",
"download_count": "Downloads",
"player_count": "Active players",
"rating_count": "Rating",
"download_error": "This download option is not available",
"download": "Download",
"executable_path_in_use": "Executable already in use by \"{{game}}\"",
@@ -207,6 +220,39 @@
"hydra_needs_to_remain_open": "for this download, Hydra needs to remain open util it's completed. If Hydra closes before completing, you will lose your progress.",
"achievements": "Achievements",
"achievements_count": "Achievements {{unlockedCount}}/{{achievementsCount}}",
"show_more": "Show more",
"show_less": "Show less",
"reviews": "Reviews",
"leave_a_review": "Leave a Review",
"write_review_placeholder": "Share your thoughts about this game...",
"sort_newest": "Newest",
"no_reviews_yet": "No reviews yet",
"be_first_to_review": "Be the first to share your thoughts about this game!",
"sort_oldest": "Oldest",
"sort_highest_score": "Highest Score",
"sort_lowest_score": "Lowest Score",
"sort_most_voted": "Most Voted",
"rating": "Rating",
"rating_stats": "Rating",
"rating_very_negative": "Very Negative",
"rating_negative": "Negative",
"rating_neutral": "Neutral",
"rating_positive": "Positive",
"rating_very_positive": "Very Positive",
"submit_review": "Submit",
"submitting": "Submitting...",
"review_submitted_successfully": "Review submitted successfully!",
"review_submission_failed": "Failed to submit review. Please try again.",
"review_cannot_be_empty": "Review text field cannot be empty.",
"review_deleted_successfully": "Review deleted successfully.",
"review_deletion_failed": "Failed to delete review. Please try again.",
"loading_reviews": "Loading reviews...",
"loading_more_reviews": "Loading more reviews...",
"load_more_reviews": "Load More Reviews",
"you_seemed_to_enjoy_this_game": "You've seemed to enjoy this game",
"would_you_recommend_this_game": "Would you like to leave a review to this game?",
"yes": "Yes",
"maybe_later": "Maybe Later",
"cloud_save": "Cloud save",
"cloud_save_description": "Save your progress in the cloud and continue playing on any device",
"backups": "Backups",
@@ -304,7 +350,13 @@
"caption": "Caption",
"audio": "Audio",
"filter_by_source": "Filter by source",
"no_repacks_found": "No sources found for this game"
"no_repacks_found": "No sources found for this game",
"delete_review": "Delete review",
"remove_review": "Remove Review",
"delete_review_modal_title": "Are you sure you want to delete your review?",
"delete_review_modal_description": "This action cannot be undone.",
"delete_review_modal_delete_button": "Delete",
"delete_review_modal_cancel_button": "Cancel"
},
"activation": {
"title": "Activate Hydra",
@@ -446,6 +498,8 @@
"delete_theme_description": "This will delete the theme {{theme}}",
"cancel": "Cancel",
"appearance": "Appearance",
"debrid": "Debrid",
"debrid_description": "Debrid services are premium unrestricted downloaders that allow you to quickly download files hosted on various file hosting services, only limited by your internet speed.",
"enable_torbox": "Enable TorBox",
"torbox_description": "TorBox is your premium seedbox service rivaling even the best servers on the market.",
"torbox_account_linked": "TorBox account linked",
@@ -520,7 +574,8 @@
"game_card": {
"available_one": "Available",
"available_other": "Available",
"no_downloads": "No downloads available"
"no_downloads": "No downloads available",
"calculating": "Calculating"
},
"binary_not_found_modal": {
"title": "Programs not installed",
@@ -623,7 +678,10 @@
"error_adding_friend": "Could not send friend request. Please check friend code",
"friend_code_length_error": "Friend code must have 8 characters",
"game_removed_from_pinned": "Game removed from pinned",
"game_added_to_pinned": "Game added to pinned"
"game_added_to_pinned": "Game added to pinned",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Earned from positive likes on reviews"
},
"achievement": {
"achievement_unlocked": "Achievement unlocked",

View File

@@ -27,21 +27,68 @@
"friends": "Amigos",
"need_help": "Precisa de ajuda?",
"favorites": "Favoritos",
"playable_button_title": "Mostrar apenas jogos que você pode jogar agora",
"add_custom_game_tooltip": "Adicionar jogo personalizado",
"show_playable_only_tooltip": "Mostrar Apenas Jogáveis",
"custom_game_modal": "Adicionar jogo personalizado",
"custom_game_modal_description": "Adicione um jogo personalizado à sua biblioteca selecionando um arquivo executável",
"custom_game_modal_executable_path": "Caminho do Executável",
"custom_game_modal_select_executable": "Selecionar arquivo executável",
"custom_game_modal_title": "Título",
"custom_game_modal_enter_title": "Insira o título",
"edit_game_modal_title": "Título",
"playable_button_title": "",
"custom_game_modal_add": "Adicionar Jogo",
"custom_game_modal_adding": "Adicionando...",
"custom_game_modal_browse": "Buscar",
"custom_game_modal_cancel": "Cancelar",
"edit_game_modal_assets": "Imagens",
"edit_game_modal_icon": "Ícone",
"edit_game_modal_browse": "Buscar",
"edit_game_modal_cancel": "Cancelar",
"custom_game_modal_add": "Adicionar Jogo",
"custom_game_modal_adding": "Adicionando...",
"custom_game_modal_success": "Jogo personalizado adicionado com sucesso",
"custom_game_modal_failed": "Falha ao adicionar jogo personalizado",
"custom_game_modal_executable": "Executável",
"edit_game_modal": "Personalizar detalhes",
"edit_game_modal_description": "Personalize os recursos e detalhes do jogo",
"edit_game_modal_enter_title": "Insira o título",
"edit_game_modal_image": "Imagem",
"edit_game_modal_select_image": "Selecionar imagem",
"edit_game_modal_browse": "Buscar",
"edit_game_modal_image_preview": "Visualização da imagem",
"edit_game_modal_icon": "Ícone",
"edit_game_modal_select_icon": "Selecionar ícone",
"edit_game_modal_icon_preview": "Visualização do ícone",
"edit_game_modal_logo": "Logo",
"edit_game_modal": "Personalizar detalhes"
"edit_game_modal_select_logo": "Selecionar logo",
"edit_game_modal_logo_preview": "Visualização do logo",
"edit_game_modal_hero": "Hero da Biblioteca",
"edit_game_modal_select_hero": "Selecionar imagem hero da biblioteca",
"edit_game_modal_hero_preview": "Visualização da imagem hero da biblioteca",
"edit_game_modal_cancel": "Cancelar",
"edit_game_modal_update": "Atualizar",
"edit_game_modal_updating": "Atualizando...",
"edit_game_modal_fill_required": "Por favor, preencha todos os campos obrigatórios",
"edit_game_modal_success": "Recursos atualizados com sucesso",
"edit_game_modal_failed": "Falha ao atualizar recursos",
"edit_game_modal_image_filter": "Imagem",
"edit_game_modal_icon_resolution": "Resolução recomendada: 256x256px",
"edit_game_modal_logo_resolution": "Resolução recomendada: 640x360px",
"edit_game_modal_hero_resolution": "Resolução recomendada: 1920x620px",
"edit_game_modal_assets": "Imagens",
"edit_game_modal_drop_icon_image_here": "Solte a imagem do ícone aqui",
"edit_game_modal_drop_logo_image_here": "Solte a imagem do logo aqui",
"edit_game_modal_drop_hero_image_here": "Solte a imagem hero aqui",
"edit_game_modal_drop_to_replace_icon": "Solte para substituir o ícone",
"edit_game_modal_drop_to_replace_logo": "Solte para substituir o logo",
"edit_game_modal_drop_to_replace_hero": "Solte para substituir o hero",
"install_decky_plugin": "Instalar Plugin Decky",
"update_decky_plugin": "Atualizar Plugin Decky",
"decky_plugin_installed_version": "Plugin Decky (v{{version}})",
"install_decky_plugin_title": "Instalar Plugin Hydra Decky",
"install_decky_plugin_message": "Isso irá baixar e instalar o plugin Hydra para Decky Loader. Pode ser necessário permissões elevadas. Continuar?",
"update_decky_plugin_title": "Atualizar Plugin Hydra Decky",
"update_decky_plugin_message": "Uma nova versão do plugin Hydra Decky está disponível. Gostaria de atualizar agora?",
"decky_plugin_installed": "Plugin Decky v{{version}} instalado com sucesso",
"decky_plugin_installation_failed": "Falha ao instalar plugin Decky: {{error}}",
"decky_plugin_installation_error": "Erro ao instalar plugin Decky: {{error}}",
"confirm": "Confirmar",
"cancel": "Cancelar"
},
"header": {
"search": "Buscar jogos",
@@ -165,6 +212,7 @@
"uploading_backup": "Criando backup…",
"no_backups": "Você ainda não fez nenhum backup deste jogo",
"backup_uploaded": "Backup criado",
"backup_failed": "Falha no backup",
"backup_deleted": "Backup apagado",
"backup_restored": "Backup restaurado",
"see_all_achievements": "Ver todas as conquistas",
@@ -256,7 +304,48 @@
"update_playtime": "Modificar tempo de jogo",
"update_playtime_description": "Atualizar manualmente o tempo de jogo de {{game}}",
"update_playtime_error": "Falha ao atualizar tempo de jogo",
"update_playtime_title": "Atualizar tempo de jogo"
"update_playtime_title": "Atualizar tempo de jogo",
"update_playtime_success": "Tempo de jogo atualizado com sucesso",
"show_more": "Mostrar mais",
"show_less": "Mostrar menos",
"reviews": "Avaliações",
"leave_a_review": "Deixar uma Avaliação",
"write_review_placeholder": "Compartilhe seus pensamentos sobre este jogo...",
"sort_newest": "Mais Recentes",
"sort_oldest": "Mais Antigas",
"sort_highest_score": "Maior Nota",
"sort_lowest_score": "Menor Nota",
"sort_most_voted": "Mais Votadas",
"no_reviews_yet": "Ainda não há avaliações",
"be_first_to_review": "Seja o primeiro a compartilhar seus pensamentos sobre este jogo!",
"rating": "Avaliação",
"rating_stats": "Avaliação",
"rating_very_negative": "Muito Negativo",
"rating_negative": "Negativo",
"rating_neutral": "Neutro",
"rating_positive": "Positivo",
"rating_very_positive": "Muito Positivo",
"submit_review": "Enviar",
"submitting": "Enviando...",
"review_submitted_successfully": "Avaliação enviada com sucesso!",
"review_submission_failed": "Falha ao enviar avaliação. Por favor, tente novamente.",
"review_cannot_be_empty": "O campo de texto da avaliação não pode estar vazio.",
"review_deleted_successfully": "Avaliação excluída com sucesso.",
"review_deletion_failed": "Falha ao excluir avaliação. Por favor, tente novamente.",
"loading_reviews": "Carregando avaliações...",
"loading_more_reviews": "Carregando mais avaliações...",
"load_more_reviews": "Carregar Mais Avaliações",
"you_seemed_to_enjoy_this_game": "Parece que você gostou deste jogo",
"would_you_recommend_this_game": "Gostaria de deixar uma avaliação para este jogo?",
"yes": "Sim",
"maybe_later": "Talvez Mais Tarde",
"delete_review": "Excluir avaliação",
"remove_review": "Remover Avaliação",
"delete_review_modal_title": "Tem certeza de que deseja excluir sua avaliação?",
"delete_review_modal_description": "Esta ação não pode ser desfeita.",
"delete_review_modal_delete_button": "Excluir",
"delete_review_modal_cancel_button": "Cancelar",
"rating_count": "Avaliação"
},
"activation": {
"title": "Ativação",
@@ -294,6 +383,7 @@
"stop_seeding": "Parar de semear",
"resume_seeding": "Semear",
"options": "Gerenciar",
"alldebrid_size_not_supported": "Informações de download para AllDebrid ainda não são suportadas",
"extract": "Extrair arquivos",
"extracting": "Extraindo arquivos…"
},
@@ -378,6 +468,8 @@
"subscription_renews_on": "Sua assinatura renova dia {{date}}",
"bill_sent_until": "Sua próxima cobrança será enviada até esse dia",
"no_themes": "Parece que você ainda não tem nenhum tema. Não se preocupe, clique aqui para criar sua primeira obra de arte.",
"editor_tab_code": "Código",
"editor_tab_info": "Info",
"editor_tab_save": "Salvar",
"web_store": "Loja de temas",
"clear_themes": "Limpar",
@@ -395,12 +487,25 @@
"delete_theme_description": "Isso irá deletar o tema {{theme}}",
"cancel": "Cancelar",
"appearance": "Aparência",
"debrid": "Debrid",
"debrid_description": "Serviços Debrid são downloaders premium sem restrições que permitem baixar rapidamente arquivos hospedados em vários serviços de hospedagem de arquivos, limitados apenas pela sua velocidade de internet.",
"enable_torbox": "Habilitar TorBox",
"torbox_description": "TorBox é o seu serviço de seedbox premium que rivaliza até com os melhores servidores do mercado.",
"torbox_account_linked": "Conta do TorBox vinculada",
"create_real_debrid_account": "Clique aqui se você ainda não tem uma conta do Real-Debrid",
"create_torbox_account": "Clique aqui se você ainda não tem uma conta do TorBox",
"real_debrid_account_linked": "Conta Real-Debrid associada",
"enable_all_debrid": "Habilitar All-Debrid",
"all_debrid_description": "All-Debrid é um downloader sem restrições que permite baixar rapidamente arquivos de várias fontes.",
"all_debrid_free_account_error": "A conta \"{{username}}\" é uma conta gratuita. Por favor, assine o All-Debrid",
"all_debrid_account_linked": "Conta All-Debrid vinculada com sucesso",
"alldebrid_missing_key": "Por favor, forneça uma chave de API",
"alldebrid_invalid_key": "Chave de API inválida",
"alldebrid_blocked": "Sua chave de API está bloqueada por geolocalização ou IP",
"alldebrid_banned": "Esta conta foi banida",
"alldebrid_unknown_error": "Ocorreu um erro desconhecido",
"alldebrid_invalid_response": "Resposta inválida do All-Debrid",
"alldebrid_network_error": "Erro de rede. Por favor, verifique sua conexão",
"name_min_length": "O nome do tema deve ter pelo menos 3 caracteres",
"import_theme": "Importar tema",
"import_theme_description": "Você irá importar {{theme}} da loja de temas",
@@ -431,8 +536,7 @@
"hidden": "Oculta",
"test_notification": "Testar notificação",
"notification_preview": "Prévia da Notificação de Conquistas",
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo",
"editor_tab_code": "Código"
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo"
},
"notifications": {
"download_complete": "Download concluído",
@@ -448,7 +552,9 @@
"game_extracted": "{{title}} extraído com sucesso",
"friend_started_playing_game": "{{displayName}} começou a jogar",
"test_achievement_notification_title": "Esta é uma notificação de teste",
"test_achievement_notification_description": "Bem legal, né?"
"test_achievement_notification_description": "Bem legal, né?",
"notification_achievement_unlocked_title": "Conquista desbloqueada para {{game}}",
"notification_achievement_unlocked_body": "{{achievement}} e outras {{count}} foram desbloqueadas"
},
"system_tray": {
"open": "Abrir Hydra",
@@ -457,7 +563,8 @@
"game_card": {
"available_one": "Disponível",
"available_other": "Disponíveis",
"no_downloads": "Sem downloads disponíveis"
"no_downloads": "Sem downloads disponíveis",
"calculating": "Calculando"
},
"binary_not_found_modal": {
"title": "Programas não instalados",
@@ -569,7 +676,12 @@
"amount_minutes_short": "{{amount}}m",
"amount_hours_short": "{{amount}}h",
"game_added_to_pinned": "Jogo adicionado aos fixados",
"achievements_earned": "Conquistas recebidas"
"game_removed_from_pinned": "Jogo removido dos fixados",
"achievements_earned": "Conquistas recebidas",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Ganho a partir de curtidas positivas em avaliações",
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente"
},
"achievement": {
"achievement_unlocked": "Conquista desbloqueada",

View File

@@ -76,7 +76,19 @@
"edit_game_modal_drop_hero_image_here": "Перетащите изображение обложки сюда",
"edit_game_modal_drop_to_replace_icon": "Перетащите для замены иконки",
"edit_game_modal_drop_to_replace_logo": "Перетащите для замены логотипа",
"edit_game_modal_drop_to_replace_hero": "Перетащите для замены обложки"
"edit_game_modal_drop_to_replace_hero": "Перетащите для замены обложки",
"install_decky_plugin": "Установить плагин Decky",
"update_decky_plugin": "Обновить плагин Decky",
"decky_plugin_installed_version": "Плагин Decky (v{{version}})",
"install_decky_plugin_title": "Установить плагин Hydra Decky",
"install_decky_plugin_message": "Это загрузит и установит плагин Hydra для Decky Loader. Может потребоваться повышенные разрешения. Продолжить?",
"update_decky_plugin_title": "Обновить плагин Hydra Decky",
"update_decky_plugin_message": "Доступна новая версия плагина Hydra Decky. Хотите обновить его сейчас?",
"decky_plugin_installed": "Плагин Decky v{{version}} успешно установлен",
"decky_plugin_installation_failed": "Не удалось установить плагин Decky: {{error}}",
"decky_plugin_installation_error": "Ошибка установки плагина Decky: {{error}}",
"confirm": "Подтвердить",
"cancel": "Отмена"
},
"header": {
"search": "Поиск",
@@ -207,6 +219,46 @@
"hydra_needs_to_remain_open": "Для этой загрузки Hydra должна оставаться открытой до завершения. Если Hydra закроется до завершения, вы потеряете прогресс.",
"achievements": "Достижения",
"achievements_count": "Достижения {{unlockedCount}}/{{achievementsCount}}",
"show_more": "Показать больше",
"show_less": "Показать меньше",
"reviews": "Отзывы",
"leave_a_review": "Оставить отзыв",
"write_review_placeholder": "Поделитесь своими мыслями об этой игре...",
"sort_newest": "Сначала новые",
"no_reviews_yet": "Пока нет отзывов",
"be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!",
"sort_oldest": "Сначала старые",
"sort_highest_score": "Высший балл",
"sort_lowest_score": "Низший балл",
"sort_most_voted": "Самые популярные",
"rating": "Оценка",
"rating_stats": "Оценка",
"rating_very_negative": "Очень негативно",
"rating_negative": "Негативно",
"rating_neutral": "Нейтрально",
"rating_positive": "Позитивно",
"rating_very_positive": "Очень позитивно",
"submit_review": "Отправить",
"submitting": "Отправка...",
"review_submitted_successfully": "Отзыв успешно отправлен!",
"review_submission_failed": "Не удалось отправить отзыв. Пожалуйста, попробуйте снова.",
"review_cannot_be_empty": "Текстовое поле отзыва не может быть пустым.",
"review_deleted_successfully": "Отзыв успешно удален.",
"review_deletion_failed": "Не удалось удалить отзыв. Пожалуйста, попробуйте снова.",
"loading_reviews": "Загрузка отзывов...",
"loading_more_reviews": "Загрузка дополнительных отзывов...",
"load_more_reviews": "Загрузить больше отзывов",
"you_seemed_to_enjoy_this_game": "Похоже, вам понравилась эта игра",
"would_you_recommend_this_game": "Хотите оставить отзыв об этой игре?",
"yes": "Да",
"maybe_later": "Возможно позже",
"rating_count": "Оценка",
"delete_review": "Удалить отзыв",
"remove_review": "Удалить отзыв",
"delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
"delete_review_modal_description": "Это действие нельзя отменить.",
"delete_review_modal_delete_button": "Удалить",
"delete_review_modal_cancel_button": "Отмена",
"cloud_save": "Облачное сохранение",
"cloud_save_description": "Сохраняйте ваш прогресс в облаке и продолжайте играть на любом устройстве",
"backups": "Резервные копии",
@@ -219,6 +271,7 @@
"uploading_backup": "Загрузка резервной копии…",
"no_backups": "Вы еще не создали резервных копий для этой игры",
"backup_uploaded": "Резервная копия загружена",
"backup_failed": "Ошибка резервного копирования",
"backup_deleted": "Резервная копия удалена",
"backup_restored": "Резервная копия восстановлена",
"see_all_achievements": "Просмотреть все достижения",
@@ -445,6 +498,8 @@
"delete_theme_description": "Это приведет к удалению темы {{theme}}",
"cancel": "Отменить",
"appearance": "Внешний вид",
"debrid": "Debrid",
"debrid_description": "Сервисы Debrid - это премиум-загрузчики без ограничений, которые позволяют быстро скачивать файлы с различных файлообменников, ограничиваясь только скоростью вашего интернета.",
"enable_torbox": "Включить TorBox",
"torbox_description": "TorBox - это ваш премиум-сервис, конкурирующий даже с лучшими серверами на рынке.",
"torbox_account_linked": "Аккаунт TorBox привязан",
@@ -519,7 +574,8 @@
"game_card": {
"available_one": "Доступный",
"available_other": "Доступный",
"no_downloads": "Нет доступных источников"
"no_downloads": "Нет доступных источников",
"calculating": "Вычисление"
},
"binary_not_found_modal": {
"title": "Программы не установлены",
@@ -622,7 +678,10 @@
"error_adding_friend": "Не удалось отправить запрос в друзья. Пожалуйста, проверьте код друга",
"friend_code_length_error": "Код друга должен содержать 8 символов",
"game_removed_from_pinned": "Игра удалена из закрепленных",
"game_added_to_pinned": "Игра добавлена в закрепленные"
"game_added_to_pinned": "Игра добавлена в закрепленные",
"karma": "Карма",
"karma_count": "карма",
"karma_description": "Заработана положительными оценками отзывов"
},
"achievement": {
"achievement_unlocked": "Достижение разблокировано",

View File

@@ -42,3 +42,14 @@ export const appVersion = app.getVersion() + (isStaging ? "-staging" : "");
export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets");
export const MAIN_LOOP_INTERVAL = 2000;
export const DECKY_PLUGINS_LOCATION = path.join(
SystemPath.getPath("home"),
"homebrew",
"plugins"
);
export const HYDRA_DECKY_PLUGIN_LOCATION = path.join(
DECKY_PLUGINS_LOCATION,
"Hydra"
);

View File

@@ -0,0 +1,15 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const checkGameReview = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
) => {
return HydraApi.get(`/games/${shop}/${objectId}/reviews/check`, null, {
needsAuth: true,
});
};
registerEvent("checkGameReview", checkGameReview);

View File

@@ -0,0 +1,18 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const createGameReview = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
reviewHtml: string,
score: number
) => {
return HydraApi.post(`/games/${shop}/${objectId}/reviews`, {
reviewHtml,
score,
});
};
registerEvent("createGameReview", createGameReview);

View File

@@ -0,0 +1,14 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const deleteReview = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
reviewId: string
) => {
return HydraApi.delete(`/games/${shop}/${objectId}/reviews/${reviewId}`);
};
registerEvent("deleteReview", deleteReview);

View File

@@ -0,0 +1,51 @@
import type { GameShop, ShopAssets } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { gamesShopAssetsSublevel, levelKeys } from "@main/level";
const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 60 * 8; // 8 hours
export const getGameAssets = async (objectId: string, shop: GameShop) => {
const cachedAssets = await gamesShopAssetsSublevel.get(
levelKeys.game(shop, objectId)
);
if (
cachedAssets &&
cachedAssets.updatedAt + LOCAL_CACHE_EXPIRATION > Date.now()
) {
return cachedAssets;
}
return HydraApi.get<ShopAssets | null>(
`/games/${shop}/${objectId}/assets`,
null,
{
needsAuth: false,
}
).then(async (assets) => {
if (!assets) return null;
// Preserve existing title if it differs from the incoming title (indicating it was customized)
const shouldPreserveTitle =
cachedAssets?.title && cachedAssets.title !== assets.title;
await gamesShopAssetsSublevel.put(levelKeys.game(shop, objectId), {
...assets,
title: shouldPreserveTitle ? cachedAssets.title : assets.title,
updatedAt: Date.now(),
});
return assets;
});
};
const getGameAssetsEvent = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
) => {
return getGameAssets(objectId, shop);
};
registerEvent("getGameAssets", getGameAssetsEvent);

View File

@@ -0,0 +1,26 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const getGameReviews = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
take: number = 20,
skip: number = 0,
sortBy: string = "newest"
) => {
const params = new URLSearchParams({
take: take.toString(),
skip: skip.toString(),
sortBy,
});
return HydraApi.get(
`/games/${shop}/${objectId}/reviews?${params.toString()}`,
null,
{ needsAuth: false }
);
};
registerEvent("getGameReviews", getGameReviews);

View File

@@ -1,25 +0,0 @@
import type { GameShop, ShopAssets } from "@types";
import { gamesShopAssetsSublevel, levelKeys } from "@main/level";
import { registerEvent } from "../register-event";
const saveGameShopAssets = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop,
assets: ShopAssets
): Promise<void> => {
const key = levelKeys.game(shop, objectId);
const existingAssets = await gamesShopAssetsSublevel.get(key);
// Preserve existing title if it differs from the incoming title (indicating it was customized)
const shouldPreserveTitle =
existingAssets?.title && existingAssets.title !== assets.title;
return gamesShopAssetsSublevel.put(key, {
...existingAssets,
...assets,
title: shouldPreserveTitle ? existingAssets.title : assets.title,
});
};
registerEvent("saveGameShopAssets", saveGameShopAssets);

View File

@@ -0,0 +1,18 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const voteReview = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
reviewId: string,
voteType: "upvote" | "downvote"
) => {
return HydraApi.put(
`/games/${shop}/${objectId}/reviews/${reviewId}/${voteType}`,
{}
);
};
registerEvent("voteReview", voteReview);

View File

@@ -3,7 +3,6 @@ import { ipcMain } from "electron";
import "./catalogue/get-catalogue";
import "./catalogue/get-game-shop-details";
import "./catalogue/save-game-shop-assets";
import "./catalogue/get-how-long-to-beat";
import "./catalogue/get-random-game";
import "./catalogue/search-games";
@@ -11,6 +10,11 @@ import "./catalogue/get-game-stats";
import "./catalogue/get-trending-games";
import "./catalogue/get-publishers";
import "./catalogue/get-developers";
import "./catalogue/create-game-review";
import "./catalogue/get-game-reviews";
import "./catalogue/vote-review";
import "./catalogue/delete-review";
import "./catalogue/check-game-review";
import "./hardware/get-disk-free-space";
import "./hardware/check-folder-write-permission";
import "./library/add-game-to-library";
@@ -53,6 +57,9 @@ import "./misc/install-common-redist";
import "./misc/can-install-common-redist";
import "./misc/save-temp-file";
import "./misc/delete-temp-file";
import "./misc/install-hydra-decky-plugin";
import "./misc/get-hydra-decky-plugin-info";
import "./misc/check-homebrew-folder-exists";
import "./torrenting/cancel-game-download";
import "./torrenting/pause-game-download";
import "./torrenting/resume-game-download";

View File

@@ -27,6 +27,7 @@ const addCustomGameToLibrary = async (
}
const assets = {
updatedAt: Date.now(),
objectId,
shop,
title,

View File

@@ -1,12 +1,11 @@
import { registerEvent } from "../register-event";
import type { GameShop, GameStats } from "@types";
import type { GameShop, ShopAssets } from "@types";
import { gamesSublevel, levelKeys } from "@main/level";
import {
composeSteamShortcut,
getSteamLocation,
getSteamShortcuts,
getSteamUsersIds,
HydraApi,
logger,
SystemPath,
writeSteamShortcuts,
@@ -15,6 +14,7 @@ import fs from "node:fs";
import axios from "axios";
import path from "node:path";
import { ASSETS_PATH } from "@main/constants";
import { getGameAssets } from "../catalogue/get-game-assets";
const downloadAsset = async (downloadPath: string, url?: string | null) => {
try {
@@ -41,7 +41,7 @@ const downloadAsset = async (downloadPath: string, url?: string | null) => {
const downloadAssetsFromSteam = async (
shop: GameShop,
objectId: string,
assets: GameStats["assets"]
assets: ShopAssets | null
) => {
const gameAssetsPath = path.join(ASSETS_PATH, `${shop}-${objectId}`);
@@ -86,9 +86,7 @@ const createSteamShortcut = async (
throw new Error("No executable path found for game");
}
const { assets } = await HydraApi.get<GameStats>(
`/games/${shop}/${objectId}/stats`
);
const assets = await getGameAssets(objectId, shop);
const steamUserIds = await getSteamUsersIds();

View File

@@ -0,0 +1,13 @@
import { registerEvent } from "../register-event";
import { DECKY_PLUGINS_LOCATION } from "@main/constants";
import fs from "node:fs";
import path from "node:path";
const checkHomebrewFolderExists = async (
_event: Electron.IpcMainInvokeEvent
): Promise<boolean> => {
const homebrewPath = path.dirname(DECKY_PLUGINS_LOCATION);
return fs.existsSync(homebrewPath);
};
registerEvent("checkHomebrewFolderExists", checkHomebrewFolderExists);

View File

@@ -0,0 +1,94 @@
import { registerEvent } from "../register-event";
import { logger, HydraApi } from "@main/services";
import { HYDRA_DECKY_PLUGIN_LOCATION } from "@main/constants";
import fs from "node:fs";
import path from "node:path";
interface DeckyReleaseInfo {
version: string;
downloadUrl: string;
}
const getHydraDeckyPluginInfo = async (
_event: Electron.IpcMainInvokeEvent
): Promise<{
installed: boolean;
version: string | null;
path: string;
outdated: boolean;
expectedVersion: string | null;
}> => {
try {
// Fetch the expected version from API
let expectedVersion: string | null = null;
try {
const releaseInfo = await HydraApi.get<DeckyReleaseInfo>(
"/decky/release",
{},
{ needsAuth: false }
);
expectedVersion = releaseInfo.version;
} catch (error) {
logger.error("Failed to fetch Decky release info:", error);
}
// Check if plugin folder exists
if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
logger.log("Hydra Decky plugin not installed");
return {
installed: false,
version: null,
path: HYDRA_DECKY_PLUGIN_LOCATION,
outdated: true,
expectedVersion,
};
}
// Check if package.json exists
const packageJsonPath = path.join(
HYDRA_DECKY_PLUGIN_LOCATION,
"package.json"
);
if (!fs.existsSync(packageJsonPath)) {
logger.log("Hydra Decky plugin package.json not found");
return {
installed: false,
version: null,
path: HYDRA_DECKY_PLUGIN_LOCATION,
outdated: true,
expectedVersion,
};
}
// Read and parse package.json
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8");
const packageJson = JSON.parse(packageJsonContent);
const version = packageJson.version;
const outdated = expectedVersion ? version !== expectedVersion : false;
logger.log(
`Hydra Decky plugin installed, version: ${version}, expected: ${expectedVersion}, outdated: ${outdated}`
);
return {
installed: true,
version,
path: HYDRA_DECKY_PLUGIN_LOCATION,
outdated,
expectedVersion,
};
} catch (error) {
logger.error("Failed to get plugin info:", error);
return {
installed: false,
version: null,
path: HYDRA_DECKY_PLUGIN_LOCATION,
outdated: true,
expectedVersion: null,
};
}
};
registerEvent("getHydraDeckyPluginInfo", getHydraDeckyPluginInfo);

View File

@@ -0,0 +1,50 @@
import { registerEvent } from "../register-event";
import { logger, DeckyPlugin } from "@main/services";
import { HYDRA_DECKY_PLUGIN_LOCATION } from "@main/constants";
const installHydraDeckyPlugin = async (
_event: Electron.IpcMainInvokeEvent
): Promise<{
success: boolean;
path: string;
currentVersion: string | null;
expectedVersion: string;
error?: string;
}> => {
try {
logger.log("Installing/updating Hydra Decky plugin...");
const result = await DeckyPlugin.checkPluginVersion();
if (result.exists && !result.outdated) {
logger.log("Plugin installed successfully");
return {
success: true,
path: HYDRA_DECKY_PLUGIN_LOCATION,
currentVersion: result.currentVersion,
expectedVersion: result.expectedVersion,
};
} else {
logger.error("Failed to install plugin");
return {
success: false,
path: HYDRA_DECKY_PLUGIN_LOCATION,
currentVersion: result.currentVersion,
expectedVersion: result.expectedVersion,
error: "Plugin installation failed",
};
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error("Failed to install plugin:", error);
return {
success: false,
path: HYDRA_DECKY_PLUGIN_LOCATION,
currentVersion: null,
expectedVersion: "unknown",
error: errorMessage,
};
}
};
registerEvent("installHydraDeckyPlugin", installHydraDeckyPlugin);

View File

@@ -3,9 +3,9 @@ import type { ShopAssets } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const gamesShopAssetsSublevel = db.sublevel<string, ShopAssets>(
levelKeys.gameShopAssets,
{
valueEncoding: "json",
}
);
export const gamesShopAssetsSublevel = db.sublevel<
string,
ShopAssets & { updatedAt: number }
>(levelKeys.gameShopAssets, {
valueEncoding: "json",
});

View File

@@ -16,6 +16,7 @@ import {
startMainLoop,
Ludusavi,
Lock,
DeckyPlugin,
} from "@main/services";
export const loadState = async () => {
@@ -49,6 +50,10 @@ export const loadState = async () => {
Ludusavi.copyConfigFileToUserData();
Ludusavi.copyBinaryToUserData();
if (process.platform === "linux") {
DeckyPlugin.checkAndUpdateIfOutdated();
}
await HydraApi.setupApi().then(() => {
uploadGamesBatch();
// WSClient.connect();

View File

@@ -0,0 +1,400 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import axios from "axios";
import sudo from "sudo-prompt";
import { app } from "electron";
import {
HYDRA_DECKY_PLUGIN_LOCATION,
DECKY_PLUGINS_LOCATION,
} from "@main/constants";
import { logger } from "./logger";
import { SevenZip } from "./7zip";
import { SystemPath } from "./system-path";
import { HydraApi } from "./hydra-api";
interface DeckyReleaseInfo {
version: string;
downloadUrl: string;
}
export class DeckyPlugin {
private static releaseInfo: DeckyReleaseInfo | null = null;
private static async getDeckyReleaseInfo(): Promise<DeckyReleaseInfo> {
if (this.releaseInfo) {
return this.releaseInfo;
}
try {
const response = await HydraApi.get<DeckyReleaseInfo>(
"/decky/release",
{},
{ needsAuth: false }
);
this.releaseInfo = response;
return response;
} catch (error) {
logger.error("Failed to fetch Decky release info:", error);
throw error;
}
}
private static getPackageJsonPath(): string {
return path.join(HYDRA_DECKY_PLUGIN_LOCATION, "package.json");
}
private static async downloadPlugin(): Promise<string> {
logger.log("Downloading Hydra Decky plugin...");
const releaseInfo = await this.getDeckyReleaseInfo();
const tempDir = SystemPath.getPath("temp");
const zipPath = path.join(tempDir, "Hydra.zip");
const response = await axios.get(releaseInfo.downloadUrl, {
responseType: "arraybuffer",
});
await fs.promises.writeFile(zipPath, response.data);
logger.log(`Plugin downloaded to: ${zipPath}`);
return zipPath;
}
private static async extractPlugin(zipPath: string): Promise<string> {
logger.log("Extracting Hydra Decky plugin...");
const tempDir = SystemPath.getPath("temp");
const extractPath = path.join(tempDir, "hydra-decky-plugin");
if (fs.existsSync(extractPath)) {
await fs.promises.rm(extractPath, { recursive: true, force: true });
}
await fs.promises.mkdir(extractPath, { recursive: true });
return new Promise((resolve, reject) => {
SevenZip.extractFile(
{
filePath: zipPath,
outputPath: extractPath,
},
() => {
logger.log(`Plugin extracted to: ${extractPath}`);
resolve(extractPath);
},
() => {
reject(new Error("Failed to extract plugin"));
}
);
});
}
private static needsSudo(): boolean {
try {
if (fs.existsSync(DECKY_PLUGINS_LOCATION)) {
fs.accessSync(DECKY_PLUGINS_LOCATION, fs.constants.W_OK);
return false;
}
const parentDir = path.dirname(DECKY_PLUGINS_LOCATION);
if (fs.existsSync(parentDir)) {
fs.accessSync(parentDir, fs.constants.W_OK);
return false;
}
return true;
} catch (error) {
if (
error &&
typeof error === "object" &&
"code" in error &&
(error.code === "EACCES" || error.code === "EPERM")
) {
return true;
}
throw error;
}
}
private static async installPluginWithSudo(
extractPath: string
): Promise<void> {
logger.log("Installing plugin with sudo...");
const username = os.userInfo().username;
const sourcePath = path.join(extractPath, "Hydra");
return new Promise((resolve, reject) => {
const command = `mkdir -p "${DECKY_PLUGINS_LOCATION}" && rm -rf "${HYDRA_DECKY_PLUGIN_LOCATION}" && cp -r "${sourcePath}" "${HYDRA_DECKY_PLUGIN_LOCATION}" && chown -R ${username}: "${DECKY_PLUGINS_LOCATION}"`;
sudo.exec(
command,
{ name: app.getName() },
(sudoError, _stdout, stderr) => {
if (sudoError) {
logger.error("Failed to install plugin with sudo:", sudoError);
reject(sudoError);
} else {
logger.log("Plugin installed successfully with sudo");
if (stderr) {
logger.log("Sudo stderr:", stderr);
}
resolve();
}
}
);
});
}
private static async installPluginWithoutSudo(
extractPath: string
): Promise<void> {
logger.log("Installing plugin without sudo...");
const sourcePath = path.join(extractPath, "Hydra");
if (!fs.existsSync(DECKY_PLUGINS_LOCATION)) {
await fs.promises.mkdir(DECKY_PLUGINS_LOCATION, { recursive: true });
}
if (fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
await fs.promises.rm(HYDRA_DECKY_PLUGIN_LOCATION, {
recursive: true,
force: true,
});
}
await fs.promises.cp(sourcePath, HYDRA_DECKY_PLUGIN_LOCATION, {
recursive: true,
});
logger.log("Plugin installed successfully");
}
private static async installPlugin(extractPath: string): Promise<void> {
if (this.needsSudo()) {
await this.installPluginWithSudo(extractPath);
} else {
await this.installPluginWithoutSudo(extractPath);
}
}
private static async updatePlugin(): Promise<void> {
let zipPath: string | null = null;
let extractPath: string | null = null;
try {
zipPath = await this.downloadPlugin();
extractPath = await this.extractPlugin(zipPath);
await this.installPlugin(extractPath);
logger.log("Plugin update completed successfully");
} catch (error) {
logger.error("Failed to update plugin:", error);
throw error;
} finally {
if (zipPath) {
try {
await fs.promises.rm(zipPath, { force: true });
logger.log("Cleaned up downloaded zip file");
} catch (cleanupError) {
logger.error("Failed to clean up zip file:", cleanupError);
}
}
if (extractPath) {
try {
await fs.promises.rm(extractPath, { recursive: true, force: true });
logger.log("Cleaned up extraction directory");
} catch (cleanupError) {
logger.error(
"Failed to clean up extraction directory:",
cleanupError
);
}
}
}
}
public static async checkAndUpdateIfOutdated(): Promise<void> {
if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
logger.log("Hydra Decky plugin not installed, skipping update check");
return;
}
const packageJsonPath = this.getPackageJsonPath();
try {
if (!fs.existsSync(packageJsonPath)) {
logger.log(
"Hydra Decky plugin package.json not found, skipping update"
);
return;
}
const releaseInfo = await this.getDeckyReleaseInfo();
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8");
const packageJson = JSON.parse(packageJsonContent);
const currentVersion = packageJson.version;
const isOutdated = currentVersion !== releaseInfo.version;
if (isOutdated) {
logger.log(
`Hydra Decky plugin is outdated. Current: ${currentVersion}, Expected: ${releaseInfo.version}. Updating...`
);
await this.updatePlugin();
logger.log("Hydra Decky plugin updated successfully");
} else {
logger.log(`Hydra Decky plugin is up to date (${currentVersion})`);
}
} catch (error) {
logger.error(`Error checking/updating Hydra Decky plugin: ${error}`);
}
}
public static async checkPluginVersion(): Promise<{
exists: boolean;
outdated: boolean;
currentVersion: string | null;
expectedVersion: string;
}> {
try {
const releaseInfo = await this.getDeckyReleaseInfo();
if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
logger.log("Hydra Decky plugin folder not found, installing...");
try {
await this.updatePlugin();
// Read the actual installed version from package.json
const packageJsonPath = this.getPackageJsonPath();
if (fs.existsSync(packageJsonPath)) {
const packageJsonContent = fs.readFileSync(
packageJsonPath,
"utf-8"
);
const packageJson = JSON.parse(packageJsonContent);
return {
exists: true,
outdated: false,
currentVersion: packageJson.version,
expectedVersion: releaseInfo.version,
};
}
return {
exists: true,
outdated: false,
currentVersion: releaseInfo.version,
expectedVersion: releaseInfo.version,
};
} catch (error) {
logger.error("Failed to install plugin:", error);
return {
exists: false,
outdated: true,
currentVersion: null,
expectedVersion: releaseInfo.version,
};
}
}
const packageJsonPath = this.getPackageJsonPath();
try {
if (!fs.existsSync(packageJsonPath)) {
logger.log(
"Hydra Decky plugin package.json not found, installing..."
);
await this.updatePlugin();
// Read the actual installed version from package.json
if (fs.existsSync(packageJsonPath)) {
const packageJsonContent = fs.readFileSync(
packageJsonPath,
"utf-8"
);
const packageJson = JSON.parse(packageJsonContent);
return {
exists: true,
outdated: false,
currentVersion: packageJson.version,
expectedVersion: releaseInfo.version,
};
}
return {
exists: true,
outdated: false,
currentVersion: releaseInfo.version,
expectedVersion: releaseInfo.version,
};
}
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8");
const packageJson = JSON.parse(packageJsonContent);
const currentVersion = packageJson.version;
const isOutdated = currentVersion !== releaseInfo.version;
if (isOutdated) {
logger.log(
`Hydra Decky plugin is outdated. Current: ${currentVersion}, Expected: ${releaseInfo.version}`
);
await this.updatePlugin();
if (fs.existsSync(packageJsonPath)) {
const updatedPackageJsonContent = fs.readFileSync(
packageJsonPath,
"utf-8"
);
const updatedPackageJson = JSON.parse(updatedPackageJsonContent);
return {
exists: true,
outdated: false,
currentVersion: updatedPackageJson.version,
expectedVersion: releaseInfo.version,
};
}
return {
exists: true,
outdated: false,
currentVersion: releaseInfo.version,
expectedVersion: releaseInfo.version,
};
} else {
logger.log(`Hydra Decky plugin is up to date (${currentVersion})`);
}
return {
exists: true,
outdated: isOutdated,
currentVersion,
expectedVersion: releaseInfo.version,
};
} catch (error) {
logger.error(`Error checking Hydra Decky plugin version: ${error}`);
return {
exists: false,
outdated: true,
currentVersion: null,
expectedVersion: releaseInfo.version,
};
}
} catch (error) {
logger.error(`Error fetching release info: ${error}`);
return {
exists: false,
outdated: true,
currentVersion: null,
expectedVersion: "unknown",
};
}
}
}

View File

@@ -17,3 +17,4 @@ export * from "./system-path";
export * from "./library-sync";
export * from "./wine";
export * from "./lock";
export * from "./decky-plugin";

View File

@@ -58,7 +58,11 @@ export const mergeWithRemoteGames = async () => {
});
}
const localGameShopAsset = await gamesShopAssetsSublevel.get(gameKey);
await gamesShopAssetsSublevel.put(gameKey, {
updatedAt: Date.now(),
...localGameShopAsset,
shop: game.shop,
objectId: game.objectId,
title: localGame?.title || game.title, // Preserve local title if it exists

View File

@@ -10,7 +10,7 @@ import icon from "@resources/icon.png?asset";
import { NotificationOptions, toXmlString } from "./xml";
import { logger } from "../logger";
import { WindowManager } from "../window-manager";
import type { Game, GameStats, UserPreferences, UserProfile } from "@types";
import type { Game, UserPreferences, UserProfile } from "@types";
import { db, levelKeys } from "@main/level";
import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update";
import { SystemPath } from "../system-path";
@@ -108,15 +108,14 @@ export const publishNewFriendRequestNotification = async (
};
export const publishFriendStartedPlayingGameNotification = async (
friend: UserProfile,
game: GameStats
friend: UserProfile
) => {
new Notification({
title: t("friend_started_playing_game", {
ns: "notifications",
displayName: friend.displayName,
}),
body: game.assets?.title,
body: friend?.currentGame?.title,
icon: friend?.profileImageUrl
? await downloadImage(friend.profileImageUrl)
: trayIcon,

View File

@@ -2,7 +2,7 @@ import type { FriendGameSession } from "@main/generated/envelope";
import { db, levelKeys } from "@main/level";
import { HydraApi } from "@main/services/hydra-api";
import { publishFriendStartedPlayingGameNotification } from "@main/services/notifications";
import type { GameStats, UserPreferences, UserProfile } from "@types";
import type { UserPreferences, UserProfile } from "@types";
export const friendGameSessionEvent = async (payload: FriendGameSession) => {
const userPreferences = await db.get<string, UserPreferences | null>(
@@ -14,12 +14,9 @@ export const friendGameSessionEvent = async (payload: FriendGameSession) => {
if (userPreferences?.friendStartGameNotificationsEnabled === false) return;
const [friend, gameStats] = await Promise.all([
HydraApi.get<UserProfile>(`/users/${payload.friendId}`),
HydraApi.get<GameStats>(`/games/steam/${payload.objectId}/stats`),
]).catch(() => [null, null]);
const friend = await HydraApi.get<UserProfile>(`/users/${payload.friendId}`);
if (friend && gameStats) {
publishFriendStartedPlayingGameNotification(friend, gameStats);
if (friend) {
publishFriendStartedPlayingGameNotification(friend);
}
};

View File

@@ -17,7 +17,6 @@ import type {
Theme,
FriendRequestSync,
ShortcutLocation,
ShopAssets,
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
} from "@types";
@@ -67,8 +66,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("searchGames", payload, take, skip),
getCatalogue: (category: CatalogueCategory) =>
ipcRenderer.invoke("getCatalogue", category),
saveGameShopAssets: (objectId: string, shop: GameShop, assets: ShopAssets) =>
ipcRenderer.invoke("saveGameShopAssets", objectId, shop, assets),
getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
ipcRenderer.invoke("getGameShopDetails", objectId, shop, language),
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
@@ -76,7 +73,33 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getHowLongToBeat", objectId, shop),
getGameStats: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameStats", objectId, shop),
getGameAssets: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameAssets", objectId, shop),
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
createGameReview: (
shop: GameShop,
objectId: string,
reviewHtml: string,
score: number
) =>
ipcRenderer.invoke("createGameReview", shop, objectId, reviewHtml, score),
getGameReviews: (
shop: GameShop,
objectId: string,
take?: number,
skip?: number,
sortBy?: string
) => ipcRenderer.invoke("getGameReviews", shop, objectId, take, skip, sortBy),
voteReview: (
shop: GameShop,
objectId: string,
reviewId: string,
voteType: "upvote" | "downvote"
) => ipcRenderer.invoke("voteReview", shop, objectId, reviewId, voteType),
deleteReview: (shop: GameShop, objectId: string, reviewId: string) =>
ipcRenderer.invoke("deleteReview", shop, objectId, reviewId),
checkGameReview: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("checkGameReview", shop, objectId),
onUpdateAchievements: (
objectId: string,
shop: GameShop,
@@ -362,6 +385,10 @@ contextBridge.exposeInMainWorld("electron", {
getBadges: () => ipcRenderer.invoke("getBadges"),
canInstallCommonRedist: () => ipcRenderer.invoke("canInstallCommonRedist"),
installCommonRedist: () => ipcRenderer.invoke("installCommonRedist"),
installHydraDeckyPlugin: () => ipcRenderer.invoke("installHydraDeckyPlugin"),
getHydraDeckyPluginInfo: () => ipcRenderer.invoke("getHydraDeckyPluginInfo"),
checkHomebrewFolderExists: () =>
ipcRenderer.invoke("checkHomebrewFolderExists"),
platform: process.platform,
/* Auto update */

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

View File

@@ -1,11 +0,0 @@
@use "../../scss/globals.scss";
.confirm-modal {
&__actions {
display: flex;
width: 100%;
justify-content: flex-end;
align-items: center;
gap: globals.$spacing-unit;
}
}

View File

@@ -1,57 +0,0 @@
import { useTranslation } from "react-i18next";
import { Button, Modal } from "@renderer/components";
import "./confirm-modal.scss";
export interface ConfirmModalProps {
visible: boolean;
title: string;
description?: string;
onClose: () => void;
onConfirm: () => Promise<void> | void;
confirmLabel?: string;
cancelLabel?: string;
confirmTheme?: "primary" | "outline" | "danger";
confirmDisabled?: boolean;
}
export function ConfirmModal({
visible,
title,
description,
onClose,
onConfirm,
confirmLabel,
cancelLabel,
confirmTheme = "outline",
confirmDisabled = false,
}: ConfirmModalProps) {
const { t } = useTranslation();
const handleConfirm = async () => {
await onConfirm();
onClose();
};
return (
<Modal
visible={visible}
title={title}
description={description}
onClose={onClose}
>
<div className="confirm-modal__actions">
<Button
onClick={handleConfirm}
theme={confirmTheme}
disabled={confirmDisabled}
>
{confirmLabel || t("confirm")}
</Button>
<Button onClick={onClose} theme="primary">
{cancelLabel || t("cancel")}
</Button>
</div>
</Modal>
);
}

View File

@@ -8,7 +8,7 @@
&__actions {
display: flex;
align-self: flex-end;
gap: calc(globals.$spacing-unit * 2);
gap: globals.$spacing-unit;
}
&__description {
font-size: 16px;

View File

@@ -42,7 +42,7 @@ export function ConfirmationModal({
{cancelButtonLabel}
</Button>
<Button
theme="danger"
theme="primary"
disabled={buttonsIsDisabled}
onClick={onConfirm}
>

View File

@@ -72,7 +72,11 @@
display: flex;
color: globals.$muted-color;
font-size: 12px;
align-items: flex-end;
align-items: center;
.star-rating {
align-items: center;
}
}
&__title-container {

View File

@@ -7,6 +7,7 @@ import "./game-card.scss";
import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge";
import { StarRating } from "../star-rating/star-rating";
import { useCallback, useState, useMemo } from "react";
import { useFormat, useRepacks } from "@renderer/hooks";
@@ -107,6 +108,14 @@ export function GameCard({ game, ...props }: GameCardProps) {
{stats ? numberFormatter.format(stats.playerCount) : "…"}
</span>
</div>
<div className="game-card__specifics-item">
<StarRating
rating={stats?.averageScore || null}
size={14}
showCalculating={!!(stats && stats.averageScore === null)}
calculatingText={t("calculating")}
/>
</div>
</div>
</div>
</div>

View File

@@ -14,9 +14,13 @@ import {
} from "@primer/octicons-react";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { LibraryGame } from "@types";
import { ContextMenu, ContextMenuItemData, ContextMenuProps } from "..";
import { ConfirmModal } from "@renderer/components/confirm-modal/confirm-modal";
import { useGameActions } from "..";
import {
ContextMenu,
ContextMenuItemData,
ContextMenuProps,
ConfirmationModal,
useGameActions,
} from "..";
interface GameContextMenuProps extends Omit<ContextMenuProps, "items"> {
game: LibraryGame;
@@ -195,36 +199,40 @@ export function GameContextMenu({
}
/>
<ConfirmModal
<ConfirmationModal
visible={showConfirmRemoveLibrary}
title={t("remove_from_library_title")}
description={t("remove_from_library_description", { game: game.title })}
descriptionText={t("remove_from_library_description", {
game: game.title,
})}
onClose={() => {
setShowConfirmRemoveLibrary(false);
onClose();
}}
onConfirm={async () => {
setShowConfirmRemoveLibrary(false);
onClose();
await handleRemoveFromLibrary();
}}
confirmLabel={t("remove")}
cancelLabel={t("cancel")}
confirmTheme="danger"
cancelButtonLabel={t("cancel")}
confirmButtonLabel={t("remove")}
/>
<ConfirmModal
<ConfirmationModal
visible={showConfirmRemoveFiles}
title={t("remove_files")}
description={t("delete_modal_description", { ns: "downloads" })}
descriptionText={t("delete_modal_description", { ns: "downloads" })}
onClose={() => {
setShowConfirmRemoveFiles(false);
onClose();
}}
onConfirm={async () => {
setShowConfirmRemoveFiles(false);
onClose();
await handleRemoveFiles();
}}
confirmLabel={t("remove")}
cancelLabel={t("cancel")}
confirmTheme="danger"
cancelButtonLabel={t("cancel")}
confirmButtonLabel={t("remove")}
/>
</>
);

View File

@@ -18,3 +18,4 @@ export * from "./debrid-badge/debrid-badge";
export * from "./context-menu/context-menu";
export * from "./game-context-menu/game-context-menu";
export * from "./game-context-menu/use-game-actions";
export * from "./star-rating/star-rating";

View File

@@ -71,6 +71,23 @@
opacity: 1;
}
}
&--decky {
background: linear-gradient(
135deg,
rgba(22, 177, 149, 0.2) 0%,
rgba(62, 98, 192, 0.1) 100%
);
color: globals.$muted-color;
&:hover {
background: linear-gradient(
135deg,
rgba(22, 177, 149, 0.3) 0%,
rgba(62, 98, 192, 0.15) 100%
);
}
}
}
&__menu-item-button {
@@ -123,6 +140,11 @@
padding-bottom: globals.$spacing-unit;
}
&__bottom-buttons {
display: flex;
flex-direction: column;
}
&__help-button {
color: globals.$muted-color;
padding: globals.$spacing-unit calc(globals.$spacing-unit * 2);

View File

@@ -5,7 +5,7 @@ import { Tooltip } from "react-tooltip";
import type { LibraryGame } from "@types";
import { TextField } from "@renderer/components";
import { TextField, ConfirmationModal } from "@renderer/components";
import {
useDownload,
useLibrary,
@@ -31,6 +31,7 @@ import { SidebarGameItem } from "./sidebar-game-item";
import { SidebarAddingCustomGameModal } from "./sidebar-adding-custom-game-modal";
import { setFriendRequestCount } from "@renderer/features/user-details-slice";
import { useDispatch } from "react-redux";
import deckyIcon from "@renderer/assets/icons/decky.png";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
@@ -47,6 +48,13 @@ export function Sidebar() {
const { t } = useTranslation("sidebar");
const { library, updateLibrary } = useLibrary();
const [deckyPluginInfo, setDeckyPluginInfo] = useState<{
installed: boolean;
version: string | null;
outdated: boolean;
}>({ installed: false, version: null, outdated: false });
const [homebrewFolderExists, setHomebrewFolderExists] = useState(false);
const [showDeckyConfirmModal, setShowDeckyConfirmModal] = useState(false);
const navigate = useNavigate();
const [filteredLibrary, setFilteredLibrary] = useState<LibraryGame[]>([]);
@@ -66,7 +74,7 @@ export function Sidebar() {
const { lastPacket, progress } = useDownload();
const { showWarningToast } = useToast();
const { showWarningToast, showSuccessToast, showErrorToast } = useToast();
const [showPlayableOnly, setShowPlayableOnly] = useState(false);
const [showAddGameModal, setShowAddGameModal] = useState(false);
@@ -83,10 +91,68 @@ export function Sidebar() {
setShowAddGameModal(false);
};
const loadDeckyPluginInfo = async () => {
if (window.electron.platform !== "linux") return;
try {
const [info, folderExists] = await Promise.all([
window.electron.getHydraDeckyPluginInfo(),
window.electron.checkHomebrewFolderExists(),
]);
setDeckyPluginInfo({
installed: info.installed,
version: info.version,
outdated: info.outdated,
});
setHomebrewFolderExists(folderExists);
} catch (error) {
console.error("Failed to load Decky plugin info:", error);
}
};
const handleInstallHydraDeckyPlugin = () => {
if (deckyPluginInfo.installed && !deckyPluginInfo.outdated) {
return;
}
setShowDeckyConfirmModal(true);
};
const handleConfirmDeckyInstallation = async () => {
setShowDeckyConfirmModal(false);
try {
const result = await window.electron.installHydraDeckyPlugin();
if (result.success) {
showSuccessToast(
t("decky_plugin_installed", {
version: result.currentVersion,
})
);
await loadDeckyPluginInfo();
} else {
showErrorToast(
t("decky_plugin_installation_failed", {
error: result.error || "Unknown error",
})
);
}
} catch (error) {
showErrorToast(
t("decky_plugin_installation_error", { error: String(error) })
);
}
};
useEffect(() => {
updateLibrary();
}, [lastPacket?.gameId, updateLibrary]);
useEffect(() => {
loadDeckyPluginInfo();
}, []);
useEffect(() => {
const unsubscribe = window.electron.onSyncFriendRequests((result) => {
dispatch(setFriendRequestCount(result.friendRequestCount));
@@ -244,6 +310,30 @@ export function Sidebar() {
</button>
</li>
))}
{window.electron.platform === "linux" && homebrewFolderExists && (
<li className="sidebar__menu-item sidebar__menu-item--decky">
<button
type="button"
className="sidebar__menu-item-button"
onClick={handleInstallHydraDeckyPlugin}
>
<img
src={deckyIcon}
alt="Decky"
style={{ width: 16, height: 16 }}
/>
<span>
{deckyPluginInfo.installed && !deckyPluginInfo.outdated
? t("decky_plugin_installed_version", {
version: deckyPluginInfo.version,
})
: deckyPluginInfo.installed && deckyPluginInfo.outdated
? t("update_decky_plugin")
: t("install_decky_plugin")}
</span>
</button>
</li>
)}
</ul>
</section>
@@ -321,18 +411,20 @@ export function Sidebar() {
</div>
</div>
{hasActiveSubscription && (
<button
type="button"
className="sidebar__help-button"
data-open-support-chat
>
<div className="sidebar__help-button-icon">
<CommentDiscussionIcon size={14} />
</div>
<span>{t("need_help")}</span>
</button>
)}
<div className="sidebar__bottom-buttons">
{hasActiveSubscription && (
<button
type="button"
className="sidebar__help-button"
data-open-support-chat
>
<div className="sidebar__help-button-icon">
<CommentDiscussionIcon size={14} />
</div>
<span>{t("need_help")}</span>
</button>
)}
</div>
<button
type="button"
@@ -345,6 +437,24 @@ export function Sidebar() {
onClose={handleCloseAddGameModal}
/>
<ConfirmationModal
visible={showDeckyConfirmModal}
title={
deckyPluginInfo.installed && deckyPluginInfo.outdated
? t("update_decky_plugin_title")
: t("install_decky_plugin_title")
}
descriptionText={
deckyPluginInfo.installed && deckyPluginInfo.outdated
? t("update_decky_plugin_message")
: t("install_decky_plugin_message")
}
onClose={() => setShowDeckyConfirmModal(false)}
onConfirm={handleConfirmDeckyInstallation}
cancelButtonLabel={t("cancel")}
confirmButtonLabel={t("confirm")}
/>
<Tooltip id="add-custom-game-tooltip" />
<Tooltip id="show-playable-only-tooltip" />
</aside>

View File

@@ -0,0 +1 @@
export * from "./star-rating";

View File

@@ -0,0 +1,54 @@
@use "../../scss/globals.scss";
.star-rating {
display: flex;
align-items: center;
gap: 2px;
&__star {
color: globals.$muted-color;
transition: color ease 0.2s;
&--filled {
color: #ffffff;
}
&--empty {
color: globals.$muted-color;
}
&--half {
color: #ffffff;
position: absolute;
top: 0;
left: 0;
clip-path: polygon(0 0, 50% 0, 50% 100%, 0 100%);
}
}
&__half-star {
position: relative;
display: inline-block;
}
&__value {
margin-left: 4px;
font-size: 12px;
color: globals.$muted-color;
font-weight: 500;
}
&__calculating-text,
&__no-rating-text {
margin-left: 4px;
font-size: 12px;
color: globals.$muted-color;
}
&--calculating,
&--no-rating {
.star-rating__star {
color: globals.$muted-color;
}
}
}

View File

@@ -0,0 +1,77 @@
import { StarIcon, StarFillIcon } from "@primer/octicons-react";
import "./star-rating.scss";
export interface StarRatingProps {
rating: number | null;
maxStars?: number;
size?: number;
showCalculating?: boolean;
calculatingText?: string;
hideIcon?: boolean;
}
export function StarRating({
rating,
maxStars = 5,
size = 12,
showCalculating = false,
calculatingText = "Calculating",
hideIcon = false,
}: Readonly<StarRatingProps>) {
if (rating === null && showCalculating) {
return (
<div className="star-rating star-rating--calculating">
{!hideIcon && <StarIcon size={size} />}
<span className="star-rating__calculating-text">{calculatingText}</span>
</div>
);
}
if (rating === null || rating === undefined) {
return (
<div className="star-rating star-rating--no-rating">
{!hideIcon && <StarIcon size={size} />}
<span className="star-rating__no-rating-text"></span>
</div>
);
}
const filledStars = Math.floor(rating);
const hasHalfStar = rating % 1 >= 0.5;
const emptyStars = maxStars - filledStars - (hasHalfStar ? 1 : 0);
return (
<div className="star-rating">
{Array.from({ length: filledStars }, (_, index) => (
<StarFillIcon
key={`filled-${index}`}
size={size}
className="star-rating__star star-rating__star--filled"
/>
))}
{hasHalfStar && (
<div className="star-rating__half-star" key="half-star">
<StarIcon
size={size}
className="star-rating__star star-rating__star--empty"
/>
<StarFillIcon
size={size}
className="star-rating__star star-rating__star--half"
/>
</div>
)}
{Array.from({ length: emptyStars }, (_, index) => (
<StarIcon
key={`empty-${index}`}
size={size}
className="star-rating__star star-rating__star--empty"
/>
))}
<span className="star-rating__value">{rating.toFixed(1)}</span>
</div>
);
}

View File

@@ -142,29 +142,23 @@ export function GameDetailsContextProvider({
}
});
const statsPromise = window.electron
.getGameStats(objectId, shop)
.then((result) => {
if (abortController.signal.aborted) return null;
setStats(result);
return result;
});
window.electron.getGameStats(objectId, shop).then((result) => {
if (abortController.signal.aborted) return;
setStats(result);
});
Promise.all([shopDetailsPromise, statsPromise])
.then(([_, stats]) => {
if (stats) {
const assets = stats.assets;
if (assets) {
window.electron.saveGameShopAssets(objectId, shop, assets);
const assetsPromise = window.electron.getGameAssets(objectId, shop);
setShopDetails((prev) => {
if (!prev) return null;
return {
...prev,
assets,
};
});
}
Promise.all([shopDetailsPromise, assetsPromise])
.then(([_, assets]) => {
if (assets) {
setShopDetails((prev) => {
if (!prev) return null;
return {
...prev,
assets,
};
});
}
})
.finally(() => {
@@ -207,8 +201,8 @@ export function GameDetailsContextProvider({
setShowRepacksModal(true);
try {
window.history.replaceState({}, document.title, location.pathname);
} catch (_e) {
void _e;
} catch (e) {
console.error(e);
}
}
}, [location]);

View File

@@ -39,6 +39,7 @@ import type {
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
UserLibraryResponse,
Game,
} from "@types";
import type { AxiosProgressEvent } from "axios";
import type disk from "diskusage";
@@ -77,11 +78,6 @@ declare global {
skip: number
) => Promise<{ edges: CatalogueSearchResult[]; count: number }>;
getCatalogue: (category: CatalogueCategory) => Promise<ShopAssets[]>;
saveGameShopAssets: (
objectId: string,
shop: GameShop,
assets: ShopAssets
) => Promise<void>;
getGameShopDetails: (
objectId: string,
shop: GameShop,
@@ -93,7 +89,39 @@ declare global {
shop: GameShop
) => Promise<HowLongToBeatCategory[] | null>;
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
getGameAssets: (
objectId: string,
shop: GameShop
) => Promise<ShopAssets | null>;
getTrendingGames: () => Promise<TrendingGame[]>;
createGameReview: (
shop: GameShop,
objectId: string,
reviewHtml: string,
score: number
) => Promise<void>;
getGameReviews: (
shop: GameShop,
objectId: string,
take?: number,
skip?: number,
sortBy?: string
) => Promise<any[]>;
voteReview: (
shop: GameShop,
objectId: string,
reviewId: string,
voteType: "upvote" | "downvote"
) => Promise<void>;
deleteReview: (
shop: GameShop,
objectId: string,
reviewId: string
) => Promise<void>;
checkGameReview: (
shop: GameShop,
objectId: string
) => Promise<{ hasReviewed: boolean }>;
onUpdateAchievements: (
objectId: string,
shop: GameShop,
@@ -311,6 +339,21 @@ declare global {
getBadges: () => Promise<Badge[]>;
canInstallCommonRedist: () => Promise<boolean>;
installCommonRedist: () => Promise<void>;
installHydraDeckyPlugin: () => Promise<{
success: boolean;
path: string;
currentVersion: string | null;
expectedVersion: string;
error?: string;
}>;
getHydraDeckyPluginInfo: () => Promise<{
installed: boolean;
version: string | null;
path: string;
outdated: boolean;
expectedVersion: string | null;
}>;
checkHomebrewFolderExists: () => Promise<boolean>;
onCommonRedistProgress: (
cb: (value: { log: string; complete: boolean }) => void
) => () => Electron.IpcRenderer;

View File

@@ -104,3 +104,11 @@ export const generateRandomGradient = (): string => {
// Return as data URL that works in img tags
return `data:image/svg+xml;base64,${btoa(svgContent)}`;
};
export const formatNumber = (num: number): string => {
return new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
maximumFractionDigits: 1,
}).format(num);
};

View File

@@ -68,6 +68,7 @@ export function useUserDetails() {
username: userDetails?.username || "",
subscription: userDetails?.subscription || null,
featurebaseJwt: userDetails?.featurebaseJwt || "",
karma: userDetails?.karma || 0,
});
},
[

View File

@@ -2,20 +2,30 @@
.description-header {
width: 100%;
padding: calc(globals.$spacing-unit * 1.5);
padding: calc(globals.$spacing-unit * 2);
display: flex;
justify-content: space-between;
align-items: center;
background-color: globals.$background-color;
height: 72px;
border-radius: 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
margin-bottom: calc(globals.$spacing-unit * 1);
margin-bottom: calc(globals.$spacing-unit * 1.5);
&__info {
display: flex;
gap: globals.$spacing-unit;
gap: calc(globals.$spacing-unit * 0.5);
flex-direction: column;
p {
font-size: globals.$small-font-size;
color: globals.$muted-color;
font-weight: 400;
&:first-child {
font-weight: 600;
}
}
}
}

View File

@@ -65,7 +65,7 @@
&__preview {
width: 100%;
padding: globals.$spacing-unit 0;
height: 100%;
height: auto;
display: flex;
position: relative;
overflow-x: auto;

View File

@@ -1,33 +1,116 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { PencilIcon } from "@primer/octicons-react";
import {
PencilIcon,
TrashIcon,
ClockIcon,
NoteIcon,
} from "@primer/octicons-react";
import { ThumbsUp, ThumbsDown, Star } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { motion, AnimatePresence } from "framer-motion";
import type { GameReview } from "@types";
import { HeroPanel } from "./hero";
import { DescriptionHeader } from "./description-header/description-header";
import { GallerySlider } from "./gallery-slider/gallery-slider";
import { Sidebar } from "./sidebar/sidebar";
import { EditGameModal } from "./modals";
import { EditGameModal, DeleteReviewModal } from "./modals";
import { ReviewSortOptions } from "./review-sort-options";
import { ReviewPromptBanner } from "./review-prompt-banner";
import { sanitizeHtml, AuthPage } from "@shared";
import { useTranslation } from "react-i18next";
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import { AuthPage } from "@shared";
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
import { useUserDetails, useLibrary } from "@renderer/hooks";
import { useUserDetails, useLibrary, useDate, useToast } from "@renderer/hooks";
import { useSubscription } from "@renderer/hooks/use-subscription";
import { formatNumber } from "@renderer/helpers";
import { Button } from "@renderer/components";
import "./game-details.scss";
const getScoreColorClass = (score: number): string => {
if (score >= 1 && score <= 2) return "game-details__review-score--red";
if (score >= 3 && score <= 3) return "game-details__review-score--yellow";
if (score >= 4 && score <= 5) return "game-details__review-score--green";
return "";
};
const processMediaElements = (document: Document) => {
const $images = Array.from(document.querySelectorAll("img"));
$images.forEach(($image) => {
$image.loading = "lazy";
$image.removeAttribute("width");
$image.removeAttribute("height");
$image.removeAttribute("style");
$image.style.maxWidth = "100%";
$image.style.width = "auto";
$image.style.height = "auto";
$image.style.boxSizing = "border-box";
});
// Handle videos the same way
const $videos = Array.from(document.querySelectorAll("video"));
$videos.forEach(($video) => {
$video.removeAttribute("width");
$video.removeAttribute("height");
$video.removeAttribute("style");
$video.style.maxWidth = "100%";
$video.style.width = "auto";
$video.style.height = "auto";
$video.style.boxSizing = "border-box";
});
};
const getSelectScoreColorClass = (score: number): string => {
if (score >= 1 && score <= 2) return "game-details__review-score-select--red";
if (score >= 3 && score <= 3)
return "game-details__review-score-select--yellow";
if (score >= 4 && score <= 5)
return "game-details__review-score-select--green";
return "";
};
const getRatingText = (score: number, t: (key: string) => string): string => {
switch (score) {
case 1:
return t("rating_very_negative");
case 2:
return t("rating_negative");
case 3:
return t("rating_neutral");
case 4:
return t("rating_positive");
case 5:
return t("rating_very_positive");
default:
return "";
}
};
export function GameDetailsContent() {
const heroRef = useRef<HTMLDivElement | null>(null);
const navigate = useNavigate();
const { t } = useTranslation("game_details");
const { objectId, shopDetails, game, hasNSFWContentBlocked, updateGame } =
useContext(gameDetailsContext);
const {
objectId,
shopDetails,
game,
hasNSFWContentBlocked,
updateGame,
shop,
} = useContext(gameDetailsContext);
const { showHydraCloudModal } = useSubscription();
const { userDetails, hasActiveSubscription } = useUserDetails();
const { updateLibrary } = useLibrary();
const { updateLibrary, library } = useLibrary();
const { formatDistance } = useDate();
const { showSuccessToast, showErrorToast } = useToast();
const { setShowCloudSyncModal, getGameArtifacts } =
useContext(cloudSyncContext);
@@ -40,33 +123,7 @@ export function GameDetailsContent() {
"text/html"
);
const $images = Array.from(document.querySelectorAll("img"));
$images.forEach(($image) => {
$image.loading = "lazy";
// Remove any inline width/height styles that might cause overflow
$image.removeAttribute("width");
$image.removeAttribute("height");
$image.removeAttribute("style");
// Set max-width to prevent overflow
$image.style.maxWidth = "100%";
$image.style.width = "auto";
$image.style.height = "auto";
$image.style.boxSizing = "border-box";
});
// Handle videos the same way
const $videos = Array.from(document.querySelectorAll("video"));
$videos.forEach(($video) => {
// Remove any inline width/height styles that might cause overflow
$video.removeAttribute("width");
$video.removeAttribute("height");
$video.removeAttribute("style");
// Set max-width to prevent overflow
$video.style.maxWidth = "100%";
$video.style.width = "auto";
$video.style.height = "auto";
$video.style.boxSizing = "border-box";
});
processMediaElements(document);
return document.body.outerHTML;
}
@@ -80,6 +137,91 @@ export function GameDetailsContent() {
const [backdropOpacity, setBackdropOpacity] = useState(1);
const [showEditGameModal, setShowEditGameModal] = useState(false);
const [showDeleteReviewModal, setShowDeleteReviewModal] = useState(false);
const [reviewToDelete, setReviewToDelete] = useState<string | null>(null);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
// Reviews state management
const [reviews, setReviews] = useState<GameReview[]>([]);
const [reviewsLoading, setReviewsLoading] = useState(false);
const [reviewScore, setReviewScore] = useState<number | null>(null);
const [submittingReview, setSubmittingReview] = useState(false);
const [reviewCharCount, setReviewCharCount] = useState(0);
const MAX_REVIEW_CHARS = 1000;
const [reviewsSortBy, setReviewsSortBy] = useState("newest");
const previousVotesRef = useRef<
Map<string, { upvotes: number; downvotes: number }>
>(new Map());
const [reviewsPage, setReviewsPage] = useState(0);
const [hasMoreReviews, setHasMoreReviews] = useState(true);
const [visibleBlockedReviews, setVisibleBlockedReviews] = useState<
Set<string>
>(new Set());
const [totalReviewCount, setTotalReviewCount] = useState(0);
const [showReviewForm, setShowReviewForm] = useState(false);
const [showReviewPrompt, setShowReviewPrompt] = useState(false);
const [hasUserReviewed, setHasUserReviewed] = useState(false);
const [reviewCheckLoading, setReviewCheckLoading] = useState(false);
// Check if the current game is in the user's library
const isGameInLibrary = useMemo(() => {
if (!library || !shop || !objectId) return false;
return library.some(
(libItem) => libItem.shop === shop && libItem.objectId === objectId
);
}, [library, shop, objectId]);
const editor = useEditor({
extensions: [
StarterKit.configure({
link: false,
}),
],
content: "",
editorProps: {
attributes: {
class: "game-details__review-editor",
"data-placeholder": t("write_review_placeholder"),
},
handlePaste: (view, event) => {
const htmlContent = event.clipboardData?.getData("text/html") || "";
const plainText = event.clipboardData?.getData("text/plain") || "";
const currentText = view.state.doc.textContent;
const remainingChars = MAX_REVIEW_CHARS - currentText.length;
if ((htmlContent || plainText) && remainingChars > 0) {
event.preventDefault();
if (htmlContent) {
const tempDiv = document.createElement("div");
tempDiv.innerHTML = htmlContent;
const textLength = tempDiv.textContent?.length || 0;
if (textLength <= remainingChars) {
return false;
}
}
const truncatedText = plainText.slice(0, remainingChars);
view.dispatch(view.state.tr.insertText(truncatedText));
return true;
}
return false;
},
},
onUpdate: ({ editor }) => {
const text = editor.getText();
setReviewCharCount(text.length);
if (text.length > MAX_REVIEW_CHARS) {
const truncatedContent = text.slice(0, MAX_REVIEW_CHARS);
editor.commands.setContent(truncatedContent);
setReviewCharCount(MAX_REVIEW_CHARS);
}
},
});
useEffect(() => {
setBackdropOpacity(1);
@@ -114,7 +256,221 @@ export function GameDetailsContent() {
const isCustomGame = game?.shop === "custom";
// Helper function to get image with custom asset priority
const checkUserReview = async () => {
if (!objectId || !userDetails) return;
setReviewCheckLoading(true);
try {
const response = await window.electron.checkGameReview(shop, objectId);
const hasReviewed = (response as any)?.hasReviewed || false;
setHasUserReviewed(hasReviewed);
if (
!hasReviewed &&
!sessionStorage.getItem(`reviewPromptDismissed_${objectId}`)
) {
setShowReviewPrompt(true);
}
} catch (error) {
console.error("Failed to check user review:", error);
} finally {
setReviewCheckLoading(false);
}
};
const loadReviews = async (reset = false) => {
if (!objectId) return;
setReviewsLoading(true);
try {
const skip = reset ? 0 : reviewsPage * 20;
const response = await window.electron.getGameReviews(
shop,
objectId,
20,
skip,
reviewsSortBy
);
const reviewsData = (response as any)?.reviews || [];
const reviewCount = (response as any)?.totalCount || 0;
if (reset) {
setReviews(reviewsData);
setReviewsPage(0);
setTotalReviewCount(reviewCount);
} else {
setReviews((prev) => [...prev, ...reviewsData]);
}
setHasMoreReviews(reviewsData.length === 20);
} catch (error) {
console.error("Failed to load reviews:", error);
} finally {
setReviewsLoading(false);
}
};
const handleVoteReview = async (
reviewId: string,
voteType: "upvote" | "downvote"
) => {
if (!objectId) return;
try {
await window.electron.voteReview(shop, objectId, reviewId, voteType);
loadReviews(true);
} catch (error) {
console.error(`Failed to ${voteType} review:`, error);
}
};
const handleDeleteReview = async (reviewId: string) => {
setReviewToDelete(reviewId);
setShowDeleteReviewModal(true);
};
const confirmDeleteReview = async () => {
if (!objectId || !reviewToDelete) return;
try {
await window.electron.deleteReview(shop, objectId, reviewToDelete);
loadReviews(true);
setShowDeleteReviewModal(false);
setReviewToDelete(null);
setHasUserReviewed(false);
setShowReviewForm(true);
showSuccessToast(t("review_deleted_successfully"));
} catch (error) {
console.error("Failed to delete review:", error);
showErrorToast(t("review_deletion_failed"));
}
};
const handleSubmitReview = async () => {
const reviewHtml = editor?.getHTML() || "";
const reviewText = editor?.getText() || "";
if (!objectId) {
return;
}
if (!reviewText.trim()) {
showErrorToast(t("review_cannot_be_empty"));
return;
}
if (submittingReview || reviewCharCount > MAX_REVIEW_CHARS) {
return;
}
if (reviewScore === null) {
return;
}
setSubmittingReview(true);
try {
await window.electron.createGameReview(
shop,
objectId,
reviewHtml,
reviewScore
);
editor?.commands.clearContent();
setReviewScore(null);
showSuccessToast(t("review_submitted_successfully"));
await loadReviews(true);
setShowReviewForm(false);
setShowReviewPrompt(false);
setHasUserReviewed(true);
} catch (error) {
console.error("Failed to submit review:", error);
showErrorToast(t("review_submission_failed"));
} finally {
setSubmittingReview(false);
}
};
const handleReviewPromptYes = () => {
setShowReviewPrompt(false);
setShowReviewForm(true);
setTimeout(() => {
const reviewFormElement = document.querySelector(
".game-details__review-form"
);
if (reviewFormElement) {
reviewFormElement.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
}, 100);
};
const handleReviewPromptLater = () => {
setShowReviewPrompt(false);
if (objectId) {
sessionStorage.setItem(`reviewPromptDismissed_${objectId}`, "true");
}
};
const handleSortChange = (newSortBy: string) => {
if (newSortBy !== reviewsSortBy) {
setReviewsSortBy(newSortBy);
setReviewsPage(0);
setHasMoreReviews(true);
loadReviews(true);
}
};
const toggleBlockedReview = (reviewId: string) => {
setVisibleBlockedReviews((prev) => {
const newSet = new Set(prev);
if (newSet.has(reviewId)) {
newSet.delete(reviewId);
} else {
newSet.add(reviewId);
}
return newSet;
});
};
const loadMoreReviews = () => {
if (!reviewsLoading && hasMoreReviews) {
setReviewsPage((prev) => prev + 1);
loadReviews(false);
}
};
useEffect(() => {
if (objectId && (game || shop)) {
loadReviews(true);
checkUserReview();
}
}, [game, shop, objectId, reviewsSortBy, userDetails]);
useEffect(() => {
if (reviewsPage > 0) {
loadReviews(false);
}
}, [reviewsPage]);
// Initialize previousVotesRef for new reviews
useEffect(() => {
reviews.forEach((review) => {
if (!previousVotesRef.current.has(review.id)) {
previousVotesRef.current.set(review.id, {
upvotes: review.upvotes || 0,
downvotes: review.downvotes || 0,
});
}
});
}, [reviews]);
const getImageWithCustomPriority = (
customUrl: string | null | undefined,
originalUrl: string | null | undefined,
@@ -227,6 +583,19 @@ export function GameDetailsContent() {
<div className="game-details__description-container">
<div className="game-details__description-content">
{/* Review Prompt Banner */}
{game?.shop !== "custom" &&
showReviewPrompt &&
userDetails &&
!hasUserReviewed &&
!reviewCheckLoading &&
isGameInLibrary && (
<ReviewPromptBanner
onYesClick={handleReviewPromptYes}
onLaterClick={handleReviewPromptLater}
/>
)}
<DescriptionHeader />
<GallerySlider />
@@ -234,8 +603,404 @@ export function GameDetailsContent() {
dangerouslySetInnerHTML={{
__html: aboutTheGame,
}}
className="game-details__description"
className={`game-details__description ${
isDescriptionExpanded
? "game-details__description--expanded"
: "game-details__description--collapsed"
}`}
/>
{aboutTheGame && aboutTheGame.length > 500 && (
<button
type="button"
className="game-details__description-toggle"
onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)}
>
{isDescriptionExpanded ? t("show_less") : t("show_more")}
</button>
)}
{game?.shop !== "custom" && (
<div className="game-details__reviews-section">
{showReviewForm && (
<>
<div className="game-details__reviews-header">
<h3 className="game-details__reviews-title">
{t("leave_a_review")}
</h3>
</div>
<div className="game-details__review-form">
<div className="game-details__review-input-container">
<div className="game-details__review-input-header">
<div className="game-details__review-editor-toolbar">
<button
type="button"
onClick={() =>
editor?.chain().focus().toggleBold().run()
}
className={`game-details__editor-button ${editor?.isActive("bold") ? "is-active" : ""}`}
disabled={!editor}
>
<strong>B</strong>
</button>
<button
type="button"
onClick={() =>
editor?.chain().focus().toggleItalic().run()
}
className={`game-details__editor-button ${editor?.isActive("italic") ? "is-active" : ""}`}
disabled={!editor}
>
<em>I</em>
</button>
<button
type="button"
onClick={() =>
editor?.chain().focus().toggleUnderline().run()
}
className={`game-details__editor-button ${editor?.isActive("underline") ? "is-active" : ""}`}
disabled={!editor}
>
<u>U</u>
</button>
</div>
<div className="game-details__review-char-counter">
<span
className={
reviewCharCount > MAX_REVIEW_CHARS
? "over-limit"
: ""
}
>
{reviewCharCount}/{MAX_REVIEW_CHARS}
</span>
</div>
</div>
<div className="game-details__review-input">
<EditorContent editor={editor} />
</div>
</div>
<div className="game-details__review-form-bottom">
<div className="game-details__review-score-container">
<div className="game-details__star-rating">
{[1, 2, 3, 4, 5].map((starValue) => (
<button
key={starValue}
type="button"
className={`game-details__star ${
reviewScore && starValue <= reviewScore
? "game-details__star--filled"
: "game-details__star--empty"
} ${
reviewScore && starValue <= reviewScore
? getSelectScoreColorClass(reviewScore)
: ""
}`}
onClick={() => setReviewScore(starValue)}
title={getRatingText(starValue, t)}
>
<Star
size={18}
fill={
reviewScore && starValue <= reviewScore
? "currentColor"
: "none"
}
/>
</button>
))}
</div>
</div>
<Button
theme="primary"
onClick={handleSubmitReview}
disabled={
!editor?.getHTML().trim() ||
reviewScore === null ||
submittingReview ||
reviewCharCount > MAX_REVIEW_CHARS
}
>
{submittingReview
? t("submitting")
: t("submit_review")}
</Button>
</div>
</div>
</>
)}
{showReviewForm && (
<div className="game-details__reviews-separator"></div>
)}
<div className="game-details__reviews-list">
<div className="game-details__reviews-list-header">
<div className="game-details__reviews-title-group">
<h3 className="game-details__reviews-title">
{t("reviews")}
</h3>
<span className="game-details__reviews-badge">
{totalReviewCount}
</span>
</div>
</div>
<ReviewSortOptions
sortBy={reviewsSortBy as any}
onSortChange={handleSortChange}
/>
{reviewsLoading && reviews.length === 0 && (
<div className="game-details__reviews-loading">
{t("loading_reviews")}
</div>
)}
{!reviewsLoading && reviews.length === 0 && (
<div className="game-details__reviews-empty">
<div className="game-details__reviews-empty-icon">
<NoteIcon size={48} />
</div>
<h4 className="game-details__reviews-empty-title">
{t("no_reviews_yet")}
</h4>
<p className="game-details__reviews-empty-message">
{t("be_first_to_review")}
</p>
</div>
)}
{reviews.map((review) => (
<div key={review.id} className="game-details__review-item">
{review.isBlocked &&
!visibleBlockedReviews.has(review.id) ? (
<div className="game-details__blocked-review-simple">
Review from blocked user {" "}
<button
className="game-details__blocked-review-show-link"
onClick={() => toggleBlockedReview(review.id)}
>
Show
</button>
</div>
) : (
<>
<div className="game-details__review-header">
<div className="game-details__review-user">
{review.user?.profileImageUrl && (
<button
className="game-details__review-avatar-button"
onClick={() =>
review.user?.id &&
navigate(`/profile/${review.user.id}`)
}
title={review.user.displayName || "User"}
>
<img
src={review.user.profileImageUrl}
alt={review.user.displayName || "User"}
className="game-details__review-avatar"
/>
</button>
)}
<div className="game-details__review-user-info">
<button
className="game-details__review-display-name game-details__review-display-name--clickable"
onClick={() =>
review.user?.id &&
navigate(`/profile/${review.user.id}`)
}
>
{review.user?.displayName || "Anonymous"}
</button>
<div className="game-details__review-date">
<ClockIcon size={12} />
{formatDistance(
new Date(review.createdAt),
new Date(),
{ addSuffix: true }
)}
</div>
</div>
</div>
<div
className="game-details__review-score-stars"
title={getRatingText(review.score, t)}
>
{[1, 2, 3, 4, 5].map((starValue) => (
<Star
key={starValue}
size={20}
fill={
starValue <= review.score
? "currentColor"
: "none"
}
className={`game-details__review-star ${
starValue <= review.score
? "game-details__review-star--filled"
: "game-details__review-star--empty"
} ${
starValue <= review.score
? getScoreColorClass(review.score)
: ""
}`}
/>
))}
</div>
</div>
<div
className="game-details__review-content"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(review.reviewHtml),
}}
/>
<div className="game-details__review-actions">
<div className="game-details__review-votes">
<motion.button
className={`game-details__vote-button game-details__vote-button--upvote ${review.hasUpvoted ? "game-details__vote-button--active" : ""}`}
onClick={() =>
handleVoteReview(review.id, "upvote")
}
animate={
review.hasUpvoted
? {
scale: [1, 1.2, 1],
transition: { duration: 0.3 },
}
: {}
}
>
<ThumbsUp size={16} />
<AnimatePresence mode="wait">
<motion.span
key={review.upvotes || 0}
custom={
(review.upvotes || 0) >
(previousVotesRef.current.get(review.id)
?.upvotes || 0)
}
variants={{
enter: (isIncreasing: boolean) => ({
y: isIncreasing ? 10 : -10,
opacity: 0,
}),
center: { y: 0, opacity: 1 },
exit: (isIncreasing: boolean) => ({
y: isIncreasing ? -10 : 10,
opacity: 0,
}),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.2 }}
onAnimationComplete={() => {
previousVotesRef.current.set(review.id, {
upvotes: review.upvotes || 0,
downvotes: review.downvotes || 0,
});
}}
>
{formatNumber(review.upvotes || 0)}
</motion.span>
</AnimatePresence>
</motion.button>
<motion.button
className={`game-details__vote-button game-details__vote-button--downvote ${review.hasDownvoted ? "game-details__vote-button--active" : ""}`}
onClick={() =>
handleVoteReview(review.id, "downvote")
}
animate={
review.hasDownvoted
? {
scale: [1, 1.2, 1],
transition: { duration: 0.3 },
}
: {}
}
>
<ThumbsDown size={16} />
<AnimatePresence mode="wait">
<motion.span
key={review.downvotes || 0}
custom={
(review.downvotes || 0) >
(previousVotesRef.current.get(review.id)
?.downvotes || 0)
}
variants={{
enter: (isIncreasing: boolean) => ({
y: isIncreasing ? 10 : -10,
opacity: 0,
}),
center: { y: 0, opacity: 1 },
exit: (isIncreasing: boolean) => ({
y: isIncreasing ? -10 : 10,
opacity: 0,
}),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.2 }}
onAnimationComplete={() => {
previousVotesRef.current.set(review.id, {
upvotes: review.upvotes || 0,
downvotes: review.downvotes || 0,
});
}}
>
{formatNumber(review.downvotes || 0)}
</motion.span>
</AnimatePresence>
</motion.button>
</div>
{userDetails?.id === review.user?.id && (
<button
className="game-details__delete-review-button"
onClick={() => handleDeleteReview(review.id)}
title={t("delete_review")}
>
<TrashIcon size={16} />
<span>{t("remove_review")}</span>
</button>
)}
{review.isBlocked &&
visibleBlockedReviews.has(review.id) && (
<button
className="game-details__blocked-review-hide-link"
onClick={() => toggleBlockedReview(review.id)}
>
Hide
</button>
)}
</div>
</>
)}
</div>
))}
{hasMoreReviews && !reviewsLoading && (
<button
className="game-details__load-more-reviews"
onClick={loadMoreReviews}
>
{t("load_more_reviews")}
</button>
)}
{reviewsLoading && reviews.length > 0 && (
<div className="game-details__reviews-loading">
{t("loading_more_reviews")}
</div>
)}
</div>
</div>
)}
</div>
{game?.shop !== "custom" && <Sidebar />}
@@ -251,6 +1016,15 @@ export function GameDetailsContent() {
onGameUpdated={handleGameUpdated}
/>
)}
<DeleteReviewModal
visible={showDeleteReviewModal}
onClose={() => {
setShowDeleteReviewModal(false);
setReviewToDelete(null);
}}
onConfirm={confirmDeleteReview}
/>
</div>
);
}

View File

@@ -35,6 +35,11 @@ export function GameDetailsSkeleton() {
))}
<Skeleton className="game-details__hero-image-skeleton" />
<Skeleton />
<Skeleton
width={120}
height={36}
className="game-details__description-toggle"
/>
</div>
</div>
<div className="content-sidebar">

View File

@@ -27,6 +27,509 @@ $hero-height: 300px;
}
}
&__review-form {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
&__review-form-controls {
display: flex;
gap: calc(globals.$spacing-unit * 2);
align-items: flex-end;
flex-wrap: wrap;
@media (max-width: 768px) {
flex-direction: column;
align-items: stretch;
gap: calc(globals.$spacing-unit * 1.5);
}
}
&__review-form-bottom {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
&__review-message {
padding: calc(globals.$spacing-unit * 1);
border-radius: 4px;
font-size: globals.$small-font-size;
font-weight: 500;
margin-top: calc(globals.$spacing-unit * 1);
border: 1px solid;
&--success {
background: rgba(34, 197, 94, 0.1);
color: #86efac;
border-color: rgba(34, 197, 94, 0.3);
}
&--error {
background: rgba(239, 68, 68, 0.1);
color: #fca5a5;
border-color: rgba(239, 68, 68, 0.3);
}
}
&__review-score-container {
display: flex;
align-items: center;
gap: 4px;
}
&__review-score-label {
font-size: 14px;
color: #ffffff;
font-weight: 500;
}
&__review-score-select {
background-color: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
color: #ffffff;
padding: 6px 12px;
font-size: 14px;
cursor: pointer;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
&:focus {
outline: none;
}
&--red {
border-color: #e74c3c;
background-color: rgba(231, 76, 60, 0.1);
}
&--yellow {
border-color: #f39c12;
background-color: rgba(243, 156, 18, 0.1);
}
&--green {
border-color: #27ae60;
background-color: rgba(39, 174, 96, 0.1);
}
option {
background-color: #2a2a2a;
color: #ffffff;
}
}
&__star-rating {
display: flex;
align-items: center;
gap: 2px;
}
&__star {
background: none;
border: none;
color: #666666;
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover {
color: #ffffff;
background-color: rgba(255, 255, 255, 0.1);
transform: scale(1.1);
}
&--filled {
color: #ffffff;
&.game-details__review-score-select--red {
color: #e74c3c;
}
&.game-details__review-score-select--yellow {
color: #f39c12;
}
&.game-details__review-score-select--green {
color: #27ae60;
}
}
&--empty {
color: #666666;
&:hover {
color: #ffffff;
}
}
svg {
fill: currentColor;
}
}
&__reviews-sort {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.75);
min-width: 150px;
}
&__reviews-sort-label {
display: block;
font-size: globals.$body-font-size;
color: globals.$body-color;
}
&__reviews-sort-select {
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid globals.$border-color;
border-radius: 4px;
padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1);
color: globals.$body-color;
font-size: globals.$body-font-size;
font-family: inherit;
cursor: pointer;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
&:focus {
outline: none;
background-color: rgba(255, 255, 255, 0.08);
border-color: globals.$brand-teal;
}
&:hover {
border-color: rgba(255, 255, 255, 0.15);
}
option {
background-color: globals.$dark-background-color;
color: globals.$body-color;
}
}
&__reviews-list {
margin-top: calc(globals.$spacing-unit * 3);
}
&__reviews-separator {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: calc(globals.$spacing-unit * 3) 0;
width: 100%;
}
&__reviews-list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: calc(globals.$spacing-unit * 1);
}
&__reviews-empty {
text-align: center;
padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 2);
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__reviews-empty-icon {
font-size: 48px;
margin-bottom: calc(globals.$spacing-unit * 2);
color: rgba(255, 255, 255, 0.6);
}
&__reviews-empty-title {
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
margin: 0 0 calc(globals.$spacing-unit * 1) 0;
}
&__reviews-empty-message {
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
margin: 0;
line-height: 1.4;
}
&__review-item {
background: linear-gradient(
to right,
globals.$dark-background-color 0%,
globals.$dark-background-color 30%,
globals.$background-color 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
padding: calc(globals.$spacing-unit * 2);
margin-bottom: calc(globals.$spacing-unit * 2);
overflow: hidden;
word-wrap: break-word;
}
&__review-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: calc(globals.$spacing-unit * 1.5);
}
&__review-user {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1);
}
&__review-avatar-button {
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
&:active {
opacity: 0.6;
}
}
&__review-avatar {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
border: 2px solid rgba(255, 255, 255, 0.1);
display: block;
}
&__review-user-info {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.25);
}
&__review-display-name {
color: rgba(255, 255, 255, 0.9);
font-size: globals.$small-font-size;
font-weight: 600;
display: inline-flex;
&--clickable {
cursor: pointer;
transition: color 0.2s ease;
&:hover {
text-decoration: underline;
}
}
}
&__review-actions {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
&__review-votes {
display: flex;
gap: 12px;
}
&__vote-button {
display: flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 6px 12px;
color: #ccc;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: #ffffff;
}
&--active {
&.game-details__vote-button--upvote {
svg {
fill: white;
}
}
&.game-details__vote-button--downvote {
svg {
fill: white;
}
}
}
span {
font-weight: 500;
display: inline-block;
min-width: 1ch;
overflow: hidden;
}
}
&__delete-review-button {
display: flex;
align-items: center;
justify-content: center;
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: 6px;
padding: 6px;
color: #f44336;
cursor: pointer;
transition: all 0.2s ease;
gap: 6px;
&:hover {
background: rgba(244, 67, 54, 0.2);
border-color: #f44336;
color: #ff5722;
}
}
&__blocked-review-simple {
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
}
&__blocked-review-show-link {
background: none;
border: none;
color: #ffc107;
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color 0.2s ease;
&:hover {
color: #ffeb3b;
}
}
&__blocked-review-hide-link {
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color 0.2s ease;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
}
&__review-score-stars {
display: flex;
align-items: center;
gap: 2px;
}
&__review-star {
color: #666666;
transition: color 0.2s ease;
cursor: default;
&--filled {
color: #ffffff;
&.game-details__review-score--red {
color: #fca5a5;
}
&.game-details__review-score--yellow {
color: #fcd34d;
}
&.game-details__review-score--green {
color: #86efac;
}
}
&--empty {
color: #666666;
}
svg {
fill: currentColor;
}
}
&__review-date {
display: flex;
align-items: center;
gap: 4px;
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
}
&__review-content {
color: globals.$body-color;
line-height: 1.5;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
max-width: 100%;
}
&__reviews-loading {
text-align: center;
color: rgba(255, 255, 255, 0.6);
padding: calc(globals.$spacing-unit * 2);
}
&__load-more-reviews {
background: rgba(255, 255, 255, 0.05);
border: 1px solid globals.$border-color;
color: globals.$body-color;
padding: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 2);
border-radius: 4px;
cursor: pointer;
font-size: globals.$body-font-size;
font-family: inherit;
transition: all 0.2s ease;
width: 100%;
margin-top: calc(globals.$spacing-unit * 2);
&:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: globals.$brand-teal;
}
}
&__hero {
width: 100%;
height: $hero-height;
@@ -192,6 +695,8 @@ $hero-height: 300px;
min-width: 0;
flex: 1;
overflow-x: hidden;
display: flex;
flex-direction: column;
}
&__description {
@@ -203,6 +708,7 @@ $hero-height: 300px;
margin-right: auto;
overflow-x: auto;
min-height: auto;
transition: max-height 0.3s ease-in-out;
@media (min-width: 1280px) {
width: 60%;
@@ -212,6 +718,27 @@ $hero-height: 300px;
width: 50%;
}
&--collapsed {
max-height: 300px;
overflow: hidden;
position: relative;
&::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background: linear-gradient(transparent, globals.$background-color);
pointer-events: none;
}
}
&--expanded {
max-height: none;
}
img,
video {
border-radius: 5px;
@@ -237,6 +764,25 @@ $hero-height: 300px;
}
}
&__description-toggle {
background: none;
border: 1px solid globals.$border-color;
color: globals.$body-color;
padding: calc(globals.$spacing-unit * 0.75)
calc(globals.$spacing-unit * 1.5);
border-radius: 4px;
cursor: pointer;
font-size: globals.$body-font-size;
margin-top: calc(globals.$spacing-unit * 1.5);
transition: all 0.2s ease;
align-self: center;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.1);
}
}
&__description-skeleton {
display: flex;
flex-direction: column;
@@ -367,4 +913,192 @@ $hero-height: 300px;
flex: 1;
transition: opacity 0.2s ease;
}
&__reviews-section {
margin-top: calc(globals.$spacing-unit * 3);
padding-top: calc(globals.$spacing-unit * 3);
border-top: 1px solid rgba(255, 255, 255, 0.1);
width: 100%;
margin-left: auto;
margin-right: auto;
@media (min-width: 1280px) {
width: 60%;
}
@media (min-width: 1536px) {
width: 50%;
}
}
&__reviews-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: calc(globals.$spacing-unit * 2);
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
gap: calc(globals.$spacing-unit * 1.5);
}
}
&__reviews-title {
font-size: 1.25rem;
font-weight: 600;
color: globals.$muted-color;
margin: 0;
}
&__reviews-title-group {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
flex: 1;
}
&__reviews-badge {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
min-width: 24px;
text-align: center;
flex-shrink: 0;
}
&__leave-review-cta {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
padding: calc(globals.$spacing-unit * 0.75)
calc(globals.$spacing-unit * 1.5);
background: linear-gradient(
135deg,
globals.$brand-teal,
globals.$brand-blue
);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: calc(globals.$spacing-unit);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(globals.$brand-teal, 0.3);
}
&:active {
transform: translateY(0);
}
svg {
flex-shrink: 0;
}
}
&__review-input-container {
display: flex;
flex-direction: column;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background-color: globals.$dark-background-color;
overflow: hidden;
}
&__review-input-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background-color: globals.$background-color;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
&__review-editor-toolbar {
display: flex;
gap: 4px;
}
&__editor-button {
background: none;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
color: #ffffff;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
&:hover {
background-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
}
&.is-active {
background-color: globals.$brand-blue;
border-color: globals.$brand-blue;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&__review-char-counter {
font-size: 12px;
color: #888888;
.over-limit {
color: #ff6b6b;
}
}
&__review-input {
min-height: 120px;
padding: 12px;
cursor: text;
.ProseMirror {
outline: none;
color: #ffffff;
font-size: 14px;
line-height: 1.5;
min-height: 96px; // 120px - 24px padding
width: 100%;
cursor: text;
&:focus {
outline: none;
}
p {
margin: 0 0 8px 0;
&:last-child {
margin-bottom: 0;
}
}
strong {
font-weight: bold;
}
em {
font-style: italic;
}
u {
text-decoration: underline;
}
}
}
}

View File

@@ -244,7 +244,7 @@ export function HeroPanelActions() {
onClick={() => setShowRepacksModal(true)}
theme="outline"
disabled={isGameDownloading}
className={`hero-panel-actions__action ${!repacks.length ? "hero-panel-actions__action--disabled" : ""}`}
className={`hero-panel-actions__action ${repacks.length === 0 ? "hero-panel-actions__action--disabled" : ""}`}
>
<DownloadIcon />
{t("download")}

View File

@@ -0,0 +1,20 @@
@use "../../../scss/globals.scss";
.delete-review-modal {
&__karma-warning {
background-color: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 4px;
padding: 12px;
margin-bottom: 16px;
color: #ffc107;
font-size: 14px;
font-weight: 500;
}
&__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
}

View File

@@ -0,0 +1,41 @@
import { useTranslation } from "react-i18next";
import { Button, Modal } from "@renderer/components";
import "./delete-review-modal.scss";
interface DeleteReviewModalProps {
visible: boolean;
onClose: () => void;
onConfirm: () => void;
}
export function DeleteReviewModal({
visible,
onClose,
onConfirm,
}: Readonly<DeleteReviewModalProps>) {
const { t } = useTranslation("game_details");
const handleDeleteReview = () => {
onConfirm();
onClose();
};
return (
<Modal
visible={visible}
title={t("delete_review_modal_title")}
description={t("delete_review_modal_description")}
onClose={onClose}
>
<div className="delete-review-modal__actions">
<Button onClick={onClose} theme="outline">
{t("delete_review_modal_cancel_button")}
</Button>
<Button onClick={handleDeleteReview} theme="danger">
{t("delete_review_modal_delete_button")}
</Button>
</div>
</Modal>
);
}

View File

@@ -2,3 +2,4 @@ export * from "./repacks-modal";
export * from "./download-settings-modal";
export * from "./game-options-modal";
export * from "./edit-game-modal";
export * from "./delete-review-modal";

View File

@@ -0,0 +1,46 @@
@use "../../scss/globals.scss";
.review-prompt-banner {
background: rgba(255, 255, 255, 0.02);
border-radius: 8px;
padding: calc(globals.$spacing-unit * 2);
margin-bottom: calc(globals.$spacing-unit * 1.5);
border: 1px solid rgba(255, 255, 255, 0.05);
&__content {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(globals.$spacing-unit * 2.5);
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
gap: calc(globals.$spacing-unit * 2);
}
}
&__text {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.5);
}
&__playtime {
font-size: globals.$body-font-size;
color: globals.$body-color;
font-weight: 600;
}
&__question {
font-size: globals.$small-font-size;
color: globals.$muted-color;
font-weight: 400;
}
&__actions {
display: flex;
gap: globals.$spacing-unit;
align-items: center;
}
}

View File

@@ -0,0 +1,38 @@
import { useTranslation } from "react-i18next";
import { Button } from "@renderer/components";
import "./review-prompt-banner.scss";
interface ReviewPromptBannerProps {
onYesClick: () => void;
onLaterClick: () => void;
}
export function ReviewPromptBanner({
onYesClick,
onLaterClick,
}: Readonly<ReviewPromptBannerProps>) {
const { t } = useTranslation("game_details");
return (
<div className="review-prompt-banner">
<div className="review-prompt-banner__content">
<div className="review-prompt-banner__text">
<span className="review-prompt-banner__playtime">
{t("you_seemed_to_enjoy_this_game")}
</span>
<span className="review-prompt-banner__question">
{t("would_you_recommend_this_game")}
</span>
</div>
<div className="review-prompt-banner__actions">
<Button theme="outline" onClick={onLaterClick}>
{t("maybe_later")}
</Button>
<Button theme="primary" onClick={onYesClick}>
{t("yes")}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
@use "../../scss/globals.scss";
.review-sort-options {
&__container {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: calc(globals.$spacing-unit);
margin-bottom: calc(globals.$spacing-unit * 3);
}
&__label {
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
font-weight: 400;
}
&__options {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
font-size: 14px;
flex-wrap: wrap;
@media (max-width: 768px) {
gap: calc(globals.$spacing-unit * 0.75);
}
}
&__option {
background: none;
border: none;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
padding: 4px 0;
font-size: 14px;
font-weight: 300;
transition: all ease 0.2s;
display: flex;
align-items: center;
gap: 4px;
&:hover:not(:disabled) {
color: rgba(255, 255, 255, 0.6);
}
&.active {
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
span {
display: inline-block;
@media (max-width: 480px) {
display: none;
}
}
@media (max-width: 480px) {
gap: 0;
}
}
&__separator {
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
@media (max-width: 480px) {
display: none;
}
}
}

View File

@@ -0,0 +1,90 @@
import {
ThumbsupIcon,
ChevronUpIcon,
ChevronDownIcon,
} from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import "./review-sort-options.scss";
type ReviewSortOption =
| "newest"
| "oldest"
| "score_high"
| "score_low"
| "most_voted";
interface ReviewSortOptionsProps {
sortBy: ReviewSortOption;
onSortChange: (sortBy: ReviewSortOption) => void;
}
export function ReviewSortOptions({
sortBy,
onSortChange,
}: Readonly<ReviewSortOptionsProps>) {
const { t } = useTranslation("game_details");
const handleDateToggle = () => {
const newSort = sortBy === "newest" ? "oldest" : "newest";
onSortChange(newSort);
};
const handleScoreToggle = () => {
const newSort = sortBy === "score_high" ? "score_low" : "score_high";
onSortChange(newSort);
};
const handleMostVotedClick = () => {
if (sortBy !== "most_voted") {
onSortChange("most_voted");
}
};
const isDateActive = sortBy === "newest" || sortBy === "oldest";
const isScoreActive = sortBy === "score_high" || sortBy === "score_low";
const isMostVotedActive = sortBy === "most_voted";
return (
<div className="review-sort-options__container">
<div className="review-sort-options__options">
<button
className={`review-sort-options__option review-sort-options__toggle-option ${isDateActive ? "active" : ""}`}
onClick={handleDateToggle}
>
{sortBy === "newest" ? (
<ChevronDownIcon size={16} />
) : (
<ChevronUpIcon size={16} />
)}
<span>
{sortBy === "oldest" ? t("sort_oldest") : t("sort_newest")}
</span>
</button>
<span className="review-sort-options__separator">|</span>
<button
className={`review-sort-options__option review-sort-options__toggle-option ${isScoreActive ? "active" : ""}`}
onClick={handleScoreToggle}
>
{sortBy === "score_high" ? (
<ChevronDownIcon size={16} />
) : (
<ChevronUpIcon size={16} />
)}
<span>
{sortBy === "score_low"
? t("sort_lowest_score")
: t("sort_highest_score")}
</span>
</button>
<span className="review-sort-options__separator">|</span>
<button
className={`review-sort-options__option ${isMostVotedActive ? "active" : ""}`}
onClick={handleMostVotedClick}
>
<ThumbsupIcon size={16} />
<span>{t("sort_most_voted")}</span>
</button>
</div>
</div>
);
}

View File

@@ -106,7 +106,7 @@
.stats {
&__section {
display: flex;
gap: calc(globals.$spacing-unit * 2);
gap: calc(globals.$spacing-unit * 1);
padding: calc(globals.$spacing-unit * 2);
justify-content: space-between;
transition: max-height ease 0.5s;
@@ -115,10 +115,6 @@
@media (min-width: 1024px) {
flex-direction: column;
}
@media (min-width: 1280px) {
flex-direction: row;
}
}
&__category-title {

View File

@@ -5,7 +5,7 @@ import type {
UserAchievement,
} from "@types";
import { useTranslation } from "react-i18next";
import { Button, Link } from "@renderer/components";
import { Button, Link, StarRating } from "@renderer/components";
import { gameDetailsContext } from "@renderer/context";
import { useDate, useFormat, useUserDetails } from "@renderer/hooks";
@@ -14,6 +14,7 @@ import {
DownloadIcon,
LockIcon,
PeopleIcon,
StarIcon,
} from "@primer/octicons-react";
import { HowLongToBeatSection } from "./how-long-to-beat-section";
import { howLongToBeatEntriesTable } from "@renderer/dexie";
@@ -225,6 +226,29 @@ export function Sidebar() {
</p>
<p>{numberFormatter.format(stats?.playerCount)}</p>
</div>
<div className="stats__category">
<p className="stats__category-title">
<StarIcon size={18} />
{t("rating_count")}
</p>
<StarRating
rating={
stats?.averageScore === 0
? null
: (stats?.averageScore ?? null)
}
size={16}
showCalculating={
!!(
stats &&
(stats.averageScore === null || stats.averageScore === 0)
)
}
calculatingText={t("calculating", { ns: "game_card" })}
hideIcon={true}
/>
</div>
</div>
</SidebarSection>
)}

View File

@@ -10,6 +10,7 @@ import { ReportProfile } from "../report-profile/report-profile";
import { FriendsBox } from "./friends-box";
import { RecentGamesBox } from "./recent-games-box";
import { UserStatsBox } from "./user-stats-box";
import { UserKarmaBox } from "./user-karma-box";
import { UserLibraryGameCard } from "./user-library-game-card";
import { SortOptions } from "./sort-options";
import { useSectionCollapse } from "@renderer/hooks/use-section-collapse";
@@ -223,6 +224,7 @@ export function ProfileContent() {
{shouldShowRightContent && (
<div className="profile-content__right-content">
<UserStatsBox />
<UserKarmaBox />
<RecentGamesBox />
<FriendsBox />
<ReportProfile />

View File

@@ -0,0 +1,47 @@
@use "../../../scss/globals.scss";
.user-karma {
&__box {
background-color: globals.$background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
padding: calc(globals.$spacing-unit * 2);
}
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__content {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 1.5);
}
&__stats-row {
display: flex;
align-items: center;
color: globals.$body-color;
}
&__description {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
font-weight: 600;
font-size: 1.1rem;
}
&__info {
padding-top: calc(globals.$spacing-unit * 0.5);
}
&__info-text {
color: globals.$muted-color;
font-size: 0.85rem;
line-height: 1.4;
}
}

View File

@@ -0,0 +1,43 @@
import { useContext } from "react";
import { userProfileContext } from "@renderer/context";
import { useTranslation } from "react-i18next";
import { useFormat, useUserDetails } from "@renderer/hooks";
import { Award } from "lucide-react";
import "./user-karma-box.scss";
export function UserKarmaBox() {
const { isMe, userProfile } = useContext(userProfileContext);
const { userDetails } = useUserDetails();
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
// Get karma from userDetails (for current user) or userProfile (for other users)
const karma = isMe ? userDetails?.karma : userProfile?.karma;
// Don't show if karma is not available
if (karma === undefined || karma === null) return null;
return (
<div>
<div className="user-karma__section-header">
<h2>{t("karma")}</h2>
</div>
<div className="user-karma__box">
<div className="user-karma__content">
<div className="user-karma__stats-row">
<p className="user-karma__description">
<Award size={20} /> {numberFormatter.format(karma)}{" "}
{t("karma_count")}
</p>
</div>
<div className="user-karma__info">
<small className="user-karma__info-text">
{t("karma_description")}
</small>
</div>
</div>
</div>
</div>
);
}

View File

@@ -234,7 +234,7 @@ export function UserLibraryGameCard({
</div>
<img
src={game.coverImageUrl}
src={game.coverImageUrl ?? undefined}
alt={game.title}
className="user-library-game__game-image"
/>

View File

@@ -0,0 +1,71 @@
@use "../../scss/globals.scss";
.settings-debrid {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
&__description {
margin: 0 0 calc(globals.$spacing-unit * 2) 0;
color: var(--text-secondary);
line-height: 1.6;
}
&__section {
display: flex;
flex-direction: column;
&:not(:last-child) {
margin-bottom: calc(globals.$spacing-unit * 2);
}
}
&__section-header {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__section-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
&__collapse-button {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all ease 0.2s;
flex-shrink: 0;
&:hover {
color: rgba(255, 255, 255, 0.9);
background-color: rgba(255, 255, 255, 0.1);
}
}
&__check-icon {
color: white;
flex-shrink: 0;
}
&__beta-badge {
background: linear-gradient(135deg, #c9aa71, #d4af37);
color: #1a1a1a;
font-size: 0.625rem;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
letter-spacing: 0.5px;
flex-shrink: 0;
}
}

View File

@@ -0,0 +1,228 @@
import { useState, useCallback, useMemo } from "react";
import { useFeature, useAppSelector } from "@renderer/hooks";
import { SettingsTorBox } from "./settings-torbox";
import { SettingsRealDebrid } from "./settings-real-debrid";
import { SettingsAllDebrid } from "./settings-all-debrid";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronRightIcon, CheckCircleFillIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import "./settings-debrid.scss";
interface CollapseState {
torbox: boolean;
realDebrid: boolean;
allDebrid: boolean;
}
const sectionVariants = {
collapsed: {
opacity: 0,
y: -20,
height: 0,
transition: {
duration: 0.3,
ease: [0.25, 0.1, 0.25, 1],
opacity: { duration: 0.1 },
y: { duration: 0.1 },
height: { duration: 0.2 },
},
},
expanded: {
opacity: 1,
y: 0,
height: "auto",
transition: {
duration: 0.3,
ease: [0.25, 0.1, 0.25, 1],
opacity: { duration: 0.2, delay: 0.1 },
y: { duration: 0.3 },
height: { duration: 0.3 },
},
},
};
const chevronVariants = {
collapsed: {
rotate: 0,
transition: {
duration: 0.2,
ease: "easeInOut",
},
},
expanded: {
rotate: 90,
transition: {
duration: 0.2,
ease: "easeInOut",
},
},
};
export function SettingsDebrid() {
const { t } = useTranslation("settings");
const { isFeatureEnabled, Feature } = useFeature();
const isTorBoxEnabled = isFeatureEnabled(Feature.TorBox);
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const initialCollapseState = useMemo<CollapseState>(() => {
return {
torbox: !userPreferences?.torBoxApiToken,
realDebrid: !userPreferences?.realDebridApiToken,
allDebrid: !userPreferences?.allDebridApiKey,
};
}, [userPreferences]);
const [collapseState, setCollapseState] =
useState<CollapseState>(initialCollapseState);
const toggleSection = useCallback((section: keyof CollapseState) => {
setCollapseState((prevState) => ({
...prevState,
[section]: !prevState[section],
}));
}, []);
return (
<div className="settings-debrid">
<p className="settings-debrid__description">{t("debrid_description")}</p>
<div className="settings-debrid__section">
<div className="settings-debrid__section-header">
<button
type="button"
className="settings-debrid__collapse-button"
onClick={() => toggleSection("realDebrid")}
aria-label={
collapseState.realDebrid
? "Expand Real-Debrid section"
: "Collapse Real-Debrid section"
}
>
<motion.div
variants={chevronVariants}
animate={collapseState.realDebrid ? "collapsed" : "expanded"}
>
<ChevronRightIcon size={16} />
</motion.div>
</button>
<h3 className="settings-debrid__section-title">Real-Debrid</h3>
{userPreferences?.realDebridApiToken && (
<CheckCircleFillIcon
size={16}
className="settings-debrid__check-icon"
/>
)}
</div>
<AnimatePresence initial={true} mode="wait">
{!collapseState.realDebrid && (
<motion.div
key="realdebrid-content"
variants={sectionVariants}
initial="collapsed"
animate="expanded"
exit="collapsed"
layout
>
<SettingsRealDebrid />
</motion.div>
)}
</AnimatePresence>
</div>
{isTorBoxEnabled && (
<div className="settings-debrid__section">
<div className="settings-debrid__section-header">
<button
type="button"
className="settings-debrid__collapse-button"
onClick={() => toggleSection("torbox")}
aria-label={
collapseState.torbox
? "Expand TorBox section"
: "Collapse TorBox section"
}
>
<motion.div
variants={chevronVariants}
animate={collapseState.torbox ? "collapsed" : "expanded"}
>
<ChevronRightIcon size={16} />
</motion.div>
</button>
<h3 className="settings-debrid__section-title">TorBox</h3>
{userPreferences?.torBoxApiToken && (
<CheckCircleFillIcon
size={16}
className="settings-debrid__check-icon"
/>
)}
</div>
<AnimatePresence initial={true} mode="wait">
{!collapseState.torbox && (
<motion.div
key="torbox-content"
variants={sectionVariants}
initial="collapsed"
animate="expanded"
exit="collapsed"
layout
>
<SettingsTorBox />
</motion.div>
)}
</AnimatePresence>
</div>
)}
<div className="settings-debrid__section">
<div className="settings-debrid__section-header">
<button
type="button"
className="settings-debrid__collapse-button"
onClick={() => toggleSection("allDebrid")}
aria-label={
collapseState.allDebrid
? "Expand All-Debrid section"
: "Collapse All-Debrid section"
}
>
<motion.div
variants={chevronVariants}
animate={collapseState.allDebrid ? "collapsed" : "expanded"}
>
<ChevronRightIcon size={16} />
</motion.div>
</button>
<h3 className="settings-debrid__section-title">All-Debrid</h3>
<span className="settings-debrid__beta-badge">BETA</span>
{userPreferences?.allDebridApiKey && (
<CheckCircleFillIcon
size={16}
className="settings-debrid__check-icon"
/>
)}
</div>
<AnimatePresence initial={true} mode="wait">
{!collapseState.allDebrid && (
<motion.div
key="alldebrid-content"
variants={sectionVariants}
initial="collapsed"
animate="expanded"
exit="collapsed"
layout
>
<SettingsAllDebrid />
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -1,7 +1,5 @@
import { Button } from "@renderer/components";
import { useTranslation } from "react-i18next";
import { SettingsRealDebrid } from "./settings-real-debrid";
import { SettingsAllDebrid } from "./settings-all-debrid";
import { SettingsGeneral } from "./settings-general";
import { SettingsBehavior } from "./settings-behavior";
import { SettingsDownloadSources } from "./settings-download-sources";
@@ -10,21 +8,17 @@ import {
SettingsContextProvider,
} from "@renderer/context";
import { SettingsAccount } from "./settings-account";
import { useFeature, useUserDetails } from "@renderer/hooks";
import { useUserDetails } from "@renderer/hooks";
import { useMemo } from "react";
import "./settings.scss";
import { SettingsAppearance } from "./aparence/settings-appearance";
import { SettingsTorBox } from "./settings-torbox";
import { SettingsDebrid } from "./settings-debrid";
export default function Settings() {
const { t } = useTranslation("settings");
const { userDetails } = useUserDetails();
const { isFeatureEnabled, Feature } = useFeature();
const isTorBoxEnabled = isFeatureEnabled(Feature.TorBox);
const categories = useMemo(() => {
const categories = [
{ tabLabel: t("general"), contentTitle: t("general") },
@@ -34,16 +28,7 @@ export default function Settings() {
tabLabel: t("appearance"),
contentTitle: t("appearance"),
},
...(isTorBoxEnabled
? [
{
tabLabel: "TorBox",
contentTitle: "TorBox",
},
]
: []),
{ tabLabel: "Real-Debrid", contentTitle: "Real-Debrid" },
{ tabLabel: "All-Debrid", contentTitle: "All-Debrid" },
{ tabLabel: t("debrid"), contentTitle: t("debrid") },
];
if (userDetails)
@@ -52,7 +37,7 @@ export default function Settings() {
{ tabLabel: t("account"), contentTitle: t("account") },
];
return categories;
}, [userDetails, t, isTorBoxEnabled]);
}, [userDetails, t]);
return (
<SettingsContextProvider>
@@ -76,15 +61,7 @@ export default function Settings() {
}
if (currentCategoryIndex === 4) {
return <SettingsTorBox />;
}
if (currentCategoryIndex === 5) {
return <SettingsRealDebrid />;
}
if (currentCategoryIndex === 6) {
return <SettingsAllDebrid />;
return <SettingsDebrid />;
}
return <SettingsAccount />;

View File

@@ -0,0 +1,80 @@
function removeZalgoText(text: string): string {
// Match combining characters that are commonly used in Zalgo text
// Using a more explicit approach to avoid misleading-character-class warning
const combiningMarks = [
/\u0300-\u036F/g, // Combining Diacritical Marks
/\u1AB0-\u1AFF/g, // Combining Diacritical Marks Extended
/\u1DC0-\u1DFF/g, // Combining Diacritical Marks Supplement
/\u20D0-\u20FF/g, // Combining Diacritical Marks for Symbols
/\uFE20-\uFE2F/g, // Combining Half Marks
];
let result = text;
for (const regex of combiningMarks) {
result = result.replace(regex, "");
}
return result;
}
export function sanitizeHtml(html: string): string {
if (!html || typeof html !== "string") {
return "";
}
const tempDiv = document.createElement("div");
tempDiv.innerHTML = html;
const disallowedSelectors = [
"script",
"style",
"iframe",
"object",
"embed",
"link",
"meta",
];
for (const sel of disallowedSelectors) {
for (const el of tempDiv.querySelectorAll(sel)) {
el.remove();
}
}
for (const el of tempDiv.querySelectorAll("*")) {
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
if (
name.startsWith("on") ||
name === "style" ||
name === "src" ||
name === "href"
) {
el.removeAttribute(attr.name);
}
}
}
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
const textNode = node as Text;
const value = textNode.nodeValue || "";
textNode.nodeValue = removeZalgoText(value);
}
const cleanHtml = tempDiv.innerHTML.trim();
return cleanHtml;
}
export function stripHtml(html: string): string {
if (!html || typeof html !== "string") {
return "";
}
const tempDiv = document.createElement("div");
tempDiv.innerHTML = html;
let cleanText = tempDiv.textContent || tempDiv.innerText || "";
cleanText = removeZalgoText(cleanText);
return cleanText;
}

View File

@@ -19,6 +19,7 @@ import { format } from "date-fns";
import { AchievementNotificationInfo } from "@types";
export * from "./constants";
export * from "./html-sanitizer";
export class UserNotLoggedInError extends Error {
constructor() {

View File

@@ -45,7 +45,7 @@ export interface ShopAssets {
libraryImageUrl: string;
logoImageUrl: string;
logoPosition: string | null;
coverImageUrl: string;
coverImageUrl: string | null;
}
export type ShopDetails = SteamAppDetails & {
@@ -182,6 +182,7 @@ export interface UserDetails {
bio: string;
featurebaseJwt: string;
subscription: Subscription | null;
karma: number;
quirks?: {
backupsPerGameLimit: number;
};
@@ -202,6 +203,7 @@ export interface UserProfile {
currentGame: UserProfileCurrentGame | null;
bio: string;
hasActiveSubscription: boolean;
karma: number;
quirks: {
backupsPerGameLimit: number;
};
@@ -233,7 +235,26 @@ export interface DownloadSourceValidationResult {
export interface GameStats {
downloadCount: number;
playerCount: number;
assets: ShopAssets | null;
averageScore: number | null;
reviewCount: number;
}
export interface GameReview {
id: string;
reviewHtml: string;
score: number;
createdAt: string;
updatedAt: string;
upvotes: number;
downvotes: number;
isBlocked: boolean;
hasUpvoted: boolean;
hasDownvoted: boolean;
user: {
id: string;
displayName: string;
profileImageUrl: string | null;
} | null;
}
export interface TrendingGame extends ShopAssets {

446
yarn.lock
View File

@@ -2073,6 +2073,11 @@
redux-thunk "^3.1.0"
reselect "^5.1.0"
"@remirror/core-constants@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@remirror/core-constants/-/core-constants-3.0.0.tgz#96fdb89d25c62e7b6a5d08caf0ce5114370e3b8f"
integrity sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==
"@remix-run/router@1.19.2":
version "1.19.2"
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.19.2.tgz#0c896535473291cb41f152c180bedd5680a3b273"
@@ -2840,6 +2845,201 @@
dependencies:
uint8-util "^2.2.5"
"@tiptap/core@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.6.2.tgz#abda4116e4a39779fca7070e316b9ed9fdcded7e"
integrity sha512-XKZYrCVFsyQGF6dXQR73YR222l/76wkKfZ+2/4LCrem5qtcOarmv5pYxjUBG8mRuBPskTTBImSFTeQltJIUNCg==
"@tiptap/extension-blockquote@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-3.6.2.tgz#01b589565c87a691e586e189ddcbcdc5f35618fc"
integrity sha512-TSl41UZhi3ugJMDaf91CA4F5NeFylgTSm6GqnZAHOE6IREdCpAK3qej2zaW3EzfpzxW7sRGLlytkZRvpeyjgJA==
"@tiptap/extension-bold@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-3.6.2.tgz#ed721961daf3210c7ba4433a5aeae981043c2d77"
integrity sha512-Q9KO8CCPCAXYqHzIw8b/ookVmrfqfCg2cyh9h9Hvw6nhO4LOOnJMcGVmWsrpFItbwCGMafI5iY9SbSj7RpCyuw==
"@tiptap/extension-bubble-menu@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.6.2.tgz#237d84f217c8da52c0bc5265a36557fb27d64eaf"
integrity sha512-OF5CxCmYExcXZjcectwAeujSeDZ4IltPy+SsqBZLbQRDts9PQhzv5azGDvYdL2eMMkT3yhO2gWkXxSHMxI3O6w==
dependencies:
"@floating-ui/dom" "^1.0.0"
"@tiptap/extension-bullet-list@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-3.6.2.tgz#be20b6c795c53bc0d199bdc4dd9f01b6270a1bee"
integrity sha512-Y5Uhir+za7xMm6RAe592aNNlLvCayVSQt2HfSckOr+c/v/Zd2bFUHv0ef6l/nUzUhDBs32Bg9SvfWx/yyMyNEw==
"@tiptap/extension-code-block@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-3.6.2.tgz#cb3f6f607dcfb36e3eff25255fdcfdedfb3940a7"
integrity sha512-5jfoiQ/3AUrIyuVU1NmEXar6sZFnY7wDFf3ZU2zpcBUG++yg/CmpOe5bXpoolczhl58cM/jyBG5gumQjyOxLNg==
"@tiptap/extension-code@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-3.6.2.tgz#5c6500d748fd4f52ddbe01ff114d4933c7a09e8f"
integrity sha512-U6jilbcpCxtLZAgJrTapXzzVJTXnS78kJITFSOLyGCTyGSm6PXatQ4hnaxVGmNet66GySONGjhwAVZ8+l94Rwg==
"@tiptap/extension-document@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-3.6.2.tgz#5c3f3a85d12868f5d4e6d6d258b8fa0b8000b778"
integrity sha512-4qg3KWL3aO1M7hfDpZR6/vSo7Cfqr3McyGUfqb/BXqYDW1DwT8jJkDTcHrGU7WUKRlWgoyPyzM8pZiGlP0uQHg==
"@tiptap/extension-dropcursor@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.6.2.tgz#22a64a4da25ac17cf0cd33e1e924762000152817"
integrity sha512-6R5sma/i2TKd5h9OpIcy3a0wOGp5BNT/zIgnE/1HTmKi40eNcCAVe8sxd6+iWA5ETONP1E48kDy4hqA5ZzZCiQ==
"@tiptap/extension-floating-menu@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.6.2.tgz#cc9c97cdd5fa55407631d3135e00ca8051516444"
integrity sha512-ym7YMKGY3QhFUKUS6JYOwtdi8s2PeGmOhu7TwI9/U0LmGbELeKJBJl2BP1yB+Sjpv25pVL++CwJQ6dsrjDlZ8g==
"@tiptap/extension-gapcursor@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.6.2.tgz#790c94d20a5b8ded4c0d38960254d24704a2bc08"
integrity sha512-gXg+EvUKlv3ZO1GxKkRmAsi/V4yyA8AzLW6ppOcYrM2CKf6epmPaVRgAjdwHCA6cm3QuCBJyWeGTCAjhjNakhw==
"@tiptap/extension-hard-break@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.6.2.tgz#3c379d9104cd7d9e942277f22ba62c57fae267ad"
integrity sha512-ncuPBHhGY58QjluJvEH6vXotaa1QZ/vphXBGAr55kiATZwMIEHgwh2Hgc6AiFTcw057gabGn6jNFDfRB+HjbmA==
"@tiptap/extension-heading@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-3.6.2.tgz#3884c309de60c9d61f1bb60c521410b3a0d88ed7"
integrity sha512-JQ2yjwXGAiwGc+MhS1mULBr354MHfmWqVDQLRg8ey6LkdXggTDDJ1Ni3GrUS7B5YcA/ICdhr4krXaQpNkT5Syw==
"@tiptap/extension-horizontal-rule@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.6.2.tgz#f5680b3209bc48bf8635f3674355bd3d47f15622"
integrity sha512-3TlPqedPDM9QkRTUPhOTxNxQVPSsBwlsuLrAZOgyM1y871Xi7M1DFX0h9LLXuqzPndYzUY16NjrfBGFJX+O56w==
"@tiptap/extension-italic@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.6.2.tgz#ea314f5e723499c9e7a1021ad7836693db9c653c"
integrity sha512-46zYKqM3o9w1A2G9hWr0ERGbJpqIncoH45XIfLdAI6ZldZVVf+NeXMGwjOPf4+03cZ5/emk3MRTnVp9vF4ToIg==
"@tiptap/extension-link@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-3.6.2.tgz#5577d100cd3b735247db327b15d91de025cc76b6"
integrity sha512-3yiRDWa187h30e6iUOJeejZLsbzbJthLfBwTeJGx7pHh7RngsEW82npBRuqLoI3udhJGTkXbzwAFZ9qOGOjl1Q==
dependencies:
linkifyjs "^4.3.2"
"@tiptap/extension-list-item@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-3.6.2.tgz#705f782a872e4bbb6f0e125fe277c45aeefe8161"
integrity sha512-ma/D2GKylpNB04FfNI3tDMY+C9nz7Yk85H21YTIGv8QL5KlDK97L6orydmx6IVRc2nNMZQVitBIEKDOXcczX9w==
"@tiptap/extension-list-keymap@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-list-keymap/-/extension-list-keymap-3.6.2.tgz#f14e173325b443a89dbbca7f418b76ec3d5c9a21"
integrity sha512-1kl/lggH+LL/FUwcSx8p761ebk9L5ZGK06mGyDDU9XiGLS310CktZYLnpEuFgn/oMPbRHo26oNl9SXLn1/U53A==
"@tiptap/extension-list@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-list/-/extension-list-3.6.2.tgz#beb4d965f48085fa7f69197e10109cde8c175046"
integrity sha512-ZLaEHGVq4eL26hZZFE9e7RArk2rEjcVstN/YTRTKElTnLaf58kLTKN3nlgy1PWGwzfWGUuXURBuEBLaq5l6djg==
"@tiptap/extension-ordered-list@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-3.6.2.tgz#43b83757f67264ff0050c03825e780da43680c1d"
integrity sha512-KdJ5MLIw19N+XiqQ2COXGtaq9TzUbtlLE5dgYCJQ2EumeZKIGELvUnHjrnIB9gH/gRlMs+hprLTh23xVUDJovg==
"@tiptap/extension-paragraph@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.6.2.tgz#d6cc89cdc369e463dd7dd4eb9121718441c984a0"
integrity sha512-jeJWj2xKib3392iHQEcB7wYZ30dUgXuwqpCTwtN9eANor+Zvv6CpDKBs1R2al6BYFbIJCgKeTulqxce0yoC80g==
"@tiptap/extension-strike@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.6.2.tgz#2dab3f253a4ecfd525c5609ab5edb9325a6364c2"
integrity sha512-976u5WaioIN/0xCjl/UIEypmzACzxgVz6OGgfIsYyreMUiPjhhgzXb0A/2Po5p3nZpKcaMcxifOdhqdw+lDpIQ==
"@tiptap/extension-text@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.6.2.tgz#77313173a9f91208e40d298bc2d40b39371b8fca"
integrity sha512-fFSUEv1H3lM92yr6jZdELk0gog8rPTK5hTf08kP8RsY8pA80Br1ADVenejrMV4UNTmT1JWTXGBGhMqfQFHUvAQ==
"@tiptap/extension-underline@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.6.2.tgz#9f0dfb9722bd3d0cd144fc955bcb94a3fcf5eac2"
integrity sha512-IrG6vjxTMI2EeyhZCtx0sNTEu83PsAvzIh4vxmG1fUi/RYokks+sFbgGMuq0jtO96iVNEszlpAC/vaqfxFJwew==
"@tiptap/extensions@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.6.2.tgz#591fbd5b9fa41f98f69dbd7d21d5d38a2241d94b"
integrity sha512-tg7/DgaI6SpkeawryapUtNoBxsJUMJl3+nSjTfTvsaNXed+BHzLPsvmPbzlF9ScrAbVEx8nj6CCkneECYIQ4CQ==
"@tiptap/pm@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-3.6.2.tgz#2121d4917f92d11229529a26955a7033aa8a8843"
integrity sha512-g+NXjqjbj6NfHOMl22uNWVYIu8oCq7RFfbnpohPMsSKJLaHYE8mJR++7T6P5R9FoqhIFdwizg1jTpwRU5CHqXQ==
dependencies:
prosemirror-changeset "^2.3.0"
prosemirror-collab "^1.3.1"
prosemirror-commands "^1.6.2"
prosemirror-dropcursor "^1.8.1"
prosemirror-gapcursor "^1.3.2"
prosemirror-history "^1.4.1"
prosemirror-inputrules "^1.4.0"
prosemirror-keymap "^1.2.2"
prosemirror-markdown "^1.13.1"
prosemirror-menu "^1.2.4"
prosemirror-model "^1.24.1"
prosemirror-schema-basic "^1.2.3"
prosemirror-schema-list "^1.5.0"
prosemirror-state "^1.4.3"
prosemirror-tables "^1.6.4"
prosemirror-trailing-node "^3.0.0"
prosemirror-transform "^1.10.2"
prosemirror-view "^1.38.1"
"@tiptap/react@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/react/-/react-3.6.2.tgz#5495776c9051a60ece7522da176c9f211a67c7df"
integrity sha512-jgG+bM/GDvI6jnqW3YyLtr/vOR6iO2ta9PYVzoWqNYIxISsMOJeRfinsIqB8l6hkiGZApn9bQji6oUXTc59fgA==
dependencies:
"@types/use-sync-external-store" "^0.0.6"
fast-deep-equal "^3.1.3"
use-sync-external-store "^1.4.0"
optionalDependencies:
"@tiptap/extension-bubble-menu" "^3.6.2"
"@tiptap/extension-floating-menu" "^3.6.2"
"@tiptap/starter-kit@^3.6.2":
version "3.6.2"
resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-3.6.2.tgz#ddd5612d4836a87082254779c9f152bb51e757bc"
integrity sha512-nPzraIx/f1cOUNqG1LSC0OTnEu3mudcN3jQVuyGh3dvdOnik7FUciJEVfHKnloAyeoijidEeiLpiGHInp2uREg==
dependencies:
"@tiptap/core" "^3.6.2"
"@tiptap/extension-blockquote" "^3.6.2"
"@tiptap/extension-bold" "^3.6.2"
"@tiptap/extension-bullet-list" "^3.6.2"
"@tiptap/extension-code" "^3.6.2"
"@tiptap/extension-code-block" "^3.6.2"
"@tiptap/extension-document" "^3.6.2"
"@tiptap/extension-dropcursor" "^3.6.2"
"@tiptap/extension-gapcursor" "^3.6.2"
"@tiptap/extension-hard-break" "^3.6.2"
"@tiptap/extension-heading" "^3.6.2"
"@tiptap/extension-horizontal-rule" "^3.6.2"
"@tiptap/extension-italic" "^3.6.2"
"@tiptap/extension-link" "^3.6.2"
"@tiptap/extension-list" "^3.6.2"
"@tiptap/extension-list-item" "^3.6.2"
"@tiptap/extension-list-keymap" "^3.6.2"
"@tiptap/extension-ordered-list" "^3.6.2"
"@tiptap/extension-paragraph" "^3.6.2"
"@tiptap/extension-strike" "^3.6.2"
"@tiptap/extension-text" "^3.6.2"
"@tiptap/extension-underline" "^3.6.2"
"@tiptap/extensions" "^3.6.2"
"@tiptap/pm" "^3.6.2"
"@tokenizer/inflate@^0.2.6":
version "0.2.7"
resolved "https://registry.yarnpkg.com/@tokenizer/inflate/-/inflate-0.2.7.tgz#32dd9dfc9abe457c89b3d9b760fc0690c85a103b"
@@ -3014,6 +3214,11 @@
dependencies:
"@types/node" "*"
"@types/linkify-it@^5":
version "5.0.0"
resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76"
integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==
"@types/lodash-es@^4.17.12":
version "4.17.12"
resolved "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz"
@@ -3033,6 +3238,19 @@
dependencies:
"@types/node" "*"
"@types/markdown-it@^14.0.0":
version "14.1.2"
resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61"
integrity sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==
dependencies:
"@types/linkify-it" "^5"
"@types/mdurl" "^2"
"@types/mdurl@^2":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd"
integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==
"@types/minimatch@*":
version "5.1.2"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca"
@@ -3125,6 +3343,11 @@
resolved "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz"
integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==
"@types/use-sync-external-store@^0.0.6":
version "0.0.6"
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc"
integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==
"@types/user-agents@^1.0.4":
version "1.0.4"
resolved "https://registry.npmjs.org/@types/user-agents/-/user-agents-1.0.4.tgz"
@@ -4209,6 +4432,11 @@ create-require@^1.1.0:
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
crelt@^1.0.0:
version "1.0.6"
resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72"
integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==
cross-fetch-ponyfill@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/cross-fetch-ponyfill/-/cross-fetch-ponyfill-1.0.3.tgz#5c5524e3bd3374e71d5016c2327e416369a57527"
@@ -6614,6 +6842,18 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
linkify-it@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.0.tgz#9ef238bfa6dc70bd8e7f9572b52d369af569b421"
integrity sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==
dependencies:
uc.micro "^2.0.0"
linkifyjs@^4.3.2:
version "4.3.2"
resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.2.tgz#d97eb45419aabf97ceb4b05a7adeb7b8c8ade2b1"
integrity sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==
locate-path@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
@@ -6779,6 +7019,11 @@ lru-cache@^7.7.1:
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89"
integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==
lucide-react@^0.544.0:
version "0.544.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.544.0.tgz#4719953c10fd53a64dd8343bb0ed16ec79f3eeef"
integrity sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==
magic-string@^0.30.17:
version "0.30.17"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453"
@@ -6822,6 +7067,18 @@ make-fetch-happen@^10.2.1:
socks-proxy-agent "^7.0.0"
ssri "^9.0.0"
markdown-it@^14.0.0:
version "14.1.0"
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.1.0.tgz#3c3c5992883c633db4714ccb4d7b5935d98b7d45"
integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==
dependencies:
argparse "^2.0.1"
entities "^4.4.0"
linkify-it "^5.0.0"
mdurl "^2.0.0"
punycode.js "^2.3.1"
uc.micro "^2.1.0"
matcher@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/matcher/-/matcher-3.0.0.tgz#bd9060f4c5b70aa8041ccc6f80368760994f30ca"
@@ -6844,6 +7101,11 @@ maybe-combine-errors@^1.0.0:
resolved "https://registry.yarnpkg.com/maybe-combine-errors/-/maybe-combine-errors-1.0.0.tgz#e9592832e61fc47643a92cff3c1f33e27211e5be"
integrity sha512-eefp6IduNPT6fVdwPp+1NgD0PML1NU5P6j1Mj5nz1nidX8/sWY7119WL8vTAHgqfsY74TzW0w1XPgdYEKkGZ5A==
mdurl@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==
meow@^12.0.1:
version "12.1.1"
resolved "https://registry.yarnpkg.com/meow/-/meow-12.1.1.tgz#e558dddbab12477b69b2e9a2728c327f191bace6"
@@ -7264,6 +7526,11 @@ ora@^5.1.0:
strip-ansi "^6.0.0"
wcwidth "^1.0.1"
orderedmap@^2.0.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-2.1.1.tgz#61481269c44031c449915497bf5a4ad273c512d2"
integrity sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==
own-keys@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358"
@@ -7499,6 +7766,160 @@ property-expr@^2.0.5:
resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.6.tgz#f77bc00d5928a6c748414ad12882e83f24aec1e8"
integrity sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==
prosemirror-changeset@^2.3.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz#eee3299cfabc7a027694e9abdc4e85505e9dd5e7"
integrity sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==
dependencies:
prosemirror-transform "^1.0.0"
prosemirror-collab@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz#0e8c91e76e009b53457eb3b3051fb68dad029a33"
integrity sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==
dependencies:
prosemirror-state "^1.0.0"
prosemirror-commands@^1.0.0, prosemirror-commands@^1.6.2:
version "1.7.1"
resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz#d101fef85618b1be53d5b99ea17bee5600781b38"
integrity sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==
dependencies:
prosemirror-model "^1.0.0"
prosemirror-state "^1.0.0"
prosemirror-transform "^1.10.2"
prosemirror-dropcursor@^1.8.1:
version "1.8.2"
resolved "https://registry.yarnpkg.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz#2ed30c4796109ddeb1cf7282372b3850528b7228"
integrity sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==
dependencies:
prosemirror-state "^1.0.0"
prosemirror-transform "^1.1.0"
prosemirror-view "^1.1.0"
prosemirror-gapcursor@^1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz#5fa336b83789c6199a7341c9493587e249215cb4"
integrity sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==
dependencies:
prosemirror-keymap "^1.0.0"
prosemirror-model "^1.0.0"
prosemirror-state "^1.0.0"
prosemirror-view "^1.0.0"
prosemirror-history@^1.0.0, prosemirror-history@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/prosemirror-history/-/prosemirror-history-1.4.1.tgz#cc370a46fb629e83a33946a0e12612e934ab8b98"
integrity sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==
dependencies:
prosemirror-state "^1.2.2"
prosemirror-transform "^1.0.0"
prosemirror-view "^1.31.0"
rope-sequence "^1.3.0"
prosemirror-inputrules@^1.4.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/prosemirror-inputrules/-/prosemirror-inputrules-1.5.0.tgz#e22bfaf1d6ea4fe240ad447c184af3d520d43c37"
integrity sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA==
dependencies:
prosemirror-state "^1.0.0"
prosemirror-transform "^1.0.0"
prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.2.2:
version "1.2.3"
resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz#c0f6ab95f75c0b82c97e44eb6aaf29cbfc150472"
integrity sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==
dependencies:
prosemirror-state "^1.0.0"
w3c-keyname "^2.2.0"
prosemirror-markdown@^1.13.1:
version "1.13.2"
resolved "https://registry.yarnpkg.com/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz#863eb3fd5f57a444e4378174622b562735b1c503"
integrity sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==
dependencies:
"@types/markdown-it" "^14.0.0"
markdown-it "^14.0.0"
prosemirror-model "^1.25.0"
prosemirror-menu@^1.2.4:
version "1.2.5"
resolved "https://registry.yarnpkg.com/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz#dea00e7b623cea89f4d76963bee22d2ac2343250"
integrity sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==
dependencies:
crelt "^1.0.0"
prosemirror-commands "^1.0.0"
prosemirror-history "^1.0.0"
prosemirror-state "^1.0.0"
prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.24.1, prosemirror-model@^1.25.0:
version "1.25.3"
resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.25.3.tgz#c657c60a361cb1e9c9f683d19118c0af50a6f7a9"
integrity sha512-dY2HdaNXlARknJbrManZ1WyUtos+AP97AmvqdOQtWtrrC5g4mohVX5DTi9rXNFSk09eczLq9GuNTtq3EfMeMGA==
dependencies:
orderedmap "^2.0.0"
prosemirror-schema-basic@^1.2.3:
version "1.2.4"
resolved "https://registry.yarnpkg.com/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz#389ce1ec09b8a30ea9bbb92c58569cb690c2d695"
integrity sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==
dependencies:
prosemirror-model "^1.25.0"
prosemirror-schema-list@^1.5.0:
version "1.5.1"
resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz#5869c8f749e8745c394548bb11820b0feb1e32f5"
integrity sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==
dependencies:
prosemirror-model "^1.0.0"
prosemirror-state "^1.0.0"
prosemirror-transform "^1.7.3"
prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.3:
version "1.4.3"
resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.4.3.tgz#94aecf3ffd54ec37e87aa7179d13508da181a080"
integrity sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==
dependencies:
prosemirror-model "^1.0.0"
prosemirror-transform "^1.0.0"
prosemirror-view "^1.27.0"
prosemirror-tables@^1.6.4:
version "1.8.1"
resolved "https://registry.yarnpkg.com/prosemirror-tables/-/prosemirror-tables-1.8.1.tgz#896a234e3e18240b629b747a871369dae78c8a9a"
integrity sha512-DAgDoUYHCcc6tOGpLVPSU1k84kCUWTWnfWX3UDy2Delv4ryH0KqTD6RBI6k4yi9j9I8gl3j8MkPpRD/vWPZbug==
dependencies:
prosemirror-keymap "^1.2.2"
prosemirror-model "^1.25.0"
prosemirror-state "^1.4.3"
prosemirror-transform "^1.10.3"
prosemirror-view "^1.39.1"
prosemirror-trailing-node@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz#5bc223d4fc1e8d9145e4079ec77a932b54e19e04"
integrity sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==
dependencies:
"@remirror/core-constants" "3.0.0"
escape-string-regexp "^4.0.0"
prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.10.2, prosemirror-transform@^1.10.3, prosemirror-transform@^1.7.3:
version "1.10.4"
resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz#56419eac14f9f56612c806ae46f9238648f3f02e"
integrity sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==
dependencies:
prosemirror-model "^1.21.0"
prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.39.1:
version "1.41.2"
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.2.tgz#e69ad3883bfd3c9f3c9cf6da5cee940210df0b6f"
integrity sha512-PGS/jETmh+Qjmre/6vcG7SNHAKiGc4vKOJmHMPRmvcUl7ISuVtrtHmH06UDUwaim4NDJfZfVMl7U7JkMMETa6g==
dependencies:
prosemirror-model "^1.20.0"
prosemirror-state "^1.0.0"
prosemirror-transform "^1.1.0"
proxy-from-env@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
@@ -7517,6 +7938,11 @@ pump@^3.0.0:
end-of-stream "^1.1.0"
once "^1.3.1"
punycode.js@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
@@ -7901,6 +8327,11 @@ rollup@^4.20.0:
"@rollup/rollup-win32-x64-msvc" "4.23.0"
fsevents "~2.3.2"
rope-sequence@^1.3.0:
version "1.3.4"
resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.4.tgz#df85711aaecd32f1e756f76e43a415171235d425"
integrity sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==
rrweb-cssom@^0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz#c73451a484b86dd7cfb1e0b2898df4b703183e4b"
@@ -8902,6 +9333,11 @@ typescript@^5.3.3, typescript@^5.4.3:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0"
integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==
uc.micro@^2.0.0, uc.micro@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
uint8-util@^2.2.2, uint8-util@^2.2.5:
version "2.2.5"
resolved "https://registry.yarnpkg.com/uint8-util/-/uint8-util-2.2.5.tgz#f1a8ff800e4e10a3ac1c82ee3667c99245123896"
@@ -9021,6 +9457,11 @@ use-sync-external-store@^1.0.0:
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9"
integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==
use-sync-external-store@^1.4.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#55122e2a3edd2a6c106174c27485e0fd59bcfca0"
integrity sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==
user-agents@^1.1.387:
version "1.1.387"
resolved "https://registry.yarnpkg.com/user-agents/-/user-agents-1.1.387.tgz#afc69da00b50eee7ffa17724890e755a6672b99f"
@@ -9087,6 +9528,11 @@ void-elements@3.1.0:
resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09"
integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==
w3c-keyname@^2.2.0:
version "2.2.8"
resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5"
integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==
w3c-xmlserializer@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c"