mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
Merge branch 'main' into feat/source-filter
This commit is contained in:
committed by
GitHub
commit
0e999496e3
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
os: [windows-2022, ubuntu-latest]
|
||||
fail-fast: false
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }}
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
if: matrix.os == 'windows-2022'
|
||||
run: yarn build:win
|
||||
env:
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
os: [windows-2022, ubuntu-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }}
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
if: matrix.os == 'windows-2022'
|
||||
run: yarn build:win
|
||||
env:
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "تم تسجيل الدخول بنجاح"
|
||||
},
|
||||
"home": {
|
||||
"featured": "مميز",
|
||||
"surprise_me": "مفاجئني",
|
||||
"no_results": "لم يتم العثور على نتائج",
|
||||
"start_typing": "ابدأ بالكتابة للبحث...",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"language_name": "беларуская мова",
|
||||
"home": {
|
||||
"featured": "Рэкамэндаванае",
|
||||
"surprise_me": "Здзіві мяне",
|
||||
"no_results": "Няма вынікаў"
|
||||
},
|
||||
@@ -17,7 +16,6 @@
|
||||
"home": "Галоўная",
|
||||
"favorites": "Улюбленыя"
|
||||
},
|
||||
|
||||
"header": {
|
||||
"search": "Пошук",
|
||||
"home": "Галоўная",
|
||||
@@ -31,10 +29,7 @@
|
||||
"downloading_metadata": "Сцягванне мэтаданых {{title}}…",
|
||||
"downloading": "Сцягванне {{title}}… ({{percentage}} скончана) - Канчатак {{eta}} - {{speed}}"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Наступная старонка",
|
||||
"previous_page": "Папярэдняя старонка"
|
||||
},
|
||||
"catalogue": {},
|
||||
"game_details": {
|
||||
"open_download_options": "Адкрыць варыянты сцягвання",
|
||||
"download_options_zero": "Няма варыянтаў сцягвання",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "Успешно влизане"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Препоръчани",
|
||||
"surprise_me": "Изненадай ме",
|
||||
"no_results": "Няма намерени резултати",
|
||||
"start_typing": "Започнете да пишете за търсене...",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "Has entrat correctament"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Destacats",
|
||||
"surprise_me": "Sorprèn-me",
|
||||
"no_results": "No s'ha trobat res"
|
||||
},
|
||||
@@ -25,7 +24,6 @@
|
||||
},
|
||||
"header": {
|
||||
"search": "Cerca jocs",
|
||||
|
||||
"home": "Inici",
|
||||
"catalogue": "Catàleg",
|
||||
"downloads": "Baixades",
|
||||
@@ -41,10 +39,7 @@
|
||||
"calculating_eta": "Descarregant {{title}}… ({{percentage}} completat) - Calculant el temps restant…",
|
||||
"checking_files": "Comprovant els fitxers de {{title}}… ({{percentage}} completat)"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Pàgina següent",
|
||||
"previous_page": "Pàgina anterior"
|
||||
},
|
||||
"catalogue": {},
|
||||
"game_details": {
|
||||
"open_download_options": "Obre les opcions de baixada",
|
||||
"download_options_zero": "No hi ha opcions de baixada",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "Úspěšně přihlášen"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Doporučené",
|
||||
"surprise_me": "Překvap mě",
|
||||
"no_results": "Výsledek nenalezen",
|
||||
"start_typing": "Začni psát pro vyhledávání...",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "Loggede ind successfuldt"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Anbefalet",
|
||||
"surprise_me": "Overrask mig",
|
||||
"no_results": "Ingen resultater fundet",
|
||||
"start_typing": "Begynd at skrive for at søge...",
|
||||
@@ -29,7 +28,6 @@
|
||||
},
|
||||
"header": {
|
||||
"search": "Søg efter spil",
|
||||
|
||||
"home": "Hjem",
|
||||
"catalogue": "Katalog",
|
||||
"downloads": "Downloads",
|
||||
@@ -45,10 +43,7 @@
|
||||
"calculating_eta": "Downloader {{title}}… ({{percentage}} færdig) - Udregner resterende tid…",
|
||||
"checking_files": "Checker {{title}} filer… ({{percentage}} færdig)"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Næste side",
|
||||
"previous_page": "Forrige side"
|
||||
},
|
||||
"catalogue": {},
|
||||
"game_details": {
|
||||
"open_download_options": "Åben download muligheder",
|
||||
"download_options_zero": "Ingen download mulighed",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "Erfolgreich angemeldet"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Empfohlen",
|
||||
"surprise_me": "Überrasche mich",
|
||||
"no_results": "Keine Ergebnisse gefunden",
|
||||
"start_typing": "Tippe, um zu suchen...",
|
||||
@@ -59,9 +58,7 @@
|
||||
"download_sources": "Download-Quellen",
|
||||
"result_count": "{{resultCount}} Ergebnisse",
|
||||
"filter_count": "{{filterCount}} verfügbar",
|
||||
"clear_filters": "{{filterCount}} ausgewählte löschen",
|
||||
"next_page": "Nächste Seite",
|
||||
"previous_page": "Vorherige Seite"
|
||||
"clear_filters": "{{filterCount}} ausgewählte löschen"
|
||||
},
|
||||
"game_details": {
|
||||
"open_download_options": "Download-Optionen öffnen",
|
||||
|
||||
@@ -508,6 +508,8 @@
|
||||
"user_profile": {
|
||||
"amount_hours": "{{amount}} hours",
|
||||
"amount_minutes": "{{amount}} minutes",
|
||||
"amount_hours_short": "{{amount}}h",
|
||||
"amount_minutes_short": "{{amount}}m",
|
||||
"last_time_played": "Last played {{period}}",
|
||||
"activity": "Recent Activity",
|
||||
"library": "Library",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "Edukalt sisse logitud"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Esile toodud",
|
||||
"surprise_me": "Üllata mind",
|
||||
"no_results": "Tulemusi ei leitud",
|
||||
"start_typing": "Alusta otsimiseks kirjutamist...",
|
||||
@@ -45,10 +44,7 @@
|
||||
"calculating_eta": "{{title}} allalaadimine… ({{percentage}} valmis) - Järelejäänud aja arvutamine…",
|
||||
"checking_files": "{{title}} failide kontrollimine… ({{percentage}} valmis)"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Järgmine leht",
|
||||
"previous_page": "Eelmine leht"
|
||||
},
|
||||
"catalogue": {},
|
||||
"game_details": {
|
||||
"open_download_options": "Ava allalaadimise valikud",
|
||||
"download_options_zero": "Allalaadimise valikuid pole",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"language_name": "فارسی",
|
||||
"home": {
|
||||
"featured": "پیشنهادی",
|
||||
"surprise_me": "سوپرایزم کن",
|
||||
"no_results": "اتمامای پیدا نشد"
|
||||
},
|
||||
@@ -17,7 +16,6 @@
|
||||
"home": "خانه",
|
||||
"favorites": "علاقهمندیها"
|
||||
},
|
||||
|
||||
"header": {
|
||||
"search": "جستجوی بازیها",
|
||||
"home": "خانه",
|
||||
@@ -31,10 +29,7 @@
|
||||
"downloading_metadata": "درحال دانلود متادیتاهای {{title}}…",
|
||||
"downloading": "در حال دانلود {{title}}… ({{percentage}} تکمیل شده) - اتمام {{eta}} - {{speed}}"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "صفحهی بعدی",
|
||||
"previous_page": "صفحهی قبلی"
|
||||
},
|
||||
"catalogue": {},
|
||||
"game_details": {
|
||||
"open_download_options": "بازکردن آپشنهای دانلود",
|
||||
"download_options_zero": "هیچ آپشن دانلودی وجود ندارد",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "Connecté avec succès"
|
||||
},
|
||||
"home": {
|
||||
"featured": "En vedette",
|
||||
"surprise_me": "Surprenez-moi",
|
||||
"no_results": "Aucun résultat trouvé",
|
||||
"start_typing": "Commencez à taper pour rechercher...",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"language_name": "Magyar",
|
||||
"home": {
|
||||
"featured": "Featured",
|
||||
"surprise_me": "Lepj meg",
|
||||
"no_results": "Nem található"
|
||||
},
|
||||
@@ -19,7 +18,6 @@
|
||||
},
|
||||
"header": {
|
||||
"search": "Keresés",
|
||||
|
||||
"home": "Főoldal",
|
||||
"catalogue": "Katalógus",
|
||||
"downloads": "Letöltések",
|
||||
@@ -31,10 +29,7 @@
|
||||
"downloading_metadata": "{{title}} metaadatainak letöltése…",
|
||||
"downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Következő olda",
|
||||
"previous_page": "Előző olda"
|
||||
},
|
||||
"catalogue": {},
|
||||
"game_details": {
|
||||
"open_download_options": "Letöltési lehetőségek",
|
||||
"download_options_zero": "Nincs letöltési lehetőség",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "Berhasil masuk"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Unggulan",
|
||||
"surprise_me": "Kejutkan saya",
|
||||
"no_results": "Tidak ada hasil ditemukan"
|
||||
},
|
||||
@@ -25,7 +24,6 @@
|
||||
},
|
||||
"header": {
|
||||
"search": "Cari game",
|
||||
|
||||
"home": "Beranda",
|
||||
"catalogue": "Katalog",
|
||||
"downloads": "Unduhan",
|
||||
@@ -41,10 +39,7 @@
|
||||
"calculating_eta": "Mengunduh {{title}}… ({{percentage}} selesai) - Menghitung waktu yang tersisa…",
|
||||
"checking_files": "Memeriksa file {{title}}… ({{percentage}} selesai)"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Halaman Berikutnya",
|
||||
"previous_page": "Halaman Sebelumnya"
|
||||
},
|
||||
"catalogue": {},
|
||||
"game_details": {
|
||||
"open_download_options": "Buka opsi unduhan",
|
||||
"download_options_zero": "Tidak ada opsi unduhan",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"language_name": "Italiano",
|
||||
"home": {
|
||||
"featured": "In primo piano",
|
||||
"surprise_me": "Sorprendimi",
|
||||
"no_results": "Nessun risultato trovato"
|
||||
},
|
||||
@@ -20,7 +19,6 @@
|
||||
},
|
||||
"header": {
|
||||
"search": "Cerca",
|
||||
|
||||
"home": "Home",
|
||||
"catalogue": "Catalogo",
|
||||
"downloads": "Download",
|
||||
@@ -32,10 +30,7 @@
|
||||
"downloading_metadata": "Scaricamento metadati di {{title}}…",
|
||||
"downloading": "Download di {{title}}… ({{percentage}} completato) - Conclusione {{eta}} - {{speed}}"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Pagina successiva",
|
||||
"previous_page": "Pagina precedente"
|
||||
},
|
||||
"catalogue": {},
|
||||
"game_details": {
|
||||
"open_download_options": "Apri opzioni di download",
|
||||
"download_options_zero": "Nessuna opzione di download",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "Сәтті кіру"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Ұсынылған",
|
||||
"surprise_me": "Таңқалдыр",
|
||||
"no_results": "Ештеңе табылмады"
|
||||
},
|
||||
@@ -23,7 +22,6 @@
|
||||
"sign_in": "Кіру",
|
||||
"favorites": "Таңдаулылар"
|
||||
},
|
||||
|
||||
"header": {
|
||||
"search": "Іздеу",
|
||||
"home": "Басты бет",
|
||||
@@ -40,10 +38,7 @@
|
||||
"downloading": "Жүктеу {{title}}… ({{percentage}} аяқталды) - Аяқтау {{eta}} - {{speed}}",
|
||||
"calculating_eta": "Жүктеу {{title}}… ({{percentage}} аяқталды) - Қалған уақытты есептеу…"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Келесі бет",
|
||||
"previous_page": "Алдыңғы бет"
|
||||
},
|
||||
"catalogue": {},
|
||||
"game_details": {
|
||||
"open_download_options": "Жүктеу нұсқаларын ашу",
|
||||
"download_options_zero": "Жүктеу нұсқалары жоқ",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"language_name": "한국어",
|
||||
"home": {
|
||||
"featured": "추천",
|
||||
"surprise_me": "무작위 추천",
|
||||
"no_results": "결과 없음"
|
||||
},
|
||||
@@ -17,7 +16,6 @@
|
||||
"home": "홈",
|
||||
"favorites": "즐겨찾기"
|
||||
},
|
||||
|
||||
"header": {
|
||||
"search": "게임 검색하기",
|
||||
"home": "홈",
|
||||
@@ -31,10 +29,7 @@
|
||||
"downloading_metadata": "{{title}}의 메타데이터를 다운로드 중…",
|
||||
"downloading": "{{title}}의 파일들을 다운로드 중… ({{percentage}} 완료) - 완료까지 {{eta}} - {{speed}}"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "다음 페이지",
|
||||
"previous_page": "이전 페이지"
|
||||
},
|
||||
"catalogue": {},
|
||||
"game_details": {
|
||||
"open_download_options": "다운로드 선택지 열기",
|
||||
"download_options_zero": "다운로드 선택지 없음",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "Logget inn vellykket"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Anbefalinger",
|
||||
"surprise_me": "Overrask meg",
|
||||
"no_results": "Ingen resultater fundet",
|
||||
"start_typing": "Begynn å skrive for å søke...",
|
||||
@@ -29,7 +28,6 @@
|
||||
},
|
||||
"header": {
|
||||
"search": "Søk efter spill",
|
||||
|
||||
"home": "Hjem",
|
||||
"catalogue": "Katalog",
|
||||
"downloads": "Nedlastinger",
|
||||
@@ -45,10 +43,7 @@
|
||||
"calculating_eta": "Laster ned {{title}}… ({{percentage}} ferdig) - Regner ut resterende tid…",
|
||||
"checking_files": "Sjekker {{title}} filer… ({{percentage}} ferdig)"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Neste side",
|
||||
"previous_page": "Forrige side"
|
||||
},
|
||||
"catalogue": {},
|
||||
"game_details": {
|
||||
"open_download_options": "Åpne nedlastingsmuligheter",
|
||||
"download_options_zero": "Ingen nedlastingsmulighet",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"language_name": "Nederlands",
|
||||
"home": {
|
||||
"featured": "Uitgelicht",
|
||||
"surprise_me": "Verrasing",
|
||||
"no_results": "Geen resultaten gevonden"
|
||||
},
|
||||
@@ -19,7 +18,6 @@
|
||||
},
|
||||
"header": {
|
||||
"search": "Zoek spellen",
|
||||
|
||||
"home": "Home",
|
||||
"catalogue": "Bibliotheek",
|
||||
"downloads": "Downloads",
|
||||
@@ -31,10 +29,7 @@
|
||||
"downloading_metadata": "Downloading {{title}} metadata…",
|
||||
"downloading": "Downloading {{title}}… ({{percentage}} complete) - Conclusion {{eta}} - {{speed}}"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Volgende Pagina",
|
||||
"previous_page": "Vorige Pagina"
|
||||
},
|
||||
"catalogue": {},
|
||||
"game_details": {
|
||||
"open_download_options": "Open download Instellingen",
|
||||
"download_options_zero": "Geen download Instellingen",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"language_name": "Polski",
|
||||
"home": {
|
||||
"featured": "Wyróżnione",
|
||||
"surprise_me": "Zaskocz mnie",
|
||||
"no_results": "Nie znaleziono wyników"
|
||||
},
|
||||
@@ -20,7 +19,6 @@
|
||||
},
|
||||
"header": {
|
||||
"search": "Szukaj",
|
||||
|
||||
"home": "Główna",
|
||||
"catalogue": "Katalog",
|
||||
"downloads": "Pobrane",
|
||||
@@ -32,10 +30,7 @@
|
||||
"downloading_metadata": "Pobieranie {{title}} metadata…",
|
||||
"downloading": "Pobieranie {{title}}… (ukończone w {{percentage}}) - Podsumowanie {{eta}} - {{speed}}"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Następna strona",
|
||||
"previous_page": "Poprzednia strona"
|
||||
},
|
||||
"catalogue": {},
|
||||
"game_details": {
|
||||
"open_download_options": "Otwórz opcje pobierania",
|
||||
"download_options_zero": "Brak opcji pobierania",
|
||||
|
||||
@@ -26,7 +26,22 @@
|
||||
"sign_in": "Login",
|
||||
"friends": "Amigos",
|
||||
"need_help": "Precisa de ajuda?",
|
||||
"favorites": "Favoritos"
|
||||
"favorites": "Favoritos",
|
||||
"add_custom_game_tooltip": "Adicionar jogo personalizado",
|
||||
"custom_game_modal": "Adicionar jogo personalizado",
|
||||
"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",
|
||||
"edit_game_modal_enter_title": "Insira o título",
|
||||
"edit_game_modal_logo": "Logo",
|
||||
"edit_game_modal": "Personalizar detalhes"
|
||||
},
|
||||
"header": {
|
||||
"search": "Buscar jogos",
|
||||
@@ -222,6 +237,17 @@
|
||||
"audio": "Áudio",
|
||||
"filter_by_source": "Filtrar por fonte",
|
||||
"no_repacks_found": "Nenhuma fonte encontrada para este jogo"
|
||||
"edit_game_modal_button": "Alterar detalhes do jogo",
|
||||
"game_added_to_pinned": "Jogo adicionado aos fixados",
|
||||
"game_removed_from_pinned": "Jogo removido dos fixados",
|
||||
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente",
|
||||
"manual_playtime_warning": "As suas horas de jogo serão marcadas como atualizadas manualmente. Esta ação não pode ser desfeita.",
|
||||
"missing_wine_prefix": "Um prefixo Wine é necessário para criar um backup no Linux",
|
||||
"update_game_playtime": "Modificar tempo de jogo",
|
||||
"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"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Ativação",
|
||||
@@ -396,7 +422,8 @@
|
||||
"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"
|
||||
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo",
|
||||
"editor_tab_code": "Código"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Download concluído",
|
||||
@@ -525,7 +552,15 @@
|
||||
"show_achievements_on_profile": "Exiba suas conquistas no perfil",
|
||||
"show_points_on_profile": "Exiba seus pontos ganhos no perfil",
|
||||
"error_adding_friend": "Não foi possível enviar o pedido de amizade. Verifique o código de amizade inserido",
|
||||
"friend_code_length_error": "Código de amigo deve ter 8 caracteres"
|
||||
"friend_code_length_error": "Código de amigo deve ter 8 caracteres",
|
||||
"top_percentile": "Top {{percentile}}%",
|
||||
"playtime": "Tempo de jogo",
|
||||
"played_recently": "Jogado recentemente",
|
||||
"pinned": "Fixado",
|
||||
"amount_minutes_short": "{{amount}}m",
|
||||
"amount_hours_short": "{{amount}}h",
|
||||
"game_added_to_pinned": "Jogo adicionado aos fixados",
|
||||
"achievements_earned": "Conquistas recebidas"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Conquista desbloqueada",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "Sessão iniciada com sucesso"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Destaques",
|
||||
"hot": "Populares",
|
||||
"weekly": "📅 Mais descarregados esta semana",
|
||||
"achievements": "🏆 Para completar",
|
||||
@@ -26,7 +25,8 @@
|
||||
"game_has_no_executable": "O jogo não tem um executável selecionado",
|
||||
"sign_in": "Iniciar sessão",
|
||||
"friends": "Amigos",
|
||||
"favorites": "Favoritos"
|
||||
"favorites": "Favoritos",
|
||||
"edit_game_modal_cancel": "Cancelar"
|
||||
},
|
||||
"header": {
|
||||
"search": "Procurar jogos",
|
||||
@@ -247,9 +247,6 @@
|
||||
"download_count_zero": "Sem downloads na lista",
|
||||
"download_count_one": "{{countFormatted}} download na lista",
|
||||
"download_count_other": "{{countFormatted}} downloads na lista",
|
||||
"download_options_zero": "Sem downloads disponíveis",
|
||||
"download_options_one": "{{countFormatted}} download disponível",
|
||||
"download_options_other": "{{countFormatted}} downloads disponíveis",
|
||||
"download_source_url": "URL da fonte",
|
||||
"add_download_source_description": "Insere o URL que contém o ficheiro .json",
|
||||
"download_source_up_to_date": "Sincronizada",
|
||||
@@ -359,8 +356,6 @@
|
||||
"instructions": "Verifica a forma correta de instalar algum deles na tua distribuição Linux, para garantir a execução normal do jogo"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Página seguinte",
|
||||
"previous_page": "Página anterior",
|
||||
"search": "Filtrar…",
|
||||
"developers": "Desenvolvedores",
|
||||
"genres": "Géneros",
|
||||
@@ -427,7 +422,6 @@
|
||||
"friend_code_copied": "Código de amigo copiado",
|
||||
"undo_friendship_modal_text": "Isto vai remover a tua amizade com {{displayName}}",
|
||||
"privacy_hint": "Para controlar quem pode ver o teu perfil, acede às <0>Definições</0>",
|
||||
"profile_locked": "Este perfil é privado",
|
||||
"image_process_failure": "Falha ao processar a imagem",
|
||||
"required_field": "Este campo é obrigatório",
|
||||
"displayname_min_length": "O nome de apresentação deve ter pelo menos 3 caracteres",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"language_name": "Română",
|
||||
"home": {
|
||||
"featured": "Recomandate",
|
||||
"surprise_me": "Surprinde-mă",
|
||||
"no_results": "Niciun rezultat găsit"
|
||||
},
|
||||
@@ -19,7 +18,6 @@
|
||||
},
|
||||
"header": {
|
||||
"search": "Caută jocuri",
|
||||
|
||||
"home": "Acasă",
|
||||
"catalogue": "Catalog",
|
||||
"downloads": "Descărcări",
|
||||
@@ -32,10 +30,7 @@
|
||||
"downloading": "Se descarcă {{title}}... ({{percentage}} complet) - Concluzie {{eta}} - {{speed}}",
|
||||
"calculating_eta": "Se descarcă {{title}}... ({{percentage}} complet) - Calculare timp rămas..."
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Pagina următoare",
|
||||
"previous_page": "Pagina anterioară"
|
||||
},
|
||||
"catalogue": {},
|
||||
"game_details": {
|
||||
"open_download_options": "Deschide opțiunile de descărcare",
|
||||
"download_options_zero": "Nicio opțiune de descărcare",
|
||||
|
||||
@@ -4,14 +4,12 @@
|
||||
"successfully_signed_in": "Успешный вход"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Рекомендации",
|
||||
"surprise_me": "Удиви меня",
|
||||
"no_results": "Ничего не найдено",
|
||||
"hot": "Сейчас популярно",
|
||||
"start_typing": "Начинаю вводить текст...",
|
||||
"weekly": "📅 Лучшие игры недели",
|
||||
"achievements": "🏆 Игры с достижениями",
|
||||
"already_in_library": "Уже в библиотеке"
|
||||
"achievements": "🏆 Игры с достижениями"
|
||||
},
|
||||
"sidebar": {
|
||||
"catalogue": "Каталог",
|
||||
@@ -486,6 +484,8 @@
|
||||
"user_profile": {
|
||||
"amount_hours": "{{amount}} часов",
|
||||
"amount_minutes": "{{amount}} минут",
|
||||
"amount_hours_short": "{{amount}}ч",
|
||||
"amount_minutes_short": "{{amount}}м",
|
||||
"last_time_played": "Последняя игра {{period}}",
|
||||
"activity": "Недавняя активность",
|
||||
"library": "Библиотека",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "Inloggningen lyckades"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Utvalt",
|
||||
"surprise_me": "Överraska mig",
|
||||
"no_results": "Inga resultat hittades",
|
||||
"start_typing": "Börja skriva för att söka...",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "Başarıyla giriş yapıldı"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Öne Çıkanlar",
|
||||
"surprise_me": "Beni Şaşırt",
|
||||
"no_results": "Sonuç bulunamadı",
|
||||
"start_typing": "Aramak için yazmaya başlayın...",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "Успішний вхід в систему"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Рекомендоване",
|
||||
"surprise_me": "Здивуй мене",
|
||||
"no_results": "Результатів не знайдено",
|
||||
"start_typing": "Почніть набирати текст для пошуку...",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "Kirish muvaffaqiyatli amalga oshirildi"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Tavsiya etilgan",
|
||||
"surprise_me": "Hayratda qoldir",
|
||||
"no_results": "Natijalar topilmadi",
|
||||
"hot": "Eng mashhur",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "已成功登录"
|
||||
},
|
||||
"home": {
|
||||
"featured": "特色推荐",
|
||||
"surprise_me": "向我推荐",
|
||||
"no_results": "没有找到结果",
|
||||
"start_typing": "键入以开始搜素...",
|
||||
@@ -51,8 +50,6 @@
|
||||
"installing_common_redist": "{{log}}…"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "下一页",
|
||||
"previous_page": "上一页",
|
||||
"clear_filters": "清除已选的 {{filterCount}} 项",
|
||||
"developers": "开发商",
|
||||
"download_sources": "下载源",
|
||||
|
||||
@@ -1,7 +1,70 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { gamesSublevel, levelKeys } from "@main/level";
|
||||
import type { GameShop } from "@types";
|
||||
import { HydraApi, logger } from "@main/services";
|
||||
import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level";
|
||||
import type { GameShop, Game } from "@types";
|
||||
import fs from "node:fs";
|
||||
|
||||
const collectAssetPathsToDelete = (game: Game): string[] => {
|
||||
const assetPathsToDelete: string[] = [];
|
||||
|
||||
const assetUrls =
|
||||
game.shop === "custom"
|
||||
? [game.iconUrl, game.logoImageUrl, game.libraryHeroImageUrl]
|
||||
: [game.customIconUrl, game.customLogoImageUrl, game.customHeroImageUrl];
|
||||
|
||||
for (const url of assetUrls) {
|
||||
if (url?.startsWith("local:")) {
|
||||
assetPathsToDelete.push(url.replace("local:", ""));
|
||||
}
|
||||
}
|
||||
|
||||
return assetPathsToDelete;
|
||||
};
|
||||
|
||||
const updateGameAsDeleted = async (
|
||||
game: Game,
|
||||
gameKey: string
|
||||
): Promise<void> => {
|
||||
const updatedGame = {
|
||||
...game,
|
||||
isDeleted: true,
|
||||
executablePath: null,
|
||||
...(game.shop !== "custom" && {
|
||||
customIconUrl: null,
|
||||
customLogoImageUrl: null,
|
||||
customHeroImageUrl: null,
|
||||
}),
|
||||
};
|
||||
|
||||
await gamesSublevel.put(gameKey, updatedGame);
|
||||
};
|
||||
|
||||
const resetShopAssets = async (gameKey: string): Promise<void> => {
|
||||
const existingAssets = await gamesShopAssetsSublevel.get(gameKey);
|
||||
if (existingAssets) {
|
||||
const resetAssets = {
|
||||
...existingAssets,
|
||||
title: existingAssets.title,
|
||||
};
|
||||
await gamesShopAssetsSublevel.put(gameKey, resetAssets);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAssetFiles = async (
|
||||
assetPathsToDelete: string[]
|
||||
): Promise<void> => {
|
||||
if (assetPathsToDelete.length === 0) return;
|
||||
|
||||
for (const assetPath of assetPathsToDelete) {
|
||||
try {
|
||||
if (fs.existsSync(assetPath)) {
|
||||
await fs.promises.unlink(assetPath);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete asset ${assetPath}:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeGameFromLibrary = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@@ -11,17 +74,21 @@ const removeGameFromLibrary = async (
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
const game = await gamesSublevel.get(gameKey);
|
||||
|
||||
if (game) {
|
||||
await gamesSublevel.put(gameKey, {
|
||||
...game,
|
||||
isDeleted: true,
|
||||
executablePath: null,
|
||||
});
|
||||
if (!game) return;
|
||||
|
||||
if (game?.remoteId) {
|
||||
HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {});
|
||||
}
|
||||
const assetPathsToDelete = collectAssetPathsToDelete(game);
|
||||
|
||||
await updateGameAsDeleted(game, gameKey);
|
||||
|
||||
if (game.shop !== "custom") {
|
||||
await resetShopAssets(gameKey);
|
||||
}
|
||||
|
||||
if (game?.remoteId) {
|
||||
HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {});
|
||||
}
|
||||
|
||||
await deleteAssetFiles(assetPathsToDelete);
|
||||
};
|
||||
|
||||
registerEvent("removeGameFromLibrary", removeGameFromLibrary);
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level";
|
||||
import type { GameShop } from "@types";
|
||||
import fs from "node:fs";
|
||||
import { logger } from "@main/services";
|
||||
|
||||
interface UpdateCustomGameParams {
|
||||
shop: GameShop;
|
||||
objectId: string;
|
||||
title: string;
|
||||
iconUrl?: string;
|
||||
logoImageUrl?: string;
|
||||
libraryHeroImageUrl?: string;
|
||||
originalIconPath?: string;
|
||||
originalLogoPath?: string;
|
||||
originalHeroPath?: string;
|
||||
}
|
||||
|
||||
const updateCustomGame = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
iconUrl?: string,
|
||||
logoImageUrl?: string,
|
||||
libraryHeroImageUrl?: string
|
||||
params: UpdateCustomGameParams
|
||||
) => {
|
||||
const {
|
||||
shop,
|
||||
objectId,
|
||||
title,
|
||||
iconUrl,
|
||||
logoImageUrl,
|
||||
libraryHeroImageUrl,
|
||||
originalIconPath,
|
||||
originalLogoPath,
|
||||
originalHeroPath,
|
||||
} = params;
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
|
||||
const existingGame = await gamesSublevel.get(gameKey);
|
||||
@@ -18,12 +38,29 @@ const updateCustomGame = async (
|
||||
throw new Error("Game not found");
|
||||
}
|
||||
|
||||
const oldAssetPaths: string[] = [];
|
||||
|
||||
const assetPairs = [
|
||||
{ existing: existingGame.iconUrl, new: iconUrl },
|
||||
{ existing: existingGame.logoImageUrl, new: logoImageUrl },
|
||||
{ existing: existingGame.libraryHeroImageUrl, new: libraryHeroImageUrl },
|
||||
];
|
||||
|
||||
for (const { existing, new: newUrl } of assetPairs) {
|
||||
if (existing?.startsWith("local:") && (!newUrl || existing !== newUrl)) {
|
||||
oldAssetPaths.push(existing.replace("local:", ""));
|
||||
}
|
||||
}
|
||||
|
||||
const updatedGame = {
|
||||
...existingGame,
|
||||
title,
|
||||
iconUrl: iconUrl || null,
|
||||
logoImageUrl: logoImageUrl || null,
|
||||
libraryHeroImageUrl: libraryHeroImageUrl || null,
|
||||
originalIconPath: originalIconPath || existingGame.originalIconPath || null,
|
||||
originalLogoPath: originalLogoPath || existingGame.originalLogoPath || null,
|
||||
originalHeroPath: originalHeroPath || existingGame.originalHeroPath || null,
|
||||
};
|
||||
|
||||
await gamesSublevel.put(gameKey, updatedGame);
|
||||
@@ -43,6 +80,18 @@ const updateCustomGame = async (
|
||||
await gamesShopAssetsSublevel.put(gameKey, updatedAssets);
|
||||
}
|
||||
|
||||
if (oldAssetPaths.length > 0) {
|
||||
for (const assetPath of oldAssetPaths) {
|
||||
try {
|
||||
if (fs.existsSync(assetPath)) {
|
||||
await fs.promises.unlink(assetPath);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete old asset ${assetPath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedGame;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,131 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level";
|
||||
import type { GameShop } from "@types";
|
||||
import type { GameShop, Game } from "@types";
|
||||
import fs from "node:fs";
|
||||
import { logger } from "@main/services";
|
||||
|
||||
const updateGameCustomAssets = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
const collectOldAssetPaths = (
|
||||
existingGame: Game,
|
||||
customIconUrl?: string | null,
|
||||
customLogoImageUrl?: string | null,
|
||||
customHeroImageUrl?: string | null
|
||||
): string[] => {
|
||||
const oldAssetPaths: string[] = [];
|
||||
|
||||
const assetPairs = [
|
||||
{ existing: existingGame.customIconUrl, new: customIconUrl },
|
||||
{ existing: existingGame.customLogoImageUrl, new: customLogoImageUrl },
|
||||
{ existing: existingGame.customHeroImageUrl, new: customHeroImageUrl },
|
||||
];
|
||||
|
||||
for (const { existing, new: newUrl } of assetPairs) {
|
||||
if (
|
||||
existing &&
|
||||
newUrl !== undefined &&
|
||||
existing !== newUrl &&
|
||||
existing.startsWith("local:")
|
||||
) {
|
||||
oldAssetPaths.push(existing.replace("local:", ""));
|
||||
}
|
||||
}
|
||||
|
||||
return oldAssetPaths;
|
||||
};
|
||||
|
||||
interface UpdateGameDataParams {
|
||||
gameKey: string;
|
||||
existingGame: Game;
|
||||
title: string;
|
||||
customIconUrl?: string | null;
|
||||
customLogoImageUrl?: string | null;
|
||||
customHeroImageUrl?: string | null;
|
||||
customOriginalIconPath?: string | null;
|
||||
customOriginalLogoPath?: string | null;
|
||||
customOriginalHeroPath?: string | null;
|
||||
}
|
||||
|
||||
const updateGameData = async (params: UpdateGameDataParams): Promise<Game> => {
|
||||
const {
|
||||
gameKey,
|
||||
existingGame,
|
||||
title,
|
||||
customIconUrl,
|
||||
customLogoImageUrl,
|
||||
customHeroImageUrl,
|
||||
customOriginalIconPath,
|
||||
customOriginalLogoPath,
|
||||
customOriginalHeroPath,
|
||||
} = params;
|
||||
const updatedGame = {
|
||||
...existingGame,
|
||||
title,
|
||||
...(customIconUrl !== undefined && { customIconUrl }),
|
||||
...(customLogoImageUrl !== undefined && { customLogoImageUrl }),
|
||||
...(customHeroImageUrl !== undefined && { customHeroImageUrl }),
|
||||
...(customOriginalIconPath !== undefined && { customOriginalIconPath }),
|
||||
...(customOriginalLogoPath !== undefined && { customOriginalLogoPath }),
|
||||
...(customOriginalHeroPath !== undefined && { customOriginalHeroPath }),
|
||||
};
|
||||
|
||||
await gamesSublevel.put(gameKey, updatedGame);
|
||||
return updatedGame;
|
||||
};
|
||||
|
||||
const updateShopAssets = async (
|
||||
gameKey: string,
|
||||
title: string
|
||||
): Promise<void> => {
|
||||
const existingAssets = await gamesShopAssetsSublevel.get(gameKey);
|
||||
if (existingAssets) {
|
||||
const updatedAssets = {
|
||||
...existingAssets,
|
||||
title,
|
||||
};
|
||||
await gamesShopAssetsSublevel.put(gameKey, updatedAssets);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteOldAssetFiles = async (oldAssetPaths: string[]): Promise<void> => {
|
||||
if (oldAssetPaths.length === 0) return;
|
||||
|
||||
for (const assetPath of oldAssetPaths) {
|
||||
try {
|
||||
if (fs.existsSync(assetPath)) {
|
||||
await fs.promises.unlink(assetPath);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete old custom asset ${assetPath}:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface UpdateGameCustomAssetsParams {
|
||||
shop: GameShop;
|
||||
objectId: string;
|
||||
title: string;
|
||||
customIconUrl?: string | null;
|
||||
customLogoImageUrl?: string | null;
|
||||
customHeroImageUrl?: string | null;
|
||||
customOriginalIconPath?: string | null;
|
||||
customOriginalLogoPath?: string | null;
|
||||
customOriginalHeroPath?: string | null;
|
||||
}
|
||||
|
||||
const updateGameCustomAssets = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
params: UpdateGameCustomAssetsParams
|
||||
) => {
|
||||
const {
|
||||
shop,
|
||||
objectId,
|
||||
title,
|
||||
customIconUrl,
|
||||
customLogoImageUrl,
|
||||
customHeroImageUrl,
|
||||
customOriginalIconPath,
|
||||
customOriginalLogoPath,
|
||||
customOriginalHeroPath,
|
||||
} = params;
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
|
||||
const existingGame = await gamesSublevel.get(gameKey);
|
||||
@@ -18,26 +133,28 @@ const updateGameCustomAssets = async (
|
||||
throw new Error("Game not found");
|
||||
}
|
||||
|
||||
const updatedGame = {
|
||||
...existingGame,
|
||||
const oldAssetPaths = collectOldAssetPaths(
|
||||
existingGame,
|
||||
customIconUrl,
|
||||
customLogoImageUrl,
|
||||
customHeroImageUrl
|
||||
);
|
||||
|
||||
const updatedGame = await updateGameData({
|
||||
gameKey,
|
||||
existingGame,
|
||||
title,
|
||||
...(customIconUrl !== undefined && { customIconUrl }),
|
||||
...(customLogoImageUrl !== undefined && { customLogoImageUrl }),
|
||||
...(customHeroImageUrl !== undefined && { customHeroImageUrl }),
|
||||
};
|
||||
customIconUrl,
|
||||
customLogoImageUrl,
|
||||
customHeroImageUrl,
|
||||
customOriginalIconPath,
|
||||
customOriginalLogoPath,
|
||||
customOriginalHeroPath,
|
||||
});
|
||||
|
||||
await gamesSublevel.put(gameKey, updatedGame);
|
||||
await updateShopAssets(gameKey, title);
|
||||
|
||||
// Also update the shop assets for non-custom games
|
||||
const existingAssets = await gamesShopAssetsSublevel.get(gameKey);
|
||||
if (existingAssets) {
|
||||
const updatedAssets = {
|
||||
...existingAssets,
|
||||
title, // Update the title in shop assets as well
|
||||
};
|
||||
|
||||
await gamesShopAssetsSublevel.put(gameKey, updatedAssets);
|
||||
}
|
||||
await deleteOldAssetFiles(oldAssetPaths);
|
||||
|
||||
return updatedGame;
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ export const requestSteam250 = async (path: string) => {
|
||||
const { window } = new JSDOM(response.data);
|
||||
const { document } = window;
|
||||
|
||||
return Array.from(document.querySelectorAll(".appline .title a"))
|
||||
return Array.from(document.querySelectorAll("a[data-title]"))
|
||||
.map(($title) => {
|
||||
const steamGameUrl = ($title as HTMLAnchorElement).href;
|
||||
if (!steamGameUrl) return null;
|
||||
|
||||
@@ -152,40 +152,28 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
deleteTempFile: (filePath: string) =>
|
||||
ipcRenderer.invoke("deleteTempFile", filePath),
|
||||
cleanupUnusedAssets: () => ipcRenderer.invoke("cleanupUnusedAssets"),
|
||||
updateCustomGame: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
iconUrl?: string,
|
||||
logoImageUrl?: string,
|
||||
libraryHeroImageUrl?: string
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
"updateCustomGame",
|
||||
shop,
|
||||
objectId,
|
||||
title,
|
||||
iconUrl,
|
||||
logoImageUrl,
|
||||
libraryHeroImageUrl
|
||||
),
|
||||
updateGameCustomAssets: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
customIconUrl?: string | null,
|
||||
customLogoImageUrl?: string | null,
|
||||
customHeroImageUrl?: string | null
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
"updateGameCustomAssets",
|
||||
shop,
|
||||
objectId,
|
||||
title,
|
||||
customIconUrl,
|
||||
customLogoImageUrl,
|
||||
customHeroImageUrl
|
||||
),
|
||||
updateCustomGame: (params: {
|
||||
shop: GameShop;
|
||||
objectId: string;
|
||||
title: string;
|
||||
iconUrl?: string;
|
||||
logoImageUrl?: string;
|
||||
libraryHeroImageUrl?: string;
|
||||
originalIconPath?: string;
|
||||
originalLogoPath?: string;
|
||||
originalHeroPath?: string;
|
||||
}) => ipcRenderer.invoke("updateCustomGame", params),
|
||||
updateGameCustomAssets: (params: {
|
||||
shop: GameShop;
|
||||
objectId: string;
|
||||
title: string;
|
||||
customIconUrl?: string | null;
|
||||
customLogoImageUrl?: string | null;
|
||||
customHeroImageUrl?: string | null;
|
||||
customOriginalIconPath?: string | null;
|
||||
customOriginalLogoPath?: string | null;
|
||||
customOriginalHeroPath?: string | null;
|
||||
}) => ipcRenderer.invoke("updateGameCustomAssets", params),
|
||||
createGameShortcut: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
|
||||
38
src/renderer/src/declaration.d.ts
vendored
38
src/renderer/src/declaration.d.ts
vendored
@@ -119,14 +119,17 @@ declare global {
|
||||
logoImageUrl?: string,
|
||||
libraryHeroImageUrl?: string
|
||||
) => Promise<Game>;
|
||||
updateCustomGame: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
iconUrl?: string,
|
||||
logoImageUrl?: string,
|
||||
libraryHeroImageUrl?: string
|
||||
) => Promise<Game>;
|
||||
updateCustomGame: (params: {
|
||||
shop: GameShop;
|
||||
objectId: string;
|
||||
title: string;
|
||||
iconUrl?: string;
|
||||
logoImageUrl?: string;
|
||||
libraryHeroImageUrl?: string;
|
||||
originalIconPath?: string;
|
||||
originalLogoPath?: string;
|
||||
originalHeroPath?: string;
|
||||
}) => Promise<Game>;
|
||||
copyCustomGameAsset: (
|
||||
sourcePath: string,
|
||||
assetType: "icon" | "logo" | "hero"
|
||||
@@ -135,14 +138,17 @@ declare global {
|
||||
deletedCount: number;
|
||||
errors: string[];
|
||||
}>;
|
||||
updateGameCustomAssets: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
customIconUrl?: string | null,
|
||||
customLogoImageUrl?: string | null,
|
||||
customHeroImageUrl?: string | null
|
||||
) => Promise<Game>;
|
||||
updateGameCustomAssets: (params: {
|
||||
shop: GameShop;
|
||||
objectId: string;
|
||||
title: string;
|
||||
customIconUrl?: string | null;
|
||||
customLogoImageUrl?: string | null;
|
||||
customHeroImageUrl?: string | null;
|
||||
customOriginalIconPath?: string | null;
|
||||
customOriginalLogoPath?: string | null;
|
||||
customOriginalHeroPath?: string | null;
|
||||
}) => Promise<Game>;
|
||||
createGameShortcut: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.description-header {
|
||||
width: calc(100% - calc(globals.$spacing-unit * 2));
|
||||
margin: calc(globals.$spacing-unit * 1) auto;
|
||||
width: 100%;
|
||||
padding: calc(globals.$spacing-unit * 1.5);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -10,8 +9,9 @@
|
||||
background-color: globals.$background-color;
|
||||
height: 72px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: calc(globals.$spacing-unit * 1);
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-height: 80vh;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
|
||||
&__viewport {
|
||||
@@ -16,8 +25,19 @@
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
width: 80%;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 60%;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
width: 50%;
|
||||
max-height: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,10 +72,18 @@
|
||||
overflow-y: hidden;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
width: 20%;
|
||||
}
|
||||
@@ -79,6 +107,19 @@
|
||||
border: solid 1px globals.$border-color;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
aspect-ratio: 16/9;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
|
||||
@@ -43,6 +43,29 @@ export function GameDetailsContent() {
|
||||
const $images = Array.from(document.querySelectorAll("img"));
|
||||
$images.forEach(($image) => {
|
||||
$image.loading = "lazy";
|
||||
// Remove any inline width/height styles that might cause overflow
|
||||
$image.removeAttribute("width");
|
||||
$image.removeAttribute("height");
|
||||
$image.removeAttribute("style");
|
||||
// Set max-width to prevent overflow
|
||||
$image.style.maxWidth = "100%";
|
||||
$image.style.width = "auto";
|
||||
$image.style.height = "auto";
|
||||
$image.style.boxSizing = "border-box";
|
||||
});
|
||||
|
||||
// Handle videos the same way
|
||||
const $videos = Array.from(document.querySelectorAll("video"));
|
||||
$videos.forEach(($video) => {
|
||||
// Remove any inline width/height styles that might cause overflow
|
||||
$video.removeAttribute("width");
|
||||
$video.removeAttribute("height");
|
||||
$video.removeAttribute("style");
|
||||
// Set max-width to prevent overflow
|
||||
$video.style.maxWidth = "100%";
|
||||
$video.style.width = "auto";
|
||||
$video.style.height = "auto";
|
||||
$video.style.boxSizing = "border-box";
|
||||
});
|
||||
|
||||
return document.body.outerHTML;
|
||||
@@ -168,14 +191,16 @@ export function GameDetailsContent() {
|
||||
{renderGameLogo()}
|
||||
|
||||
<div className="game-details__hero-buttons game-details__hero-buttons--right">
|
||||
<button
|
||||
type="button"
|
||||
className="game-details__edit-custom-game-button"
|
||||
onClick={handleEditGameClick}
|
||||
title={t("edit_game_modal_button")}
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
</button>
|
||||
{game && (
|
||||
<button
|
||||
type="button"
|
||||
className="game-details__edit-custom-game-button"
|
||||
onClick={handleEditGameClick}
|
||||
title={t("edit_game_modal_button")}
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{game?.shop !== "custom" && (
|
||||
<button
|
||||
@@ -217,13 +242,15 @@ export function GameDetailsContent() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EditGameModal
|
||||
visible={showEditGameModal}
|
||||
onClose={() => setShowEditGameModal(false)}
|
||||
game={game}
|
||||
shopDetails={shopDetails}
|
||||
onGameUpdated={handleGameUpdated}
|
||||
/>
|
||||
{game && (
|
||||
<EditGameModal
|
||||
visible={showEditGameModal}
|
||||
onClose={() => setShowEditGameModal(false)}
|
||||
game={game}
|
||||
shopDetails={shopDetails}
|
||||
onGameUpdated={handleGameUpdated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -182,44 +182,49 @@ $hero-height: 300px;
|
||||
globals.$background-color 50%,
|
||||
globals.$dark-background-color 100%
|
||||
);
|
||||
padding: calc(globals.$spacing-unit * 1.5);
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
}
|
||||
|
||||
&__description-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
&__description {
|
||||
user-select: text;
|
||||
line-height: 22px;
|
||||
font-size: globals.$body-font-size;
|
||||
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 1.5);
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
overflow-x: auto;
|
||||
min-height: auto;
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
img {
|
||||
@media (min-width: 1536px) {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
img,
|
||||
video {
|
||||
border-radius: 5px;
|
||||
margin-top: globals.$spacing-unit;
|
||||
margin-bottom: calc(globals.$spacing-unit * 3);
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: cover;
|
||||
display: block !important;
|
||||
max-width: 100% !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
object-fit: contain !important;
|
||||
box-sizing: border-box !important;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -247,12 +252,17 @@ $hero-height: 300px;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 60%;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
&__randomizer-button {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ImageIcon, XIcon } from "@primer/octicons-react";
|
||||
|
||||
@@ -29,16 +29,29 @@ export function EditGameModal({
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const [gameName, setGameName] = useState("");
|
||||
const [iconPath, setIconPath] = useState("");
|
||||
const [logoPath, setLogoPath] = useState("");
|
||||
const [heroPath, setHeroPath] = useState("");
|
||||
const [assetPaths, setAssetPaths] = useState({
|
||||
icon: "",
|
||||
logo: "",
|
||||
hero: "",
|
||||
});
|
||||
const [assetDisplayPaths, setAssetDisplayPaths] = useState({
|
||||
icon: "",
|
||||
logo: "",
|
||||
hero: "",
|
||||
});
|
||||
const [originalAssetPaths, setOriginalAssetPaths] = useState({
|
||||
icon: "",
|
||||
logo: "",
|
||||
hero: "",
|
||||
});
|
||||
const [defaultUrls, setDefaultUrls] = useState({
|
||||
icon: null as string | null,
|
||||
logo: null as string | null,
|
||||
hero: null as string | null,
|
||||
});
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [selectedAssetType, setSelectedAssetType] = useState<AssetType>("icon");
|
||||
|
||||
const [defaultIconUrl, setDefaultIconUrl] = useState<string | null>(null);
|
||||
const [defaultLogoUrl, setDefaultLogoUrl] = useState<string | null>(null);
|
||||
const [defaultHeroUrl, setDefaultHeroUrl] = useState<string | null>(null);
|
||||
|
||||
const isCustomGame = (game: LibraryGame | Game): boolean => {
|
||||
return game.shop === "custom";
|
||||
};
|
||||
@@ -48,26 +61,58 @@ export function EditGameModal({
|
||||
};
|
||||
|
||||
const setCustomGameAssets = useCallback((game: LibraryGame | Game) => {
|
||||
setIconPath(extractLocalPath(game.iconUrl));
|
||||
setLogoPath(extractLocalPath(game.logoImageUrl));
|
||||
setHeroPath(extractLocalPath(game.libraryHeroImageUrl));
|
||||
setAssetPaths({
|
||||
icon: extractLocalPath(game.iconUrl),
|
||||
logo: extractLocalPath(game.logoImageUrl),
|
||||
hero: extractLocalPath(game.libraryHeroImageUrl),
|
||||
});
|
||||
setAssetDisplayPaths({
|
||||
icon: extractLocalPath(game.iconUrl),
|
||||
logo: extractLocalPath(game.logoImageUrl),
|
||||
hero: extractLocalPath(game.libraryHeroImageUrl),
|
||||
});
|
||||
setOriginalAssetPaths({
|
||||
icon: (game as any).originalIconPath || extractLocalPath(game.iconUrl),
|
||||
logo:
|
||||
(game as any).originalLogoPath || extractLocalPath(game.logoImageUrl),
|
||||
hero:
|
||||
(game as any).originalHeroPath ||
|
||||
extractLocalPath(game.libraryHeroImageUrl),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setNonCustomGameAssets = useCallback(
|
||||
(game: LibraryGame) => {
|
||||
setIconPath(extractLocalPath(game.customIconUrl));
|
||||
setLogoPath(extractLocalPath(game.customLogoImageUrl));
|
||||
setHeroPath(extractLocalPath(game.customHeroImageUrl));
|
||||
setAssetPaths({
|
||||
icon: extractLocalPath(game.customIconUrl),
|
||||
logo: extractLocalPath(game.customLogoImageUrl),
|
||||
hero: extractLocalPath(game.customHeroImageUrl),
|
||||
});
|
||||
setAssetDisplayPaths({
|
||||
icon: extractLocalPath(game.customIconUrl),
|
||||
logo: extractLocalPath(game.customLogoImageUrl),
|
||||
hero: extractLocalPath(game.customHeroImageUrl),
|
||||
});
|
||||
setOriginalAssetPaths({
|
||||
icon:
|
||||
(game as any).customOriginalIconPath ||
|
||||
extractLocalPath(game.customIconUrl),
|
||||
logo:
|
||||
(game as any).customOriginalLogoPath ||
|
||||
extractLocalPath(game.customLogoImageUrl),
|
||||
hero:
|
||||
(game as any).customOriginalHeroPath ||
|
||||
extractLocalPath(game.customHeroImageUrl),
|
||||
});
|
||||
|
||||
setDefaultIconUrl(shopDetails?.assets?.iconUrl || game.iconUrl || null);
|
||||
setDefaultLogoUrl(
|
||||
shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null
|
||||
);
|
||||
setDefaultHeroUrl(
|
||||
shopDetails?.assets?.libraryHeroImageUrl ||
|
||||
setDefaultUrls({
|
||||
icon: shopDetails?.assets?.iconUrl || game.iconUrl || null,
|
||||
logo: shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null,
|
||||
hero:
|
||||
shopDetails?.assets?.libraryHeroImageUrl ||
|
||||
game.libraryHeroImageUrl ||
|
||||
null
|
||||
);
|
||||
null,
|
||||
});
|
||||
},
|
||||
[shopDetails]
|
||||
);
|
||||
@@ -93,39 +138,24 @@ export function EditGameModal({
|
||||
};
|
||||
|
||||
const getAssetPath = (assetType: AssetType): string => {
|
||||
switch (assetType) {
|
||||
case "icon":
|
||||
return iconPath;
|
||||
case "logo":
|
||||
return logoPath;
|
||||
case "hero":
|
||||
return heroPath;
|
||||
}
|
||||
return assetPaths[assetType];
|
||||
};
|
||||
|
||||
const getAssetDisplayPath = (assetType: AssetType): string => {
|
||||
// Use original path if available, otherwise fall back to display path
|
||||
return originalAssetPaths[assetType] || assetDisplayPaths[assetType];
|
||||
};
|
||||
|
||||
const setAssetPath = (assetType: AssetType, path: string): void => {
|
||||
switch (assetType) {
|
||||
case "icon":
|
||||
setIconPath(path);
|
||||
break;
|
||||
case "logo":
|
||||
setLogoPath(path);
|
||||
break;
|
||||
case "hero":
|
||||
setHeroPath(path);
|
||||
break;
|
||||
}
|
||||
setAssetPaths((prev) => ({ ...prev, [assetType]: path }));
|
||||
};
|
||||
|
||||
const setAssetDisplayPath = (assetType: AssetType, path: string): void => {
|
||||
setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: path }));
|
||||
};
|
||||
|
||||
const getDefaultUrl = (assetType: AssetType): string | null => {
|
||||
switch (assetType) {
|
||||
case "icon":
|
||||
return defaultIconUrl;
|
||||
case "logo":
|
||||
return defaultLogoUrl;
|
||||
case "hero":
|
||||
return defaultHeroUrl;
|
||||
}
|
||||
return defaultUrls[assetType];
|
||||
};
|
||||
|
||||
const handleSelectAsset = async (assetType: AssetType) => {
|
||||
@@ -140,23 +170,55 @@ export function EditGameModal({
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
const originalPath = filePaths[0];
|
||||
try {
|
||||
const copiedAssetUrl = await window.electron.copyCustomGameAsset(
|
||||
filePaths[0],
|
||||
originalPath,
|
||||
assetType
|
||||
);
|
||||
setAssetPath(assetType, copiedAssetUrl.replace("local:", ""));
|
||||
setAssetDisplayPath(assetType, originalPath);
|
||||
// Store the original path for display purposes
|
||||
setOriginalAssetPaths((prev) => ({
|
||||
...prev,
|
||||
[assetType]: originalPath,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`Failed to copy ${assetType} asset:`, error);
|
||||
setAssetPath(assetType, filePaths[0]);
|
||||
setAssetPath(assetType, originalPath);
|
||||
setAssetDisplayPath(assetType, originalPath);
|
||||
setOriginalAssetPaths((prev) => ({
|
||||
...prev,
|
||||
[assetType]: originalPath,
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreDefault = (assetType: AssetType) => {
|
||||
setAssetPath(assetType, "");
|
||||
setAssetDisplayPath(assetType, "");
|
||||
setOriginalAssetPaths((prev) => ({ ...prev, [assetType]: "" }));
|
||||
};
|
||||
|
||||
const getOriginalTitle = (): string => {
|
||||
if (!game) return "";
|
||||
|
||||
// For non-custom games, the original title is from shopDetails assets
|
||||
return shopDetails?.assets?.title || game.title || "";
|
||||
};
|
||||
|
||||
const handleRestoreDefaultTitle = () => {
|
||||
const originalTitle = getOriginalTitle();
|
||||
setGameName(originalTitle);
|
||||
};
|
||||
|
||||
const isTitleChanged = useMemo((): boolean => {
|
||||
if (!game || isCustomGame(game)) return false;
|
||||
const originalTitle = getOriginalTitle();
|
||||
return gameName.trim() !== originalTitle.trim();
|
||||
}, [game, gameName, shopDetails]);
|
||||
|
||||
const [dragOverTarget, setDragOverTarget] = useState<string | null>(null);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
@@ -232,6 +294,7 @@ export function EditGameModal({
|
||||
|
||||
const assetPath = copiedAssetUrl.replace("local:", "");
|
||||
setAssetPath(assetType, assetPath);
|
||||
setAssetDisplayPath(assetType, filePath);
|
||||
|
||||
showSuccessToast(
|
||||
`${assetType.charAt(0).toUpperCase() + assetType.slice(1)} updated successfully!`
|
||||
@@ -267,10 +330,12 @@ export function EditGameModal({
|
||||
|
||||
// Helper function to prepare custom game assets
|
||||
const prepareCustomGameAssets = (game: LibraryGame | Game) => {
|
||||
const iconUrl = iconPath ? `local:${iconPath}` : game.iconUrl;
|
||||
const logoImageUrl = logoPath ? `local:${logoPath}` : game.logoImageUrl;
|
||||
const libraryHeroImageUrl = heroPath
|
||||
? `local:${heroPath}`
|
||||
const iconUrl = assetPaths.icon ? `local:${assetPaths.icon}` : game.iconUrl;
|
||||
const logoImageUrl = assetPaths.logo
|
||||
? `local:${assetPaths.logo}`
|
||||
: game.logoImageUrl;
|
||||
const libraryHeroImageUrl = assetPaths.hero
|
||||
? `local:${assetPaths.hero}`
|
||||
: game.libraryHeroImageUrl;
|
||||
|
||||
return { iconUrl, logoImageUrl, libraryHeroImageUrl };
|
||||
@@ -279,9 +344,9 @@ export function EditGameModal({
|
||||
// Helper function to prepare non-custom game assets
|
||||
const prepareNonCustomGameAssets = () => {
|
||||
return {
|
||||
customIconUrl: iconPath ? `local:${iconPath}` : null,
|
||||
customLogoImageUrl: logoPath ? `local:${logoPath}` : null,
|
||||
customHeroImageUrl: heroPath ? `local:${heroPath}` : null,
|
||||
customIconUrl: assetPaths.icon ? `local:${assetPaths.icon}` : null,
|
||||
customLogoImageUrl: assetPaths.logo ? `local:${assetPaths.logo}` : null,
|
||||
customHeroImageUrl: assetPaths.hero ? `local:${assetPaths.hero}` : null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -290,14 +355,17 @@ export function EditGameModal({
|
||||
const { iconUrl, logoImageUrl, libraryHeroImageUrl } =
|
||||
prepareCustomGameAssets(game);
|
||||
|
||||
return window.electron.updateCustomGame(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
gameName.trim(),
|
||||
iconUrl || undefined,
|
||||
logoImageUrl || undefined,
|
||||
libraryHeroImageUrl || undefined
|
||||
);
|
||||
return window.electron.updateCustomGame({
|
||||
shop: game.shop,
|
||||
objectId: game.objectId,
|
||||
title: gameName.trim(),
|
||||
iconUrl: iconUrl || undefined,
|
||||
logoImageUrl: logoImageUrl || undefined,
|
||||
libraryHeroImageUrl: libraryHeroImageUrl || undefined,
|
||||
originalIconPath: originalAssetPaths.icon || undefined,
|
||||
originalLogoPath: originalAssetPaths.logo || undefined,
|
||||
originalHeroPath: originalAssetPaths.hero || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to update non-custom game
|
||||
@@ -305,14 +373,17 @@ export function EditGameModal({
|
||||
const { customIconUrl, customLogoImageUrl, customHeroImageUrl } =
|
||||
prepareNonCustomGameAssets();
|
||||
|
||||
return window.electron.updateGameCustomAssets(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
gameName.trim(),
|
||||
return window.electron.updateGameCustomAssets({
|
||||
shop: game.shop,
|
||||
objectId: game.objectId,
|
||||
title: gameName.trim(),
|
||||
customIconUrl,
|
||||
customLogoImageUrl,
|
||||
customHeroImageUrl
|
||||
);
|
||||
customHeroImageUrl,
|
||||
customOriginalIconPath: originalAssetPaths.icon || undefined,
|
||||
customOriginalLogoPath: originalAssetPaths.logo || undefined,
|
||||
customOriginalHeroPath: originalAssetPaths.hero || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateGame = async () => {
|
||||
@@ -343,19 +414,24 @@ export function EditGameModal({
|
||||
};
|
||||
|
||||
// Helper function to reset form to initial state
|
||||
const resetFormToInitialState = (game: LibraryGame | Game) => {
|
||||
setGameName(game.title || "");
|
||||
const resetFormToInitialState = useCallback(
|
||||
(game: LibraryGame | Game) => {
|
||||
setGameName(game.title || "");
|
||||
|
||||
if (isCustomGame(game)) {
|
||||
setCustomGameAssets(game);
|
||||
// Clear default URLs for custom games
|
||||
setDefaultIconUrl(null);
|
||||
setDefaultLogoUrl(null);
|
||||
setDefaultHeroUrl(null);
|
||||
} else {
|
||||
setNonCustomGameAssets(game as LibraryGame);
|
||||
}
|
||||
};
|
||||
if (isCustomGame(game)) {
|
||||
setCustomGameAssets(game);
|
||||
// Clear default URLs for custom games
|
||||
setDefaultUrls({
|
||||
icon: null,
|
||||
logo: null,
|
||||
hero: null,
|
||||
});
|
||||
} else {
|
||||
setNonCustomGameAssets(game as LibraryGame);
|
||||
}
|
||||
},
|
||||
[setCustomGameAssets, setNonCustomGameAssets]
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isUpdating && game) {
|
||||
@@ -378,6 +454,7 @@ export function EditGameModal({
|
||||
|
||||
const renderImageSection = (assetType: AssetType) => {
|
||||
const assetPath = getAssetPath(assetType);
|
||||
const assetDisplayPath = getAssetDisplayPath(assetType);
|
||||
const defaultUrl = getDefaultUrl(assetType);
|
||||
const hasImage = assetPath || (game && !isCustomGame(game) && defaultUrl);
|
||||
const isDragOver = dragOverTarget === assetType;
|
||||
@@ -390,7 +467,7 @@ export function EditGameModal({
|
||||
<div className="edit-game-modal__image-section">
|
||||
<TextField
|
||||
placeholder={t(`edit_game_modal_select_${assetType}`)}
|
||||
value={assetPath}
|
||||
value={assetDisplayPath}
|
||||
readOnly
|
||||
theme="dark"
|
||||
rightContent={
|
||||
@@ -489,6 +566,19 @@ export function EditGameModal({
|
||||
onChange={handleGameNameChange}
|
||||
theme="dark"
|
||||
disabled={isUpdating}
|
||||
rightContent={
|
||||
isTitleChanged && (
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleRestoreDefaultTitle}
|
||||
disabled={isUpdating}
|
||||
title="Restore default title"
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="edit-game-modal__asset-selector">
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.sidebar-section {
|
||||
background-color: globals.$background-color;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
|
||||
&__button {
|
||||
padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2);
|
||||
display: flex;
|
||||
@@ -15,7 +21,7 @@
|
||||
font-weight: bold;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
@@ -34,6 +40,7 @@
|
||||
&__content {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.4s cubic-bezier(0, 1, 0, 1);
|
||||
background-color: globals.$dark-background-color;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function HowLongToBeatSection({
|
||||
return `${value} ${t(durationTranslation[unit])}`;
|
||||
};
|
||||
|
||||
if (!howLongToBeatData && !isLoading) return null;
|
||||
if (!howLongToBeatData || !isLoading) return null;
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.content-sidebar {
|
||||
border-left: solid 1px globals.$border-color;
|
||||
background-color: globals.$dark-background-color;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
width: 320px;
|
||||
|
||||
@@ -76,6 +76,15 @@
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__title-flame-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&__title {
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function Home() {
|
||||
<img
|
||||
src={flameIconAnimated}
|
||||
alt="Flame animation"
|
||||
className="home__flame-icon"
|
||||
className="home__title-flame-icon"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useAppDispatch, useFormat } from "@renderer/hooks";
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { TelescopeIcon, ChevronRightIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserGame } from "@types";
|
||||
import { LockedProfile } from "./locked-profile";
|
||||
import { ReportProfile } from "../report-profile/report-profile";
|
||||
import { FriendsBox } from "./friends-box";
|
||||
@@ -17,8 +16,6 @@ import { useSectionCollapse } from "@renderer/hooks/use-section-collapse";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
sectionVariants,
|
||||
gameCardVariants,
|
||||
gameGridVariants,
|
||||
chevronVariants,
|
||||
GAME_STATS_ANIMATION_DURATION_IN_MS,
|
||||
} from "./profile-animations";
|
||||
@@ -38,8 +35,6 @@ export function ProfileContent() {
|
||||
const [statsIndex, setStatsIndex] = useState(0);
|
||||
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
|
||||
const [sortBy, setSortBy] = useState<SortOption>("playedRecently");
|
||||
const [prevLibraryGames, setPrevLibraryGames] = useState<UserGame[]>([]);
|
||||
const [prevPinnedGames, setPrevPinnedGames] = useState<UserGame[]>([]);
|
||||
const statsAnimation = useRef(-1);
|
||||
const { toggleSection, isPinnedCollapsed } = useSectionCollapse();
|
||||
|
||||
@@ -92,27 +87,6 @@ export function ProfileContent() {
|
||||
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
const gamesHaveChanged = (
|
||||
current: UserGame[],
|
||||
previous: UserGame[]
|
||||
): boolean => {
|
||||
if (current.length !== previous.length) return true;
|
||||
return current.some(
|
||||
(game, index) => game.objectId !== previous[index]?.objectId
|
||||
);
|
||||
};
|
||||
|
||||
const shouldAnimateLibrary = gamesHaveChanged(libraryGames, prevLibraryGames);
|
||||
const shouldAnimatePinned = gamesHaveChanged(pinnedGames, prevPinnedGames);
|
||||
|
||||
useEffect(() => {
|
||||
setPrevLibraryGames(libraryGames);
|
||||
}, [libraryGames]);
|
||||
|
||||
useEffect(() => {
|
||||
setPrevPinnedGames(pinnedGames);
|
||||
}, [pinnedGames]);
|
||||
|
||||
const usersAreFriends = useMemo(() => {
|
||||
return userProfile?.relation?.status === "ACCEPTED";
|
||||
}, [userProfile]);
|
||||
@@ -192,57 +166,22 @@ export function ProfileContent() {
|
||||
exit="collapsed"
|
||||
layout
|
||||
>
|
||||
<motion.ul
|
||||
className="profile-content__games-grid"
|
||||
variants={
|
||||
shouldAnimatePinned ? gameGridVariants : undefined
|
||||
}
|
||||
initial={shouldAnimatePinned ? "hidden" : undefined}
|
||||
animate={shouldAnimatePinned ? "visible" : undefined}
|
||||
exit={shouldAnimatePinned ? "exit" : undefined}
|
||||
key={
|
||||
shouldAnimatePinned
|
||||
? `pinned-${sortBy}`
|
||||
: `pinned-static`
|
||||
}
|
||||
>
|
||||
{shouldAnimatePinned ? (
|
||||
<AnimatePresence mode="wait">
|
||||
{pinnedGames?.map((game, index) => (
|
||||
<motion.li
|
||||
key={game.objectId}
|
||||
variants={gameCardVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
transition={{ delay: index * 0.1 }}
|
||||
style={{ listStyle: "none" }}
|
||||
>
|
||||
<UserLibraryGameCard
|
||||
game={game}
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
/>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
pinnedGames?.map((game) => (
|
||||
<li
|
||||
key={game.objectId}
|
||||
style={{ listStyle: "none" }}
|
||||
>
|
||||
<UserLibraryGameCard
|
||||
game={game}
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
/>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</motion.ul>
|
||||
<ul className="profile-content__games-grid">
|
||||
{pinnedGames?.map((game) => (
|
||||
<li
|
||||
key={game.objectId}
|
||||
style={{ listStyle: "none" }}
|
||||
>
|
||||
<UserLibraryGameCard
|
||||
game={game}
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
sortBy={sortBy}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -262,54 +201,19 @@ export function ProfileContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.ul
|
||||
className="profile-content__games-grid"
|
||||
variants={
|
||||
shouldAnimateLibrary ? gameGridVariants : undefined
|
||||
}
|
||||
initial={shouldAnimateLibrary ? "hidden" : undefined}
|
||||
animate={shouldAnimateLibrary ? "visible" : undefined}
|
||||
exit={shouldAnimateLibrary ? "exit" : undefined}
|
||||
key={
|
||||
shouldAnimateLibrary
|
||||
? `library-${sortBy}`
|
||||
: `library-static`
|
||||
}
|
||||
>
|
||||
{shouldAnimateLibrary ? (
|
||||
<AnimatePresence mode="wait">
|
||||
{libraryGames?.map((game, index) => (
|
||||
<motion.li
|
||||
key={game.objectId}
|
||||
variants={gameCardVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
transition={{ delay: index * 0.1 }}
|
||||
style={{ listStyle: "none" }}
|
||||
>
|
||||
<UserLibraryGameCard
|
||||
game={game}
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
/>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
libraryGames?.map((game) => (
|
||||
<li key={game.objectId} style={{ listStyle: "none" }}>
|
||||
<UserLibraryGameCard
|
||||
game={game}
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
/>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</motion.ul>
|
||||
<ul className="profile-content__games-grid">
|
||||
{libraryGames?.map((game) => (
|
||||
<li key={game.objectId} style={{ listStyle: "none" }}>
|
||||
<UserLibraryGameCard
|
||||
game={game}
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
sortBy={sortBy}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -338,8 +242,6 @@ export function ProfileContent() {
|
||||
pinnedGames,
|
||||
isPinnedCollapsed,
|
||||
toggleSection,
|
||||
shouldAnimateLibrary,
|
||||
shouldAnimatePinned,
|
||||
sortBy,
|
||||
]);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
display: flex;
|
||||
transition: all ease 0.2s;
|
||||
cursor: grab;
|
||||
container-type: inline-size;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
@@ -86,32 +87,10 @@
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__favorite-icon {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: solid 1px rgba(255, 255, 255, 0.15);
|
||||
border-radius: 50%;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&__pin-button {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
@@ -160,6 +139,27 @@
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&-long {
|
||||
display: inline;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&-short {
|
||||
display: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// When the card is narrow (less than 180px), show short format
|
||||
@container (max-width: 140px) {
|
||||
&-long {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&-short {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
&__manual-playtime {
|
||||
color: globals.$warning-color;
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
ClockIcon,
|
||||
TrophyIcon,
|
||||
AlertFillIcon,
|
||||
HeartFillIcon,
|
||||
PinIcon,
|
||||
PinSlashIcon,
|
||||
} from "@primer/octicons-react";
|
||||
@@ -27,6 +26,7 @@ interface UserLibraryGameCardProps {
|
||||
statIndex: number;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
export function UserLibraryGameCard({
|
||||
@@ -34,6 +34,7 @@ export function UserLibraryGameCard({
|
||||
statIndex,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
sortBy,
|
||||
}: UserLibraryGameCardProps) {
|
||||
const { userProfile, isMe, getUserLibraryGames } =
|
||||
useContext(userProfileContext);
|
||||
@@ -79,17 +80,22 @@ export function UserLibraryGameCard({
|
||||
};
|
||||
|
||||
const formatPlayTime = useCallback(
|
||||
(playTimeInSeconds = 0) => {
|
||||
(playTimeInSeconds = 0, isShort = false) => {
|
||||
const minutes = playTimeInSeconds / 60;
|
||||
|
||||
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
|
||||
return t("amount_minutes", {
|
||||
return t(isShort ? "amount_minutes_short" : "amount_minutes", {
|
||||
amount: minutes.toFixed(0),
|
||||
});
|
||||
}
|
||||
|
||||
const hours = minutes / 60;
|
||||
return t("amount_hours", { amount: numberFormatter.format(hours) });
|
||||
const hoursKey = isShort ? "amount_hours_short" : "amount_hours";
|
||||
const hoursAmount = isShort
|
||||
? Math.floor(hours)
|
||||
: numberFormatter.format(hours);
|
||||
|
||||
return t(hoursKey, { amount: hoursAmount });
|
||||
},
|
||||
[numberFormatter, t]
|
||||
);
|
||||
@@ -104,7 +110,7 @@ export function UserLibraryGameCard({
|
||||
!game.isPinned
|
||||
);
|
||||
|
||||
await getUserLibraryGames();
|
||||
await getUserLibraryGames(sortBy);
|
||||
|
||||
if (game.isPinned) {
|
||||
showSuccessToast(t("game_removed_from_pinned"));
|
||||
@@ -130,33 +136,26 @@ export function UserLibraryGameCard({
|
||||
onClick={() => navigate(buildUserGameDetailsPath(game))}
|
||||
>
|
||||
<div className="user-library-game__overlay">
|
||||
{(game.isFavorite || isMe) && (
|
||||
{isMe && (
|
||||
<div className="user-library-game__actions-container">
|
||||
{game.isFavorite && (
|
||||
<div className="user-library-game__favorite-icon">
|
||||
<HeartFillIcon size={12} />
|
||||
</div>
|
||||
)}
|
||||
{isMe && (
|
||||
<button
|
||||
type="button"
|
||||
className="user-library-game__pin-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleGamePinned();
|
||||
}}
|
||||
disabled={isPinning}
|
||||
>
|
||||
{game.isPinned ? (
|
||||
<PinSlashIcon size={12} />
|
||||
) : (
|
||||
<PinIcon size={12} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="user-library-game__pin-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleGamePinned();
|
||||
}}
|
||||
disabled={isPinning}
|
||||
>
|
||||
{game.isPinned ? (
|
||||
<PinSlashIcon size={12} />
|
||||
) : (
|
||||
<PinIcon size={12} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<small
|
||||
<div
|
||||
className="user-library-game__playtime"
|
||||
data-tooltip-place="top"
|
||||
data-tooltip-content={
|
||||
@@ -174,8 +173,13 @@ export function UserLibraryGameCard({
|
||||
) : (
|
||||
<ClockIcon size={11} />
|
||||
)}
|
||||
{formatPlayTime(game.playTimeInSeconds)}
|
||||
</small>
|
||||
<span className="user-library-game__playtime-long">
|
||||
{formatPlayTime(game.playTimeInSeconds)}
|
||||
</span>
|
||||
<span className="user-library-game__playtime-short">
|
||||
{formatPlayTime(game.playTimeInSeconds, true)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{userProfile?.hasActiveSubscription &&
|
||||
game.achievementCount > 0 && (
|
||||
|
||||
@@ -38,6 +38,12 @@ export interface Game {
|
||||
customIconUrl?: string | null;
|
||||
customLogoImageUrl?: string | null;
|
||||
customHeroImageUrl?: string | null;
|
||||
originalIconPath?: string | null;
|
||||
originalLogoPath?: string | null;
|
||||
originalHeroPath?: string | null;
|
||||
customOriginalIconPath?: string | null;
|
||||
customOriginalLogoPath?: string | null;
|
||||
customOriginalHeroPath?: string | null;
|
||||
playTimeInMilliseconds: number;
|
||||
unsyncedDeltaPlayTimeInMilliseconds?: number;
|
||||
lastTimePlayed: Date | null;
|
||||
|
||||
Reference in New Issue
Block a user