diff --git a/package.json b/package.json index e21c962a..e2f750bd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index d5a0708c..0ca77d87 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 37569701..91a10149 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -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", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index bf9bf751..f3b25ee4 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -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": "Достижение разблокировано", diff --git a/src/main/constants.ts b/src/main/constants.ts index b067be80..82b99b2a 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -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" +); diff --git a/src/main/events/catalogue/check-game-review.ts b/src/main/events/catalogue/check-game-review.ts new file mode 100644 index 00000000..5fa71e29 --- /dev/null +++ b/src/main/events/catalogue/check-game-review.ts @@ -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); diff --git a/src/main/events/catalogue/create-game-review.ts b/src/main/events/catalogue/create-game-review.ts new file mode 100644 index 00000000..57c74d45 --- /dev/null +++ b/src/main/events/catalogue/create-game-review.ts @@ -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); diff --git a/src/main/events/catalogue/delete-review.ts b/src/main/events/catalogue/delete-review.ts new file mode 100644 index 00000000..e617a288 --- /dev/null +++ b/src/main/events/catalogue/delete-review.ts @@ -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); diff --git a/src/main/events/catalogue/get-game-assets.ts b/src/main/events/catalogue/get-game-assets.ts new file mode 100644 index 00000000..de1d2b1f --- /dev/null +++ b/src/main/events/catalogue/get-game-assets.ts @@ -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( + `/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); diff --git a/src/main/events/catalogue/get-game-reviews.ts b/src/main/events/catalogue/get-game-reviews.ts new file mode 100644 index 00000000..8f29db3f --- /dev/null +++ b/src/main/events/catalogue/get-game-reviews.ts @@ -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); diff --git a/src/main/events/catalogue/save-game-shop-assets.ts b/src/main/events/catalogue/save-game-shop-assets.ts deleted file mode 100644 index bf5f8b81..00000000 --- a/src/main/events/catalogue/save-game-shop-assets.ts +++ /dev/null @@ -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 => { - 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); diff --git a/src/main/events/catalogue/vote-review.ts b/src/main/events/catalogue/vote-review.ts new file mode 100644 index 00000000..a562eada --- /dev/null +++ b/src/main/events/catalogue/vote-review.ts @@ -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); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 713ce581..dbb4039e 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -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"; diff --git a/src/main/events/library/add-custom-game-to-library.ts b/src/main/events/library/add-custom-game-to-library.ts index 47fd3436..f85c008b 100644 --- a/src/main/events/library/add-custom-game-to-library.ts +++ b/src/main/events/library/add-custom-game-to-library.ts @@ -27,6 +27,7 @@ const addCustomGameToLibrary = async ( } const assets = { + updatedAt: Date.now(), objectId, shop, title, diff --git a/src/main/events/library/create-steam-shortcut.ts b/src/main/events/library/create-steam-shortcut.ts index f83dd675..d5434d7f 100644 --- a/src/main/events/library/create-steam-shortcut.ts +++ b/src/main/events/library/create-steam-shortcut.ts @@ -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( - `/games/${shop}/${objectId}/stats` - ); + const assets = await getGameAssets(objectId, shop); const steamUserIds = await getSteamUsersIds(); diff --git a/src/main/events/misc/check-homebrew-folder-exists.ts b/src/main/events/misc/check-homebrew-folder-exists.ts new file mode 100644 index 00000000..32e09754 --- /dev/null +++ b/src/main/events/misc/check-homebrew-folder-exists.ts @@ -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 => { + const homebrewPath = path.dirname(DECKY_PLUGINS_LOCATION); + return fs.existsSync(homebrewPath); +}; + +registerEvent("checkHomebrewFolderExists", checkHomebrewFolderExists); diff --git a/src/main/events/misc/get-hydra-decky-plugin-info.ts b/src/main/events/misc/get-hydra-decky-plugin-info.ts new file mode 100644 index 00000000..430bd691 --- /dev/null +++ b/src/main/events/misc/get-hydra-decky-plugin-info.ts @@ -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( + "/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); diff --git a/src/main/events/misc/install-hydra-decky-plugin.ts b/src/main/events/misc/install-hydra-decky-plugin.ts new file mode 100644 index 00000000..e14ea2ed --- /dev/null +++ b/src/main/events/misc/install-hydra-decky-plugin.ts @@ -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); diff --git a/src/main/level/sublevels/game-shop-assets.ts b/src/main/level/sublevels/game-shop-assets.ts index 561d85df..806e041f 100644 --- a/src/main/level/sublevels/game-shop-assets.ts +++ b/src/main/level/sublevels/game-shop-assets.ts @@ -3,9 +3,9 @@ import type { ShopAssets } from "@types"; import { db } from "../level"; import { levelKeys } from "./keys"; -export const gamesShopAssetsSublevel = db.sublevel( - levelKeys.gameShopAssets, - { - valueEncoding: "json", - } -); +export const gamesShopAssetsSublevel = db.sublevel< + string, + ShopAssets & { updatedAt: number } +>(levelKeys.gameShopAssets, { + valueEncoding: "json", +}); diff --git a/src/main/main.ts b/src/main/main.ts index 67391057..9b8ecc2b 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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(); diff --git a/src/main/services/decky-plugin.ts b/src/main/services/decky-plugin.ts new file mode 100644 index 00000000..4dc1fdad --- /dev/null +++ b/src/main/services/decky-plugin.ts @@ -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 { + if (this.releaseInfo) { + return this.releaseInfo; + } + + try { + const response = await HydraApi.get( + "/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 { + 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 { + 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 { + 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 { + 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 { + if (this.needsSudo()) { + await this.installPluginWithSudo(extractPath); + } else { + await this.installPluginWithoutSudo(extractPath); + } + } + + private static async updatePlugin(): Promise { + 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 { + 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", + }; + } + } +} diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 727805c7..88b39d1b 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -17,3 +17,4 @@ export * from "./system-path"; export * from "./library-sync"; export * from "./wine"; export * from "./lock"; +export * from "./decky-plugin"; diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index 68b4b835..f7ea2744 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -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 diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index fa9ac593..d28c3cd7 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -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, diff --git a/src/main/services/ws/events/friend-game-session.ts b/src/main/services/ws/events/friend-game-session.ts index 67967b3c..b089c421 100644 --- a/src/main/services/ws/events/friend-game-session.ts +++ b/src/main/services/ws/events/friend-game-session.ts @@ -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( @@ -14,12 +14,9 @@ export const friendGameSessionEvent = async (payload: FriendGameSession) => { if (userPreferences?.friendStartGameNotificationsEnabled === false) return; - const [friend, gameStats] = await Promise.all([ - HydraApi.get(`/users/${payload.friendId}`), - HydraApi.get(`/games/steam/${payload.objectId}/stats`), - ]).catch(() => [null, null]); + const friend = await HydraApi.get(`/users/${payload.friendId}`); - if (friend && gameStats) { - publishFriendStartedPlayingGameNotification(friend, gameStats); + if (friend) { + publishFriendStartedPlayingGameNotification(friend); } }; diff --git a/src/preload/index.ts b/src/preload/index.ts index cb41e176..6e36fcf0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 */ diff --git a/src/renderer/src/assets/icons/decky.png b/src/renderer/src/assets/icons/decky.png new file mode 100644 index 00000000..205552dd Binary files /dev/null and b/src/renderer/src/assets/icons/decky.png differ diff --git a/src/renderer/src/components/confirm-modal/confirm-modal.scss b/src/renderer/src/components/confirm-modal/confirm-modal.scss deleted file mode 100644 index e5bda187..00000000 --- a/src/renderer/src/components/confirm-modal/confirm-modal.scss +++ /dev/null @@ -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; - } -} diff --git a/src/renderer/src/components/confirm-modal/confirm-modal.tsx b/src/renderer/src/components/confirm-modal/confirm-modal.tsx deleted file mode 100644 index 75a8f5c9..00000000 --- a/src/renderer/src/components/confirm-modal/confirm-modal.tsx +++ /dev/null @@ -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; - 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 ( - -
- - - -
-
- ); -} diff --git a/src/renderer/src/components/confirmation-modal/confirmation-modal.scss b/src/renderer/src/components/confirmation-modal/confirmation-modal.scss index 428818c4..7689ebcd 100644 --- a/src/renderer/src/components/confirmation-modal/confirmation-modal.scss +++ b/src/renderer/src/components/confirmation-modal/confirmation-modal.scss @@ -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; diff --git a/src/renderer/src/components/confirmation-modal/confirmation-modal.tsx b/src/renderer/src/components/confirmation-modal/confirmation-modal.tsx index 63256935..f81453fa 100644 --- a/src/renderer/src/components/confirmation-modal/confirmation-modal.tsx +++ b/src/renderer/src/components/confirmation-modal/confirmation-modal.tsx @@ -42,7 +42,7 @@ export function ConfirmationModal({ {cancelButtonLabel} ))} + {window.electron.platform === "linux" && homebrewFolderExists && ( +
  • + +
  • + )} @@ -321,18 +411,20 @@ export function Sidebar() { - {hasActiveSubscription && ( - - )} +
    + {hasActiveSubscription && ( + + )} +
    + )} + + {game?.shop !== "custom" && ( +
    + {showReviewForm && ( + <> +
    +

    + {t("leave_a_review")} +

    +
    + +
    +
    +
    +
    + + + +
    +
    + MAX_REVIEW_CHARS + ? "over-limit" + : "" + } + > + {reviewCharCount}/{MAX_REVIEW_CHARS} + +
    +
    +
    + +
    +
    + +
    +
    +
    + {[1, 2, 3, 4, 5].map((starValue) => ( + + ))} +
    +
    + + +
    +
    + + )} + + {showReviewForm && ( +
    + )} + +
    +
    +
    +

    + {t("reviews")} +

    + + {totalReviewCount} + +
    +
    + + + {reviewsLoading && reviews.length === 0 && ( +
    + {t("loading_reviews")} +
    + )} + + {!reviewsLoading && reviews.length === 0 && ( +
    +
    + +
    +

    + {t("no_reviews_yet")} +

    +

    + {t("be_first_to_review")} +

    +
    + )} + + {reviews.map((review) => ( +
    + {review.isBlocked && + !visibleBlockedReviews.has(review.id) ? ( +
    + Review from blocked user —{" "} + +
    + ) : ( + <> +
    +
    + {review.user?.profileImageUrl && ( + + )} +
    + +
    + + {formatDistance( + new Date(review.createdAt), + new Date(), + { addSuffix: true } + )} +
    +
    +
    +
    + {[1, 2, 3, 4, 5].map((starValue) => ( + + ))} +
    +
    +
    +
    +
    + + handleVoteReview(review.id, "upvote") + } + animate={ + review.hasUpvoted + ? { + scale: [1, 1.2, 1], + transition: { duration: 0.3 }, + } + : {} + } + > + + + + (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)} + + + + + handleVoteReview(review.id, "downvote") + } + animate={ + review.hasDownvoted + ? { + scale: [1, 1.2, 1], + transition: { duration: 0.3 }, + } + : {} + } + > + + + + (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)} + + + +
    + {userDetails?.id === review.user?.id && ( + + )} + {review.isBlocked && + visibleBlockedReviews.has(review.id) && ( + + )} +
    + + )} +
    + ))} + + {hasMoreReviews && !reviewsLoading && ( + + )} + + {reviewsLoading && reviews.length > 0 && ( +
    + {t("loading_more_reviews")} +
    + )} +
    +
    + )}
    {game?.shop !== "custom" && } @@ -251,6 +1016,15 @@ export function GameDetailsContent() { onGameUpdated={handleGameUpdated} /> )} + + { + setShowDeleteReviewModal(false); + setReviewToDelete(null); + }} + onConfirm={confirmDeleteReview} + /> ); } diff --git a/src/renderer/src/pages/game-details/game-details-skeleton.tsx b/src/renderer/src/pages/game-details/game-details-skeleton.tsx index c39da3bb..750f92e1 100644 --- a/src/renderer/src/pages/game-details/game-details-skeleton.tsx +++ b/src/renderer/src/pages/game-details/game-details-skeleton.tsx @@ -35,6 +35,11 @@ export function GameDetailsSkeleton() { ))} +
    diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss index e1140d31..aee2e639 100644 --- a/src/renderer/src/pages/game-details/game-details.scss +++ b/src/renderer/src/pages/game-details/game-details.scss @@ -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; + } + } + } } diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx index e23120a8..2e91d361 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx @@ -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" : ""}`} > {t("download")} diff --git a/src/renderer/src/pages/game-details/modals/delete-review-modal.scss b/src/renderer/src/pages/game-details/modals/delete-review-modal.scss new file mode 100644 index 00000000..34e916ad --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/delete-review-modal.scss @@ -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; + } +} diff --git a/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx b/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx new file mode 100644 index 00000000..fe612bbd --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx @@ -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) { + const { t } = useTranslation("game_details"); + + const handleDeleteReview = () => { + onConfirm(); + onClose(); + }; + + return ( + +
    + + + +
    +
    + ); +} diff --git a/src/renderer/src/pages/game-details/modals/index.ts b/src/renderer/src/pages/game-details/modals/index.ts index 724e0003..d0f27b0f 100644 --- a/src/renderer/src/pages/game-details/modals/index.ts +++ b/src/renderer/src/pages/game-details/modals/index.ts @@ -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"; diff --git a/src/renderer/src/pages/game-details/review-prompt-banner.scss b/src/renderer/src/pages/game-details/review-prompt-banner.scss new file mode 100644 index 00000000..f9358e52 --- /dev/null +++ b/src/renderer/src/pages/game-details/review-prompt-banner.scss @@ -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; + } +} diff --git a/src/renderer/src/pages/game-details/review-prompt-banner.tsx b/src/renderer/src/pages/game-details/review-prompt-banner.tsx new file mode 100644 index 00000000..053c97e8 --- /dev/null +++ b/src/renderer/src/pages/game-details/review-prompt-banner.tsx @@ -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) { + const { t } = useTranslation("game_details"); + + return ( +
    +
    +
    + + {t("you_seemed_to_enjoy_this_game")} + + + {t("would_you_recommend_this_game")} + +
    +
    + + +
    +
    +
    + ); +} diff --git a/src/renderer/src/pages/game-details/review-sort-options.scss b/src/renderer/src/pages/game-details/review-sort-options.scss new file mode 100644 index 00000000..fba6c50f --- /dev/null +++ b/src/renderer/src/pages/game-details/review-sort-options.scss @@ -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; + } + } +} diff --git a/src/renderer/src/pages/game-details/review-sort-options.tsx b/src/renderer/src/pages/game-details/review-sort-options.tsx new file mode 100644 index 00000000..0944e58e --- /dev/null +++ b/src/renderer/src/pages/game-details/review-sort-options.tsx @@ -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) { + 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 ( +
    +
    + + | + + | + +
    +
    + ); +} diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.scss b/src/renderer/src/pages/game-details/sidebar/sidebar.scss index 06519f6c..1330d278 100755 --- a/src/renderer/src/pages/game-details/sidebar/sidebar.scss +++ b/src/renderer/src/pages/game-details/sidebar/sidebar.scss @@ -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 { diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx index 0a24c418..df1429ec 100755 --- a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx +++ b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx @@ -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() {

    {numberFormatter.format(stats?.playerCount)}

    + +
    +

    + + {t("rating_count")} +

    + +
    )} diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index 56f7d20b..d2f1f074 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -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 && (
    + diff --git a/src/renderer/src/pages/profile/profile-content/user-karma-box.scss b/src/renderer/src/pages/profile/profile-content/user-karma-box.scss new file mode 100644 index 00000000..63015b4d --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/user-karma-box.scss @@ -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; + } +} diff --git a/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx b/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx new file mode 100644 index 00000000..d2232276 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx @@ -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 ( +
    +
    +

    {t("karma")}

    +
    + +
    +
    +
    +

    + {numberFormatter.format(karma)}{" "} + {t("karma_count")} +

    +
    +
    + + {t("karma_description")} + +
    +
    +
    +
    + ); +} diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx index a3d24958..72b48a8c 100644 --- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx +++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx @@ -234,7 +234,7 @@ export function UserLibraryGameCard({
    {game.title} diff --git a/src/renderer/src/pages/settings/settings-debrid.scss b/src/renderer/src/pages/settings/settings-debrid.scss new file mode 100644 index 00000000..749ddbbc --- /dev/null +++ b/src/renderer/src/pages/settings/settings-debrid.scss @@ -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; + } +} diff --git a/src/renderer/src/pages/settings/settings-debrid.tsx b/src/renderer/src/pages/settings/settings-debrid.tsx new file mode 100644 index 00000000..4bb7d276 --- /dev/null +++ b/src/renderer/src/pages/settings/settings-debrid.tsx @@ -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(() => { + return { + torbox: !userPreferences?.torBoxApiToken, + realDebrid: !userPreferences?.realDebridApiToken, + allDebrid: !userPreferences?.allDebridApiKey, + }; + }, [userPreferences]); + + const [collapseState, setCollapseState] = + useState(initialCollapseState); + + const toggleSection = useCallback((section: keyof CollapseState) => { + setCollapseState((prevState) => ({ + ...prevState, + [section]: !prevState[section], + })); + }, []); + + return ( +
    +

    {t("debrid_description")}

    + +
    +
    + +

    Real-Debrid

    + {userPreferences?.realDebridApiToken && ( + + )} +
    + + + {!collapseState.realDebrid && ( + + + + )} + +
    + + {isTorBoxEnabled && ( +
    +
    + +

    TorBox

    + {userPreferences?.torBoxApiToken && ( + + )} +
    + + + {!collapseState.torbox && ( + + + + )} + +
    + )} + +
    +
    + +

    All-Debrid

    + BETA + {userPreferences?.allDebridApiKey && ( + + )} +
    + + + {!collapseState.allDebrid && ( + + + + )} + +
    +
    + ); +} diff --git a/src/renderer/src/pages/settings/settings.tsx b/src/renderer/src/pages/settings/settings.tsx index d609d218..eb19af31 100644 --- a/src/renderer/src/pages/settings/settings.tsx +++ b/src/renderer/src/pages/settings/settings.tsx @@ -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 ( @@ -76,15 +61,7 @@ export default function Settings() { } if (currentCategoryIndex === 4) { - return ; - } - - if (currentCategoryIndex === 5) { - return ; - } - - if (currentCategoryIndex === 6) { - return ; + return ; } return ; diff --git a/src/shared/html-sanitizer.ts b/src/shared/html-sanitizer.ts new file mode 100644 index 00000000..8f3ae932 --- /dev/null +++ b/src/shared/html-sanitizer.ts @@ -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; +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 000ffd22..9a4b4516 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -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() { diff --git a/src/types/index.ts b/src/types/index.ts index 593c45be..6a864f3a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 { diff --git a/yarn.lock b/yarn.lock index 2c321857..71551858 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"