Merge branch 'feat/migration-to-leveldb' into feature/torbox-integration

# Conflicts:
#	src/renderer/src/pages/downloads/download-group.tsx
This commit is contained in:
Zamitto 2025-02-01 16:42:15 -03:00
commit e3b9b16387
40 changed files with 1624 additions and 1101 deletions

2
.gitignore vendored
View file

@ -14,3 +14,5 @@ aria2/
# Sentry Config File # Sentry Config File
.env.sentry-build-plugin .env.sentry-build-plugin
*storybook.log

View file

@ -1,417 +1,439 @@
{ {
"language_name": "اَلْعَرَبِيَّةُ", "language_name": "العربية",
"app": { "app": {
"successfully_signed_in": "تم تسجيل الدخول بنجاح" "successfully_signed_in": "تم تسجيل الدخول بنجاح"
}, },
"home": { "home": {
"featured": ُتَمَيِّز", "featured": ميز",
"surprise_me": "فَاجِئْنِي", "surprise_me": "مفاجئني",
"no_results": َمْ يُعْثَرْ عَلَى نَتائِج", "no_results": م يتم العثور على نتائج",
"start_typing": "اِبْدَأْ بِالْكِتَابَةِ لِلْبَحْثِ...", "start_typing": "ابدأ الكتابة للبحث...",
"hot": "اَلْأَكْثَرُ شُيُوعًا الْآن", "hot": "الأكثر شيوعًا الآن",
"weekly": "📅 أَفْضَلُ أَلْعَابِ الْأُسْبُوعِ", "weekly": "📅 أفضل ألعاب الأسبوع",
"achievements": "🏆 أَلْعَابٌ لِلتَّغَلُّبِ عَلَيْهَا" "achievements": "🏆 ألعاب للتغلب عليها"
}, },
"sidebar": { "sidebar": {
"catalogue": "الْفِهْرِسُ", "catalogue": "الكـتالوج",
"downloads": "التَّنْزِيلَاتُ", "downloads": "التنزيلات",
"settings": "الإعْدَادَاتُ", "settings": "الإعدادات",
"my_library": َكْتَبَتِي", "my_library": كتبتي",
"downloading_metadata": "{{title}} (جَارٍ تَنْزِيلُ الْبَيَانَاتِ الْوَصْفِيَّةِ...)", "downloading_metadata": "{{title}} (جارٍ تنزيل البيانات الوصفية...)",
"paused": "{{title}} (مُوْقَفٌ)", "paused": "{{title}} (معلّق)",
"downloading": "{{title}} ({{percentage}} - جَارٍ التَّنْزِيلُ...)", "downloading": "{{title}} ({{percentage}} - جاري التنزيل...)",
"filter": َصْفِيَةُ الْمَكْتَبَةِ", "filter": صفية المكتبة",
"home": "الرَّئِيسِيَّةُ", "home": "الرئيسية",
"queued": "{{title}} (فِي الْانْتِظَارِ)", "queued": "{{title}} (في قائمة الانتظار)",
"game_has_no_executable": "اللُّعْبَةُ لَيْسَ لَدَيْهَا مِلَفٌّ تَنْفِيذِيٌّ مُحَدَّدٌ", "game_has_no_executable": "اللعبة لا تحتوي على ملف تشغيل",
"sign_in": َسْجِيلُ الدُّخُولِ", "sign_in": سجيل الدخول",
"friends": "الْأَصْدِقَاءُ", "friends": "الأصدقاء",
"need_help": "هَلْ تَحْتَاجُ إِلَى مُسَاعَدَةٍ؟" "need_help": "تحتاج مساعدة؟"
}, },
"header": { "header": {
"search": "بَحْثُ الْأَلْعَابِ", "search": "ابحث عن الألعاب",
"home": "الرَّئِيسِيَّةُ", "home": "الرئيسية",
"catalogue": "الْفِهْرِسُ", "catalogue": "الكـتالوج",
"downloads": "التَّنْزِيلَاتُ", "downloads": "التنزيلات",
"search_results": َتائِجُ الْبَحْثِ", "search_results": تائج البحث",
"settings": "الإعْدَادَاتُ", "settings": "الإعدادات",
"version_available_install": "الْإِصْدَارُ {{version}} مَتَوَفِّرٌ. انْقُرْ هُنَا لِإِعَادَةِ التَّشْغِيلِ وَالتَّثْبِيتِ.", "version_available_install": "الإصدار {{version}} متوفر. انقر هنا لإعادة التشغيل والتثبيت.",
"version_available_download": "الْإِصْدَارُ {{version}} مَتَوَفِّرٌ. انْقُرْ هُنَا لِلتَّنْزِيلِ." "version_available_download": "الإصدار {{version}} متوفر. انقر هنا للتنزيل."
}, },
"bottom_panel": { "bottom_panel": {
"no_downloads_in_progress": َا تَوْجَدُ تَنْزِيلَاتٌ جَارِيَةٌ", "no_downloads_in_progress": ا توجد تنزيلات قيد التقدم",
"downloading_metadata": َارٍ تَنْزِيلُ الْبَيَانَاتِ الْوَصْفِيَّةِ لِـ {{title}}...", "downloading_metadata": ارٍ تنزيل البيانات الوصفية لـ {{title}}...",
"downloading": َارٍ تَنْزِيلُ {{title}}... ({{percentage}} مَكْتُومٌ) - الِاكْتِمَالُ {{eta}} - {{speed}}", "downloading": ارٍ تنزيل {{title}}... ({{percentage}} اكتمال) - الوقت المتبقي {{eta}} - السرعة {{speed}}",
"calculating_eta": َارٍ تَنْزِيلُ {{title}}... ({{percentage}} مَكْتُومٌ) - جَارٍ حِسَابُ الْوَقْتِ الْمُتَبَقِّي...", "calculating_eta": ارٍ تنزيل {{title}}... ({{percentage}} اكتمال) - جاري حساب الوقت المتبقي...",
"checking_files": َارٍ التَّحَقُّقُ مِنْ مَلَفَّاتِ {{title}}... ({{percentage}} مَكْتُومٌ)" "checking_files": ارٍ فحص ملفات {{title}}... ({{percentage}} اكتمال)"
}, },
"catalogue": { "catalogue": {
"search": َصْفِيَةٌ...", "search": صفية...",
"developers": "الْمُطَوِّرُونَ", "developers": "المطورون",
"genres": "الْأَنْوَاعُ", "genres": "الأنواع",
"tags": "الْعَلَامَاتُ", "tags": "العلامات",
"publishers": "النَّاشِرُونَ", "publishers": "الناشرون",
"download_sources": َصَادِرُ التَّنْزِيلِ", "download_sources": صادر التنزيل",
"result_count": "{{resultCount}} نَتائِجُ", "result_count": "{{resultCount}} نتيجة",
"filter_count": "{{filterCount}} مَتَوَفِّرٌ", "filter_count": "{{filterCount}} متاح",
"clear_filters": َسْحُ {{filterCount}} الْمُخْتَارَةِ" "clear_filters": سح {{filterCount}} المحددة"
}, },
"game_details": { "game_details": {
"open_download_options": "فَتْحُ خِيَارَاتِ التَّنْزِيلِ", "open_download_options": "فتح خيارات التنزيل",
"download_options_zero": "لَا تَوْجَدُ خِيَارَاتُ تَنْزِيلٍ", "download_options_zero": "لا توجد خيارات تنزيل",
"download_options_one": "{{count}} خِيَارُ تَنْزِيلٍ", "download_options_one": "خيار تنزيل واحد",
"download_options_other": "{{count}} خِيَارَاتُ تَنْزِيلٍ", "download_options_other": "{{count}} خيارات تنزيل",
"updated_at": "تَمَّ التَّحْدِيثُ فِي {{updated_at}}", "updated_at": "تم التحديث في {{updated_at}}",
"install": "تَثْبِيتٌ", "install": "تثبيت",
"resume": "اسْتِئْنَافٌ", "resume": "استئناف",
"pause": "إِيقَافٌ", "pause": "إيقاف مؤقت",
"cancel": "إِلْغَاءٌ", "cancel": "إلغاء",
"remove": "إِزَالَةٌ", "remove": "إزالة",
"space_left_on_disk": "{{space}} مُتَبَقٍّ عَلَى الْقُرْصِ", "space_left_on_disk": "{{space}} متبقي على القرص",
"eta": "الِاكْتِمَالُ {{eta}}", "eta": "الانتهاء {{eta}}",
"calculating_eta": "جَارٍ حِسَابُ الْوَقْتِ الْمُتَبَقِّي...", "calculating_eta": "جارٍ حساب الوقت المتبقي...",
"downloading_metadata": "جَارٍ تَنْزِيلُ الْبَيَانَاتِ الْوَصْفِيَّةِ...", "downloading_metadata": "جارٍ تنزيل البيانات الوصفية...",
"filter": "تَصْفِيَةُ الْإِصْدَارَاتِ الْمُعَادِ تَغْلِيفُهَا", "filter": "تصفية الحزم المعاد تعبئتها",
"requirements": "مُتَطَلَّبَاتُ النِّظَامِ", "requirements": "متطلبات النظام",
"minimum": "الْأَدْنَى", "minimum": "الحد الأدنى",
"recommended": "الْمُوَصَّى بِهِ", "recommended": "مُوصى به",
"paused": "مُوْقَفٌ", "paused": "معلّق",
"release_date": "تَمَّ الْإِصْدَارُ فِي {{date}}", "release_date": "تاريخ الإصدار {{date}}",
"publisher": "نُشِرَ بِوَاسِطَةِ {{publisher}}", "publisher": "نشر بواسطة {{publisher}}",
"hours": "سَاعَاتٌ", "hours": "ساعات",
"minutes": "دَقَائِقُ", "minutes": "دقائق",
"amount_hours": "{{amount}} سَاعَاتٌ", "amount_hours": "{{amount}} ساعات",
"amount_minutes": "{{amount}} دَقَائِقُ", "amount_minutes": "{{amount}} دقائق",
"accuracy": "دِقَّةٌ {{accuracy}}%", "accuracy": "دقة {{accuracy}}%",
"add_to_library": "إِضَافَةٌ إِلَى الْمَكْتَبَةِ", "add_to_library": "إضافة إلى المكتبة",
"remove_from_library": "إِزَالَةٌ مِنَ الْمَكْتَبَةِ", "remove_from_library": "إزالة من المكتبة",
"no_downloads": "لَا تَوْجَدُ تَنْزِيلَاتٌ مَتَوَفِّرَةٌ", "no_downloads": "لا توجد تنزيلات متاحة",
"play_time": "لُعِبَ لِمُدَّةِ {{amount}}", "play_time": "لعب لمدة {{amount}}",
"last_time_played": "آخِرُ مَرَّةٍ لُعِبَتْ {{period}}", "last_time_played": "آخر تشغيل {{period}}",
"not_played_yet": "لَمْ تَلْعَبْ {{title}} بَعْدُ", "not_played_yet": "لم تلعب {{title}} بعد",
"next_suggestion": "الِاقْتِرَاحُ التَّالِي", "next_suggestion": "الاقتراح التالي",
"play": "لَعِبٌ", "play": "تشغيل",
"deleting": "جَارٍ حَذْفُ الْمُثَبِّتِ...", "deleting": "جارٍ حذف المثبت...",
"close": "إِغْلَاقٌ", "close": "إغلاق",
"playing_now": "جَارِي اللَّعِبُ الْآن", "playing_now": "يتم التشغيل الآن",
"change": "تَغْيِيرٌ", "change": "تغيير",
"repacks_modal_description": "اخْتَرِ الْإِصْدَارَ الْمُعَادَ تَغْلِيفُهُ الَّذِي تُرِيدُ تَنْزِيلَهُ", "repacks_modal_description": "اختر الحزمة المعاد تعبئتها التي تريد تنزيلها",
"select_folder_hint": "لِتَغْيِيرِ الْمَجَلَّدِ الافْتِرَاضِيِّ، اذْهَبْ إِلَى <0>الإعْدَادَاتِ</0>", "select_folder_hint": "لتغيير المجلد الافتراضي، انتقل إلى <0>الإعدادات</0>",
"download_now": "تَنْزِيلٌ الْآن", "download_now": "تنزيل الآن",
"no_shop_details": "لَمْ يَتَمَكَّنْ مِنْ اسْتِرْدَادِ تَفَاصِيلِ الْمَتْجَرِ.", "no_shop_details": "تعذر الحصول على تفاصيل المتجر.",
"download_options": "خِيَارَاتُ التَّنْزِيلِ", "download_options": "خيارات التنزيل",
"download_path": "مَسَارُ التَّنْزِيلِ", "download_path": "مسار التنزيل",
"previous_screenshot": "لَقْطَةُ الشَّاشَةِ السَّابِقَةُ", "previous_screenshot": "لقطة الشاشة السابقة",
"next_screenshot": "لَقْطَةُ الشَّاشَةِ التَّالِيَةُ", "next_screenshot": "لقطة الشاشة التالية",
"screenshot": "لَقْطَةُ الشَّاشَةِ {{number}}", "screenshot": "لقطة الشاشة {{number}}",
"open_screenshot": "فَتْحُ لَقْطَةِ الشَّاشَةِ {{number}}", "open_screenshot": "فتح لقطة الشاشة {{number}}",
"download_settings": "إعْدَادَاتُ التَّنْزِيلِ", "download_settings": "إعدادات التنزيل",
"downloader": "الْمُنَزِّلُ", "downloader": "أداة التنزيل",
"select_executable": "تَحْدِيدٌ", "select_executable": "تحديد",
"no_executable_selected": "لَمْ يُحَدَّدْ مِلَفٌّ تَنْفِيذِيٌّ", "no_executable_selected": "لم يتم تحديد ملف تشغيل",
"open_folder": "فَتْحُ الْمَجَلَّدِ", "open_folder": "فتح المجلد",
"open_download_location": "مُشَاهَدَةُ الْمَلَفَّاتِ الْمُنَزَّلَةِ", "open_download_location": "عرض الملفات المحملة",
"create_shortcut": "إِنْشَاءُ طَرِيقٍ مُخْتَصَرٍ عَلَى سَطْحِ الْمَكْتَبِ", "create_shortcut": "إنشاء اختصار على سطح المكتب",
"clear": "مَسْحٌ", "clear": "مسح",
"remove_files": "إِزَالَةُ الْمَلَفَّاتِ", "remove_files": "إزالة الملفات",
"remove_from_library_title": "هَلْ أَنْتَ مُتَأَكِّدٌ؟", "remove_from_library_title": "هل أنت متأكد؟",
"remove_from_library_description": "سَيُؤَدِّي هَذَا إِلَى إِزَالَةِ {{game}} مِنْ مَكْتَبَتِكَ", "remove_from_library_description": "سيؤدي هذا إلى إزالة {{game}} من مكتبتك",
"options": "خِيَارَاتٌ", "options": "خيارات",
"executable_section_title": "الْمِلَفُّ التَّنْفِيذِيُّ", "executable_section_title": "ملف التشغيل",
"executable_section_description": "مَسَارُ الْمِلَفِّ الَّذِي سَيَتِمُّ تَنْفِيذُهُ عِنْدَ النَّقْرِ عَلَى \"لَعِبٌ\"", "executable_section_description": "مسار الملف الذي سيتم تشغيله عند النقر على \"تشغيل\"",
"downloads_secion_title": "التَّنْزِيلَاتُ", "downloads_secion_title": "التنزيلات",
"downloads_section_description": "تَحَقَّقْ مِنَ التَّحْدِيثَاتِ أَوِ الْإِصْدَارَاتِ الْأُخْرَى لِهَذِهِ اللُّعْبَةِ", "downloads_section_description": "تحقق من التحديثات أو الإصدارات الأخرى لهذه اللعبة",
"danger_zone_section_title": "مِنْطَقَةُ الْخَطَرِ", "danger_zone_section_title": "منطقة الخطر",
"danger_zone_section_description": "إِزَالَةُ هَذِهِ اللُّعْبَةِ مِنْ مَكْتَبَتِكَ أَوِ الْمَلَفَّاتِ الْمُنَزَّلَةِ بِوَاسِطَةِ Hydra", "danger_zone_section_description": "إزالة هذه اللعبة من مكتبتك أو الملفات التي تم تنزيلها بواسطة Hydra",
"download_in_progress": "جَارٍ التَّنْزِيلُ", "download_in_progress": "تنزيل قيد التقدم",
"download_paused": "التَّنْزِيلُ مُوْقَفٌ", "download_paused": "التنزيل معلق",
"last_downloaded_option": "خِيَارُ التَّنْزِيلِ الْأَخِيرُ", "last_downloaded_option": "خيار التنزيل الأخير",
"create_shortcut_success": "تَمَّ إِنْشَاءُ الطَّرِيقِ الْمُخْتَصَرِ بِنَجَاحٍ", "create_shortcut_success": "تم إنشاء الاختصار بنجاح",
"create_shortcut_error": "خَطَأٌ فِي إِنْشَاءِ الطَّرِيقِ الْمُخْتَصَرِ", "create_shortcut_error": "خطأ في إنشاء الاختصار",
"nsfw_content_title": "هَذِهِ اللُّعْبَةُ تَحْتَوِي عَلَى مُحْتَوًى غَيْرِ لَائِقٍ", "nsfw_content_title": "هذه اللعبة تحتوي على محتوى غير لائق",
"nsfw_content_description": "{{title}} تَحْتَوِي عَلَى مُحْتَوًى قَدْ لَا يَكُونُ مُنَاسِبًا لِجَمِيعِ الْأَعْمَارِ. هَلْ أَنْتَ مُتَأَكِّدٌ مِنْ أَنَّكَ تُرِيدُ الْمُتَابَعَةَ؟", "nsfw_content_description": "{{title}} يحتوي على محتوى قد لا يناسب جميع الأعمار. هل تريد المتابعة؟",
"allow_nsfw_content": "الْمُتَابَعَةُ", "allow_nsfw_content": "متابعة",
"refuse_nsfw_content": "الرُّجُوعُ", "refuse_nsfw_content": "رجوع",
"stats": "الإحْصَائِيَّاتُ", "stats": "الإحصائيات",
"download_count": "التَّنْزِيلَاتُ", "download_count": "مرات التنزيل",
"player_count": "اللَّاعِبُونَ النَّشِطُونَ", "player_count": "اللاعبون النشطون",
"download_error": "هَذَا خِيَارُ التَّنْزِيلِ غَيْرُ مَتَوَفِّرٍ", "download_error": "خيار التنزيل هذا غير متاح",
"download": "تَنْزِيلٌ", "download": "تنزيل",
"executable_path_in_use": "الْمِلَفُّ التَّنْفِيذِيُّ مُسْتَخْدَمٌ بِوَاسِطَةِ \"{{game}}\"", "executable_path_in_use": "مسار التشغيل مستخدم بالفعل بواسطة \"{{game}}\"",
"warning": "تَنْبِيهٌ:", "warning": "تحذير:",
"hydra_needs_to_remain_open": "لِهَذَا التَّنْزِيلِ، يَجِبُ أَنْ يَبْقَى Hydra مَفْتُوحًا حَتَّى يَتِمَّ الِاكْتِمَالُ. إِذَا أُغْلِقَ Hydra قَبْلَ الِاكْتِمَالِ، سَتَفْقِدُ تَقَدُّمَكَ.", "hydra_needs_to_remain_open": "لهذا التنزيل، يجب أن يبقى Hydra مفتوحًا حتى اكتماله. إذا أغلق Hydra قبل الاكتمال، ستفقد تقدمك.",
"achievements": "الإِنْجَازَاتُ", "achievements": "الإنجازات",
"achievements_count": "الإِنْجَازَاتُ {{unlockedCount}}/{{achievementsCount}}", "achievements_count": "الإنجازات {{unlockedCount}}/{{achievementsCount}}",
"cloud_save": "حِفْظٌ سَحَابِيٌّ", "cloud_save": "حفظ سحابي",
"cloud_save_description": "احْفَظْ تَقَدُّمَكَ فِي السَّحَابَةِ وَاسْتَمِرَّ فِي اللَّعِبِ عَلَى أَيِّ جِهَازٍ", "cloud_save_description": "احفظ تقدمك على السحابة واستمر في اللعب من أي جهاز",
"backups": "الْنُسَخُ الِاحْتِيَاطِيَّةُ", "backups": "النسخ الاحتياطية",
"install_backup": "تَثْبِيتٌ", "install_backup": "تثبيت",
"delete_backup": "حَذْفٌ", "delete_backup": "حذف",
"create_backup": "نُسْخَةٌ احْتِيَاطِيَّةٌ جَدِيدَةٌ", "create_backup": "نسخة احتياطية جديدة",
"last_backup_date": "آخِرُ نُسْخَةٍ احْتِيَاطِيَّةٍ فِي {{date}}", "last_backup_date": "آخر نسخة احتياطية في {{date}}",
"no_backup_preview": "لَمْ يُعْثَرْ عَلَى أَيِّ أَلْعَابٍ مَحْفُوظَةٍ لِهَذَا الْعُنْوَانِ", "no_backup_preview": "لم يتم العثور على حفظات لهذا العنوان",
"restoring_backup": "جَارٍ اسْتِعَادَةُ النُّسْخَةِ الِاحْتِيَاطِيَّةِ ({{progress}} مَكْتُومٌ)...", "restoring_backup": "جارٍ استعادة النسخة الاحتياطية ({{progress}} اكتمال)...",
"uploading_backup": "جَارٍ رَفْعُ النُّسْخَةِ الِاحْتِيَاطِيَّةِ...", "uploading_backup": "جارٍ رفع النسخة الاحتياطية...",
"no_backups": "لَمْ تَقُمْ بِإِنْشَاءِ أَيِّ نُسَخٍ احْتِيَاطِيَّةٍ لِهَذِهِ اللُّعْبَةِ بَعْدُ", "no_backups": "لم تقم بإنشاء أي نسخ احتياطية لهذه اللعبة بعد",
"backup_uploaded": "تَمَّ رَفْعُ النُّسْخَةِ الِاحْتِيَاطِيَّةِ", "backup_uploaded": "تم رفع النسخة الاحتياطية",
"backup_deleted": "تَمَّ حَذْفُ النُّسْخَةِ الِاحْتِيَاطِيَّةِ", "backup_deleted": "تم حذف النسخة الاحتياطية",
"backup_restored": "تَمَّ اسْتِعَادَةُ النُّسْخَةِ الِاحْتِيَاطِيَّةِ", "backup_restored": "تم استعادة النسخة الاحتياطية",
"see_all_achievements": "عَرْضُ جَمِيعِ الإِنْجَازَاتِ", "see_all_achievements": "عرض جميع الإنجازات",
"sign_in_to_see_achievements": "سَجِّلِ الدُّخُولَ لِعَرْضِ الإِنْجَازَاتِ", "sign_in_to_see_achievements": "سجل الدخول لعرض الإنجازات",
"mapping_method_automatic": "آلِيٌّ", "mapping_method_automatic": "تلقائي",
"mapping_method_manual": "يَدَوِيٌّ", "mapping_method_manual": "يدوي",
"mapping_method_label": "طَرِيقَةُ التَّحْدِيدِ", "mapping_method_label": "طريقة التعيين",
"files_automatically_mapped": "تَمَّ تَحْدِيدُ الْمَلَفَّاتِ تِلْقَائِيًّا", "files_automatically_mapped": "تم تعيين الملفات تلقائيًا",
"no_backups_created": "لَمْ تُنْشَأْ أَيُّ نُسَخٍ احْتِيَاطِيَّةٍ لِهَذِهِ اللُّعْبَةِ", "no_backups_created": "لم يتم إنشاء نسخ احتياطية لهذه اللعبة",
"manage_files": "إِدَارَةُ الْمَلَفَّاتِ", "manage_files": "إدارة الملفات",
"loading_save_preview": "جَارٍ الْبَحْثُ عَنْ أَلْعَابٍ مَحْفُوظَةٍ...", "loading_save_preview": "جارٍ البحث عن حفظات الألعاب...",
"wine_prefix": "بَادِئَةُ Wine", "wine_prefix": "بادئة Wine",
"wine_prefix_description": "بَادِئَةُ Wine الْمُسْتَخْدَمَةُ لِتَشْغِيلِ هَذِهِ اللُّعْبَةِ", "wine_prefix_description": "بادئة Wine المستخدمة لتشغيل هذه اللعبة",
"launch_options": "خِيَارَاتُ الْإِطْلَاقِ", "launch_options": "خيارات التشغيل",
"launch_options_description": "يُمْكِنُ لِلْمُسْتَخْدِمِينَ الْمُتَقَدِّمِينَ إِدْخَالُ تَعْدِيلَاتٍ عَلَى خِيَارَاتِ الْإِطْلَاقِ", "launch_options_description": "يمكن للمستخدمين المتقدمين إدخال تعديلات على خيارات التشغيل (ميزة تجريبية)",
"launch_options_placeholder": "لَمْ يُحَدَّدْ أَيُّ مُعَامِلٍ", "launch_options_placeholder": "لم يتم تحديد أي معاملات",
"no_download_option_info": "لَا تَوْجَدُ مَعْلُومَاتٌ مَتَوَفِّرَةٌ", "no_download_option_info": "لا توجد معلومات متاحة",
"backup_deletion_failed": "فَشَلَ فِي حَذْفِ النُّسْخَةِ الِاحْتِيَاطِيَّةِ", "backup_deletion_failed": "فشل حذف النسخة الاحتياطية",
"max_number_of_artifacts_reached": "تَمَّ بَلُوغُ الْعَدَدِ الْأَقْصَى لِلنُّسَخِ الِاحْتِيَاطِيَّةِ لِهَذِهِ اللُّعْبَةِ", "max_number_of_artifacts_reached": "تم الوصول إلى الحد الأقصى لعدد النسخ الاحتياطية لهذه اللعبة",
"achievements_not_sync": "تَعَرَّفْ عَلَى كَيْفِيَّةِ مَزْجِ إِنْجَازَاتِكَ", "achievements_not_sync": "تعرف على كيفية مزامنة إنجازاتك",
"manage_files_description": "إِدَارَةُ الْمَلَفَّاتِ الَّتِي سَيَتِمُّ نَسْخُهَا احْتِيَاطِيًّا وَاسْتِعَادَتُهَا", "manage_files_description": "إدارة الملفات التي سيتم نسخها احتياطيًا واستعادتها",
"select_folder": "تَحْدِيدُ الْمَجَلَّدِ", "select_folder": "حدد المجلد",
"backup_from": "نُسْخَةٌ احْتِيَاطِيَّةٌ مِنْ {{date}}", "backup_from": "نسخة احتياطية من {{date}}",
"custom_backup_location_set": "تَمَّ تَحْدِيدُ مَوْقِعِ النُّسْخَةِ الِاحْتِيَاطِيَّةِ الْمُخَصَّصِ", "custom_backup_location_set": "تم تعيين موقع نسخ احتياطي مخصص",
"no_directory_selected": "لَمْ يُحَدَّدْ أَيُّ دَلِيلٍ" "no_directory_selected": "لم يتم تحديد مجلد",
"no_write_permission": "لا يمكن التنزيل إلى هذا المجلد. انقر هنا لمعرفة المزيد.",
"reset_achievements": "إعادة تعيين الإنجازات",
"reset_achievements_description": "سيؤدي هذا إلى إعادة تعيين جميع إنجازات {{game}}",
"reset_achievements_title": "هل أنت متأكد؟",
"reset_achievements_success": "تم إعادة تعيين الإنجازات بنجاح",
"reset_achievements_error": "فشل إعادة تعيين الإنجازات"
}, },
"activation": { "activation": {
"title": "تَفْعِيلُ Hydra", "title": فعيل Hydra",
"installation_id": "مُعَرِّفُ التَّثْبِيتِ:", "installation_id": عرف التثبيت:",
"enter_activation_code": "أَدْخِلْ رَمْزَ التَّفْعِيلِ", "enter_activation_code": دخل رمز التفعيل الخاص بك",
"message": "إِذَا كُنْتَ لَا تَعْرِفُ أَيْنَ تَطْلُبُ هَذَا، فَلا يَجِبُ أَنْ تَكُونَ لَدَيْكَ.", "message": ذا كنت لا تعرف أين تطلب هذا، فلا يجب أن يكون لديك هذا.",
"activate": َفْعِيلٌ", "activate": فعيل",
"loading": َارٍ التَّحْمِيلُ..." "loading": ارٍ التحميل..."
}, },
"downloads": { "downloads": {
"resume": "اسْتِئْنَافٌ", "resume": "استئناف",
"pause": ِيقَافٌ", "pause": يقاف مؤقت",
"eta": "الِاكْتِمَالُ {{eta}}", "eta": "الانتهاء {{eta}}",
"paused": ُوْقَفٌ", "paused": علّق",
"verifying": َارٍ التَّحَقُّقُ...", "verifying": ارٍ التحقق...",
"completed": َكْتُومٌ", "completed": كتمل",
"removed": "لَمْ يُنَزَّلْ", "removed": "غير محمل",
"cancel": ِلْغَاءٌ", "cancel": لغاء",
"filter": َصْفِيَةُ الْأَلْعَابِ الْمُنَزَّلَةِ", "filter": صفية الألعاب المحملة",
"remove": ِزَالَةٌ", "remove": زالة",
"downloading_metadata": َارٍ تَنْزِيلُ الْبَيَانَاتِ الْوَصْفِيَّةِ...", "downloading_metadata": ارٍ تنزيل البيانات الوصفية...",
"deleting": َارٍ حَذْفُ الْمُثَبِّتِ...", "deleting": ارٍ حذف المثبت...",
"delete": "حَذْفُ الْمُثَبِّتِ", "delete": "إزالة المثبت",
"delete_modal_title": "هَلْ أَنْتَ مُتَأَكِّدٌ؟", "delete_modal_title": "هل أنت متأكد؟",
"delete_modal_description": َيُؤَدِّي هَذَا إِلَى إِزَالَةِ جَمِيعِ مَلَفَّاتِ التَّثْبِيتِ مِنْ حَاسُوبِكَ", "delete_modal_description": يؤدي هذا إلى إزالة جميع ملفات التثبيت من جهازك",
"install": َثْبِيتٌ", "install": ثبيت",
"download_in_progress": "جَارٍ التَّنْفِيذُ", "download_in_progress": "قيد التقدم",
"queued_downloads": "التَّنْزِيلَاتُ فِي الْانْتِظَارِ", "queued_downloads": "التنزيلات في قائمة الانتظار",
"downloads_completed": َكْتُومٌ", "downloads_completed": كتمل",
"queued": ِي الْانْتِظَارِ", "queued": ي قائمة الانتظار",
"no_downloads_title": َرَاغٌ تَامٌ", "no_downloads_title": ارغ جدًا",
"no_downloads_description": َمْ تَقُمْ بِتَنْزِيلِ أَيِّ شَيْءٍ بِاسْتِخْدَامِ Hydra بَعْدُ، لَكِنَّهُ لَيْسَ مُتَأَخِّرًا لِلْبَدْءِ.", "no_downloads_description": م تقم بتنزيل أي شيء باستخدام Hydra بعد، ولكن لم يفت الأوان للبدء.",
"checking_files": َارٍ التَّحَقُّقُ مِنَ الْمَلَفَّاتِ...", "checking_files": ارٍ فحص الملفات...",
"seeding": "الْبَذْرُ", "seeding": "التوزيع",
"stop_seeding": ِيقَافُ الْبَذْرِ", "stop_seeding": يقاف التوزيع",
"resume_seeding": "اسْتِئْنَافُ الْبَذْرِ", "resume_seeding": "استئناف التوزيع",
"options": ِدَارَةٌ" "options": دارة"
}, },
"settings": { "settings": {
"downloads_path": "مَسَارُ التَّنْزِيلَاتِ", "downloads_path": "مسار التنزيلات",
"change": "تَحْدِيثٌ", "change": "تحديث",
"notifications": "الإِشْعَارَاتُ", "notifications": "الإشعارات",
"enable_download_notifications": "عِنْدَ اكْتِمَالِ التَّنْزِيلِ", "enable_download_notifications": "عند اكتمال التنزيل",
"enable_repack_list_notifications": "عِنْدَ إِضَافَةِ إِصْدَارٍ مُعَادٍ تَغْلِيفِهِ جَدِيدٍ", "enable_repack_list_notifications": "عند إضافة حزمة معاد تعبئتها جديدة",
"real_debrid_api_token_label": "رَمْزُ واجهة برمجة التطبيقات Real-Debrid", "real_debrid_api_token_label": "رمز واجهة برمجة تطبيقات Real-Debrid",
"quit_app_instead_hiding": "لا تُخْفِ Hydra عِنْدَ الإِغْلَاقِ", "quit_app_instead_hiding": "لا تخفي Hydra عند الإغلاق",
"launch_with_system": "تَشْغِيلُ Hydra عِنْدَ بَدْءِ النِّظَامِ", "launch_with_system": "تشغيل Hydra مع بدء النظام",
"general": "عَامٌ", "general": "عام",
"behavior": "سُلُوكٌ", "behavior": "السلوك",
"download_sources": "مَصَادِرُ التَّنْزِيلِ", "download_sources": "مصادر التنزيل",
"language": "اللُّغَةُ", "language": "اللغة",
"real_debrid_api_token": "رَمْزُ واجهة برمجة التطبيقات", "real_debrid_api_token": "رمز API",
"enable_real_debrid": "تَمْكِينُ Real-Debrid", "enable_real_debrid": "تفعيل Real-Debrid",
"real_debrid_description": "Real-Debrid هُوَ مُنَزِّلٌ غَيْرُ مَقْيُودٍ يَتِيحُ لَكَ تَنْزِيلَ الْمَلَفَّاتِ بِسُرْعَةٍ، مَحْدُودٌ فَقَطْ بِسُرْعَةِ الْإِنْتَرْنِتِ لَدَيْكَ.", "real_debrid_description": "Real-Debrid هو أداة تنزيل غير مقيدة تتيح لك تنزيل الملفات بسرعة، مقيدة فقط بسرعة الإنترنت لديك.",
"real_debrid_invalid_token": "رَمْزُ واجهة برمجة التطبيقات غَيْرُ صَالِحٍ", "real_debrid_invalid_token": "رمز API غير صالح",
"real_debrid_api_token_hint": "يُمْكِنُكَ الْحُصُولُ عَلَى رَمْزِ واجهة برمجة التطبيقات <0>هُنَا</0>", "real_debrid_api_token_hint": "يمكنك الحصول على رمز API الخاص بك <0>هنا</0>",
"real_debrid_free_account_error": "الْحِسَابُ \"{{username}}\" هُوَ حِسَابٌ مَجَّانِيٌّ. يَرْجَى الِاشْتِرَاكُ فِي Real-Debrid", "real_debrid_free_account_error": "الحساب \"{{username}}\" هو حساب مجاني. يرجى الاشتراك في Real-Debrid",
"real_debrid_linked_message": "تَمَّ رَبْطُ الْحِسَابِ \"{{username}}\"", "real_debrid_linked_message": "تم ربط الحساب \"{{username}}\"",
"save_changes": "حِفْظُ التَّغْيِيرَاتِ", "save_changes": "حفظ التغييرات",
"changes_saved": "تَمَّ حِفْظُ التَّغْيِيرَاتِ بِنَجَاحٍ", "changes_saved": "تم حفظ التغييرات بنجاح",
"download_sources_description": "سَتَقُومُ Hydra بِجَلْبِ رَوَابِطِ التَّنْزِيلِ مِنْ هَذِهِ الْمَصَادِرِ. يَجِبُ أَنْ يَكُونَ عُنْوَانُ URL لِلْمَصْدَرِ رَابِطًا مُبَاشِرًا إِلَى مِلَفٍّ .json يَحْتَوِي عَلَى رَوَابِطِ التَّنْزِيلِ.", "download_sources_description": "سيقوم Hydra بجلب روابط التنزيل من هذه المصادر. يجب أن يكون عنوان URL المصدر رابطًا مباشرًا لملف .json يحتوي على روابط التنزيل.",
"validate_download_source": "تَصْدِيقٌ", "validate_download_source": "تحقق",
"remove_download_source": "إِزَالَةٌ", "remove_download_source": "إزالة",
"add_download_source": "إِضَافَةُ مَصْدَرٍ", "add_download_source": "إضافة مصدر",
"download_count_zero": "لَا تَوْجَدُ خِيَارَاتُ تَنْزِيلٍ", "download_count_zero": "لا توجد خيارات تنزيل",
"download_count_one": "{{countFormatted}} خِيَارُ تَنْزِيلٍ", "download_count_one": "{{countFormatted}} خيار تنزيل",
"download_count_other": "{{countFormatted}} خِيَارَاتُ تَنْزِيلٍ", "download_count_other": "{{countFormatted}} خيارات تنزيل",
"download_source_url": "عُنْوَانُ مَصْدَرِ التَّنْزِيلِ", "download_source_url": "عنوان URL لمصدر التنزيل",
"add_download_source_description": "أَدْخِلْ عُنْوَانَ URL لِمِلَفٍّ .json", "add_download_source_description": "أدخل عنوان URL لملف .json",
"download_source_up_to_date": "مُحَدَّثٌ", "download_source_up_to_date": "محدث",
"download_source_errored": "خَطَأٌ", "download_source_errored": "خطأ",
"sync_download_sources": "مَزْجُ الْمَصَادِرِ", "sync_download_sources": "مزامنة المصادر",
"removed_download_source": "تَمَّ إِزَالَةُ مَصْدَرِ التَّنْزِيلِ", "removed_download_source": "تمت إزالة مصدر التنزيل",
"added_download_source": "تَمَّتْ إِضَافَةُ مَصْدَرِ التَّنْزِيلِ", "added_download_source": "تمت إضافة مصدر التنزيل",
"download_sources_synced": "تَمَّ مَزْجُ جَمِيعِ مَصَادِرِ التَّنْزِيلِ", "download_sources_synced": "تمت مزامنة جميع مصادر التنزيل",
"insert_valid_json_url": "أَدْخِلْ عُنْوَانَ JSON صَالِحًا", "insert_valid_json_url": "أدخل عنوان JSON صالح",
"found_download_option_zero": "لَمْ يُعْثَرْ عَلَى خِيَارِ تَنْزِيلٍ", "found_download_option_zero": "لم يتم العثور على خيارات تنزيل",
"found_download_option_one": "عُثِرَ عَلَى {{countFormatted}} خِيَارِ تَنْزِيلٍ", "found_download_option_one": "تم العثور على {{countFormatted}} خيار تنزيل",
"found_download_option_other": "عُثِرَ عَلَى {{countFormatted}} خِيَارَاتِ تَنْزِيلٍ", "found_download_option_other": "تم العثور على {{countFormatted}} خيارات تنزيل",
"import": "اسْتِيرَادٌ", "import": "استيراد",
"public": "عَامٌ", "public": "عام",
"private": "خَاصٌ", "private": "خاص",
"friends_only": "الْأَصْدِقَاءُ فَقَطْ", "friends_only": "الأصدقاء فقط",
"privacy": "الْخُصُوصِيَّةُ", "privacy": "الخصوصية",
"profile_visibility": "رُؤْيَةُ الْمَلَفِّ الشَّخْصِيِّ", "profile_visibility": "رؤية الملف الشخصي",
"profile_visibility_description": "اخْتَرْ مَنْ يُمْكِنُهُ رُؤْيَةُ مَلَفِّكَ الشَّخْصِيِّ وَمَكْتَبَتِكَ", "profile_visibility_description": "اختر من يمكنه رؤية ملفك الشخصي ومكتبتك",
"required_field": "هَذَا الْحَقْلُ مَطْلُوبٌ", "required_field": "هذا الحقل مطلوب",
"source_already_exists": "تَمَّتْ إِضَافَةُ هَذَا الْمَصْدَرِ مِنْ قَبْلُ", "source_already_exists": "تمت إضافة هذا المصدر مسبقًا",
"must_be_valid_url": "يَجِبُ أَنْ يَكُونَ الْمَصْدَرُ عُنْوَانَ URL صَالِحًا", "must_be_valid_url": "يجب أن يكون المصدر عنوان URL صالحًا",
"blocked_users": "الْمُسْتَخْدِمُونَ الْمَحْظُورُونَ", "blocked_users": "المستخدمون المحظورون",
"user_unblocked": "تَمَّ إِزَالَةُ حَظْرِ الْمُسْتَخْدِمِ", "user_unblocked": "تم إلغاء حظر المستخدم",
"enable_achievement_notifications": "عِنْدَ فَتْحِ إِنْجَازٍ", "enable_achievement_notifications": "عند فتح إنجاز",
"launch_minimized": "تَشْغِيلُ Hydra مُصَغَّرًا", "launch_minimized": "تشغيل Hydra مصغرًا",
"disable_nsfw_alert": "تَعْطِيلُ تَنْبِيهِ الْمُحْتَوَى غَيْرِ اللَّائِقِ", "disable_nsfw_alert": "تعطيل تنبيه المحتوى غير اللائق",
"seed_after_download_complete": "الْبَذْرُ بَعْدَ اكْتِمَالِ التَّنْزِيلِ", "seed_after_download_complete": "التوزيع بعد اكتمال التنزيل",
"show_hidden_achievement_description": "إِظْهَارُ وَصْفِ الإِنْجَازَاتِ الْمَخْفِيَّةِ قَبْلَ فَتْحِهَا" "show_hidden_achievement_description": "عرض وصف الإنجازات المخفية قبل فتحها",
"account": "الحساب",
"no_users_blocked": "لا يوجد مستخدمون محظورون",
"subscription_active_until": "اشتراك Hydra Cloud نشط حتى {{date}}",
"manage_subscription": "إدارة الاشتراك",
"update_email": "تحديث البريد الإلكتروني",
"update_password": "تحديث كلمة المرور",
"current_email": "البريد الإلكتروني الحالي:",
"no_email_account": "لم تقم بتعيين بريد إلكتروني بعد",
"account_data_updated_successfully": "تم تحديث بيانات الحساب بنجاح",
"renew_subscription": "تجديد اشتراك Hydra Cloud",
"subscription_expired_at": "انتهى اشتراكك في {{date}}",
"no_subscription": "استمتع بـ Hydra بأفضل طريقة ممكنة",
"become_subscriber": "كن مشتركًا في Hydra Cloud",
"subscription_renew_cancelled": "تم تعطيل التجديد التلقائي",
"subscription_renews_on": "سيتم تجديد اشتراكك في {{date}}",
"bill_sent_until": "سيتم إرسال فاتورتك التالية حتى هذا اليوم"
}, },
"notifications": { "notifications": {
"download_complete": "اكْتِمَالُ التَّنْزِيلِ", "download_complete": "اكتمل التنزيل",
"game_ready_to_install": "{{title}} جَاهِزٌ لِلتَّثْبِيتِ", "game_ready_to_install": "{{title}} جاهز للتثبيت",
"repack_list_updated": "تَمَّ تَحْدِيثُ قَائِمَةِ الإِصْدَارَاتِ الْمُعَادَةِ تَغْلِيفُهَا", "repack_list_updated": م تحديث قائمة الحزم المعاد تعبئتها",
"repack_count_one": "{{count}} إِصْدَارٌ مُعَادٌ تَغْلِيفُهُ أُضِيفَ", "repack_count_one": "تمت إضافة {{count}} حزمة معاد تعبئتها",
"repack_count_other": "{{count}} إِصْدَارَاتٌ مُعَادَةٌ تَغْلِيفُهَا أُضِيفَتْ", "repack_count_other": "تمت إضافة {{count}} حزم معاد تعبئتها",
"new_update_available": "الْإِصْدَارُ {{version}} مَتَوَفِّرٌ", "new_update_available": "الإصدار {{version}} متوفر",
"restart_to_install_update": "أَعِدْ تَشْغِيلَ Hydra لِتَثْبِيتِ التَّحْدِيثِ", "restart_to_install_update": عد تشغيل Hydra لتثبيت التحديث",
"notification_achievement_unlocked_title": "تَمَّ فَتْحُ إِنْجَازٍ لِـ {{game}}", "notification_achievement_unlocked_title": م فتح إنجاز لـ {{game}}",
"notification_achievement_unlocked_body": "{{achievement}} وَ{{count}} أُخْرَى تَمَّ فَتْحُهَا" "notification_achievement_unlocked_body": "{{achievement}} و {{count}} آخرين تم فتحهم"
}, },
"system_tray": { "system_tray": {
"open": "فَتْحُ Hydra", "open": تح Hydra",
"quit": "الْخُرُوجُ" "quit": "خروج"
}, },
"game_card": { "game_card": {
"no_downloads": َا تَوْجَدُ تَنْزِيلَاتٌ مَتَوَفِّرَةٌ" "no_downloads": ا توجد تنزيلات متاحة"
}, },
"binary_not_found_modal": { "binary_not_found_modal": {
"title": "الْبَرَامِجُ غَيْرُ مُثَبَّتَةٍ", "title": "البرامج غير مثبتة",
"description": َمْ يُعْثَرْ عَلَى مَلَفَّاتٍ تَنْفِيذِيَّةٍ لِـ Wine أَوْ Lutris عَلَى نِظَامِكَ", "description": م يتم العثور على ملفات تشغيل Wine أو Lutris على نظامك",
"instructions": َحَقَّقْ مِنَ الطَّرِيقَةِ الصَّحِيحَةِ لِتَثْبِيتِ أَيٍّ مِنْهُمَا عَلَى تَوْزِيعَةِ Linux لَدَيْكَ لِتَعْمَلَ اللُّعْبَةُ بِشَكْلٍ طَبِيعِيٍّ" "instructions": حقق من الطريقة الصحيحة لتثبيت أي منها على توزيعة لينكس الخاصة بك حتى تعمل اللعبة بشكل طبيعي"
}, },
"modal": { "modal": {
"close": ِرُّ الإِغْلَاقِ" "close": ر الإغلاق"
}, },
"forms": { "forms": {
"toggle_password_visibility": َبْدِيلُ رُؤْيَةِ كَلِمَةِ الْمَرُورِ" "toggle_password_visibility": بديل رؤية كلمة المرور"
}, },
"user_profile": { "user_profile": {
"amount_hours": "{{amount}} سَاعَاتٌ", "amount_hours": "{{amount}} ساعات",
"amount_minutes": "{{amount}} دَقَائِقُ", "amount_minutes": "{{amount}} دقائق",
"last_time_played": "آخِرُ مَرَّةٍ لُعِبَتْ {{period}}", "last_time_played": "آخر تشغيل {{period}}",
"activity": "النَّشَاطُ الْأَخِيرُ", "activity": "النشاط الأخير",
"library": "الْمَكْتَبَةُ", "library": "المكتبة",
"total_play_time": ِجْمَالِيُّ وَقْتِ اللَّعِبِ", "total_play_time": جمالي وقت اللعب",
"no_recent_activity_title": "هَمَمْ... لَا شَيْءَ هُنَا", "no_recent_activity_title": "همم... لا شيء هنا",
"no_recent_activity_description": َمْ تَلْعَبْ أَيَّ أَلْعَابٍ مُؤَخَّرًا. حَانَ الْوَقْتُ لِتَغْيِيرِ ذَلِكَ!", "no_recent_activity_description": م تلعب أي ألعاب مؤخرًا. حان الوقت لتغيير ذلك!",
"display_name": "اسْمُ الْعَرْضِ", "display_name": "اسم العرض",
"saving": َارٍ الْحِفْظُ", "saving": ارٍ الحفظ",
"save": ِفْظٌ", "save": فظ",
"edit_profile": َحْرِيرُ الْمَلَفِّ الشَّخْصِيِّ", "edit_profile": عديل الملف الشخصي",
"saved_successfully": َمَّ الْحِفْظُ بِنَجَاحٍ", "saved_successfully": م الحفظ بنجاح",
"try_again": "الرَّجَاءُ الْمُحَاوَلَةُ مَرَّةً أُخْرَى", "try_again": "يرجى المحاولة مرة أخرى",
"sign_out_modal_title": "هَلْ أَنْتَ مُتَأَكِّدٌ؟", "sign_out_modal_title": "هل أنت متأكد؟",
"cancel": ِلْغَاءٌ", "cancel": لغاء",
"successfully_signed_out": َمَّ تَسْجِيلُ الْخُرُوجِ بِنَجَاحٍ", "successfully_signed_out": م تسجيل الخروج بنجاح",
"sign_out": َسْجِيلُ الْخُرُوجِ", "sign_out": سجيل الخروج",
"playing_for": "جَارِي اللَّعِبُ لِمُدَّةِ {{amount}}", "playing_for": "يلعب لمدة {{amount}}",
"sign_out_modal_text": َكْتَبَتُكَ مُرْتَبِطَةٌ بِحِسَابِكَ الْحَالِيِّ. عِنْدَ تَسْجِيلِ الْخُرُوجِ، لَنْ تَكُونَ مَكْتَبَتُكَ مَرْئِيَّةً بَعْدَ الْآنِ، وَلَنْ يَتِمَّ حِفْظُ أَيِّ تَقَدُّمٍ. هَلْ تُرِيدُ الْمُتَابَعَةَ مَعَ تَسْجِيلِ الْخُرُوجِ؟", "sign_out_modal_text": كتبتك مرتبطة بحسابك الحالي. عند تسجيل الخروج، لن تكون مكتبتك مرئية بعد الآن، ولن يتم حفظ أي تقدم. هل تتابع تسجيل الخروج؟",
"add_friends": ِضَافَةُ الْأَصْدِقَاءِ", "add_friends": ضافة أصدقاء",
"add": ِضَافَةٌ", "add": ضافة",
"friend_code": َمْزُ الصَّدِيقِ", "friend_code": مز الصديق",
"see_profile": "رُؤْيَةُ الْمَلَفِّ الشَّخْصِيِّ", "see_profile": "عرض الملف الشخصي",
"sending": َارٍ الْإِرْسَالُ", "sending": ارٍ الإرسال",
"friend_request_sent": َمَّ إِرْسَالُ طَلَبِ الصَّدَاقَةِ", "friend_request_sent": م إرسال طلب الصداقة",
"friends": "الْأَصْدِقَاءُ", "friends": "الأصدقاء",
"friends_list": َائِمَةُ الْأَصْدِقَاءِ", "friends_list": ائمة الأصدقاء",
"user_not_found": "الْمُسْتَخْدِمُ غَيْرُ مَوْجُودٍ", "user_not_found": "المستخدم غير موجود",
"block_user": َظْرُ الْمُسْتَخْدِمِ", "block_user": ظر المستخدم",
"add_friend": ِضَافَةُ صَدِيقٍ", "add_friend": ضافة صديق",
"request_sent": َمَّ إِرْسَالُ الطَّلَبِ", "request_sent": م إرسال الطلب",
"request_received": َمَّ اسْتِقْبَالُ الطَّلَبِ", "request_received": م استلام الطلب",
"accept_request": َبُولُ الطَّلَبِ", "accept_request": بول الطلب",
"ignore_request": َجَاهُلُ الطَّلَبِ", "ignore_request": جاهل الطلب",
"cancel_request": ِلْغَاءُ الطَّلَبِ", "cancel_request": لغاء الطلب",
"undo_friendship": ِلْغَاءُ الصَّدَاقَةِ", "undo_friendship": لغاء الصداقة",
"request_accepted": َمَّ قَبُولُ الطَّلَبِ", "request_accepted": م قبول الطلب",
"user_blocked_successfully": َمَّ حَظْرُ الْمُسْتَخْدِمِ بِنَجَاحٍ", "user_blocked_successfully": م حظر المستخدم بنجاح",
"user_block_modal_text": َيُؤَدِّي هَذَا إِلَى حَظْرِ {{displayName}}", "user_block_modal_text": يؤدي هذا إلى حظر {{displayName}}",
"blocked_users": "الْمُسْتَخْدِمُونَ الْمَحْظُورُونَ", "blocked_users": "المستخدمون المحظورون",
"unblock": ِزَالَةُ الْحَظْرِ", "unblock": لغاء الحظر",
"no_friends_added": َيْسَ لَدَيْكَ أَصْدِقَاءٌ مُضَافُونَ", "no_friends_added": يس لديك أصدقاء مضافون",
"pending": َيْدُ الْانْتِظَارِ", "pending": يد الانتظار",
"no_pending_invites": َيْسَ لَدَيْكَ دَعَوَاتٌ قَيْدُ الْانْتِظَارِ", "no_pending_invites": يس لديك دعوات معلقة",
"no_blocked_users": َيْسَ لَدَيْكَ مُسْتَخْدِمُونَ مَحْظُورُونَ", "no_blocked_users": يس لديك مستخدمون محظورون",
"friend_code_copied": َمَّ نَسْخُ رَمْزِ الصَّدِيقِ", "friend_code_copied": م نسخ رمز الصديق",
"undo_friendship_modal_text": َيُؤَدِّي هَذَا إِلَى إِلْغَاءِ صَدَاقَتِكَ مَعَ {{displayName}}", "undo_friendship_modal_text": يؤدي هذا إلى إلغاء صداقتك مع {{displayName}}",
"privacy_hint": ِتَعْدِيلِ مَنْ يُمْكِنُهُ رُؤْيَةُ هَذَا، اذْهَبْ إِلَى <0>الإعْدَادَاتِ</0>", "privacy_hint": ضبط من يمكنه رؤية هذا، انتقل إلى <0>الإعدادات</0>",
"locked_profile": "هَذَا الْمَلَفُّ الشَّخْصِيُّ خَاصٌّ", "locked_profile": "هذا الملف الشخصي خاص",
"image_process_failure": َشَلَ أَثْنَاءَ مُعَالَجَةِ الصُّورَةِ", "image_process_failure": شل معالجة الصورة",
"required_field": "هَذَا الْحَقْلُ مَطْلُوبٌ", "required_field": "هذا الحقل مطلوب",
"displayname_min_length": َجِبُ أَنْ يَكُونَ اسْمُ الْعَرْضِ عَلَى الْأَقَلِّ 3 أَحْرُفٍ", "displayname_min_length": جب أن يكون اسم العرض على الأقل 3 أحرف",
"displayname_max_length": َجِبُ أَنْ يَكُونَ اسْمُ الْعَرْضِ عَلَى الْأَكْثَرِ 50 حَرْفًا", "displayname_max_length": جب ألا يتجاوز اسم العرض 50 حرفًا",
"report_profile": "تَقْرِيرٌ عَنْ هَذَا الْمَلَفِّ الشَّخْصِيِّ", "report_profile": "الإبلاغ عن هذا الملف الشخصي",
"report_reason": ِمَاذَا تُقَدِّمُ تَقْرِيرًا عَنْ هَذَا الْمَلَفِّ الشَّخْصِيِّ؟", "report_reason": ماذا تقوم بالإبلاغ عن هذا الملف الشخصي؟",
"report_description": َعْلُومَاتٌ إِضَافِيَّةٌ", "report_description": علومات إضافية",
"report_description_placeholder": َعْلُومَاتٌ إِضَافِيَّةٌ", "report_description_placeholder": علومات إضافية",
"report": "تَقْرِيرٌ", "report": "الإبلاغ",
"report_reason_hate": ِطَابُ الْكُرْهِ", "report_reason_hate": طاب كراهية",
"report_reason_sexual_content": ُحْتَوًى جِنْسِيٌّ", "report_reason_sexual_content": حتوى جنسي",
"report_reason_violence": ُنْفٌ", "report_reason_violence": نف",
"report_reason_spam": "رَاسِلَةٌ عَشْوَائِيَّةٌ", "report_reason_spam": "بريد عشوائي",
"report_reason_other": "آخَرُ", "report_reason_other": "أخرى",
"profile_reported": َمَّ تَقْرِيرُ الْمَلَفِّ الشَّخْصِيِّ", "profile_reported": م الإبلاغ عن الملف الشخصي",
"your_friend_code": َمْزُ صَدِيقِكَ:", "your_friend_code": مز صديقك:",
"upload_banner": "رَفْعُ لَافِتَةٍ", "upload_banner": "تحميل بانر",
"uploading_banner": َارٍ رَفْعُ اللَّافِتَةِ...", "uploading_banner": ارٍ تحميل البانر...",
"background_image_updated": َمَّ تَحْدِيثُ صُورَةِ الْخَلْفِيَّةِ", "background_image_updated": م تحديث صورة الخلفية",
"stats": "الإحْصَائِيَّاتُ", "stats": "الإحصائيات",
"achievements": "الإِنْجَازَاتُ", "achievements": "إنجازات",
"games": "الْأَلْعَابُ", "games": "الألعاب",
"top_percentile": "الْأَفْضَلُ {{percentile}}%", "top_percentile": "ال{{percentile}}% الأعلى",
"ranking_updated_weekly": "التَّرْتِيبُ يُحَدَّثُ أُسْبُوعِيًّا", "ranking_updated_weekly": "يتم تحديث التصنيف أسبوعيًا",
"playing": "جَارِي اللَّعِبُ {{game}}", "playing": "يلعب {{game}}",
"achievements_unlocked": "الإِنْجَازَاتُ الْمَفْتُوحَةُ", "achievements_unlocked": "الإنجازات المفتوحة",
"earned_points": "النَّقَاطُ الْمَكْسُوبَةُ", "earned_points": "النقاط المكتسبة",
"show_achievements_on_profile": َرْضُ إِنْجَازَاتِكَ عَلَى مَلَفِّكَ الشَّخْصِيِّ", "show_achievements_on_profile": رض إنجازاتك على ملفك الشخصي",
"show_points_on_profile": َرْضُ النَّقَاطِ الْمَكْسُوبَةِ عَلَى مَلَفِّكَ الشَّخْصِيِّ" "show_points_on_profile": رض نقاطك المكتسبة على ملفك الشخصي"
}, },
"achievement": { "achievement": {
"achievement_unlocked": "إِنْجَازٌ مَفْتُوحٌ", "achievement_unlocked": "تم فتح الإنجاز",
"user_achievements": ِنْجَازَاتُ {{displayName}}", "user_achievements": نجازات {{displayName}}",
"your_achievements": ِنْجَازَاتُكَ", "your_achievements": نجازاتك",
"unlocked_at": َمَّ الْفَتْحُ فِي: {{date}}", "unlocked_at": م الفتح في: {{date}}",
"subscription_needed": َحْتَاجُ اشْتِرَاكُ Hydra Cloud لِرُؤْيَةِ هَذَا الْمُحْتَوَى", "subscription_needed": حتاج إلى اشتراك Hydra Cloud لرؤية هذا المحتوى",
"new_achievements_unlocked": َمَّ فَتْحُ {{achievementCount}} إِنْجَازَاتٍ جَدِيدَةٍ مِنْ {{gameCount}} أَلْعَابٍ", "new_achievements_unlocked": م فتح {{achievementCount}} إنجازات جديدة من {{gameCount}} ألعاب",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} إِنْجَازَاتٍ", "achievement_progress": "{{unlockedCount}}/{{totalCount}} إنجازات",
"achievements_unlocked_for_game": َمَّ فَتْحُ {{achievementCount}} إِنْجَازَاتٍ جَدِيدَةٍ لِـ {{gameTitle}}", "achievements_unlocked_for_game": م فتح {{achievementCount}} إنجازات جديدة لـ {{gameTitle}}",
"hidden_achievement_tooltip": "هَذَا إِنْجَازٌ مَخْفِيٌّ", "hidden_achievement_tooltip": "هذا إنجاز مخفي",
"achievement_earn_points": "اكْسِبْ {{points}} نَقَاطًا بِهَذَا الإِنْجَازِ", "achievement_earn_points": "اكسب {{points}} نقطة مع هذا الإنجاز",
"earned_points": "النَّقَاطُ الْمَكْسُوبَةُ:", "earned_points": "النقاط المكتسبة:",
"available_points": "النَّقَاطُ الْمُتَوَفِّرَةُ:", "available_points": "النقاط المتاحة:",
"how_to_earn_achievements_points": َيْفَ تَكْسِبُ نَقَاطَ الإِنْجَازَاتِ؟" "how_to_earn_achievements_points": يفية كسب نقاط الإنجازات؟"
}, },
"hydra_cloud": { "hydra_cloud": {
"subscription_tour_title": "اشْتِرَاكُ Hydra Cloud", "subscription_tour_title": "اشتراك Hydra Cloud",
"subscribe_now": "اشْتَرِكِ الْآنَ", "subscribe_now": "اشترك الآن",
"cloud_saving": ِفْظٌ سَحَابِيٌّ", "cloud_saving": فظ سحابي",
"cloud_achievements": "حِفْظُ إِنْجَازَاتِكَ فِي السَّحَابَةِ", "cloud_achievements": "احفظ إنجازاتك على السحابة",
"animated_profile_picture": ُورُ الْمَلَفِّ الشَّخْصِيِّ الْمُتَحَرِّكَةِ", "animated_profile_picture": ورة ملف شخصي متحركة",
"premium_support": "الدَّعْمُ الْمُتَقَدِّمُ", "premium_support": "دعم ممتاز",
"show_and_compare_achievements": "عَرْضٌ وَمُقَارَنَةُ إِنْجَازَاتِكَ مَعَ مُسْتَخْدِمِينَ آخَرِينَ", "show_and_compare_achievements": "اعرض وقارن إنجازاتك مع المستخدمين الآخرين",
"animated_profile_banner": "لَافِتَةُ الْمَلَفِّ الشَّخْصِيِّ الْمُتَحَرِّكَةِ", "animated_profile_banner": "بانر ملف شخصي متحرك",
"hydra_cloud": "Hydra Cloud", "hydra_cloud": "Hydra Cloud",
"hydra_cloud_feature_found": َقَدْ اكْتَشَفْتَ مِيزَةً مِنْ Hydra Cloud!", "hydra_cloud_feature_found": قد اكتشفت ميزة Hydra Cloud!",
"learn_more": "تَعَلَّمْ أَكْثَرَ" "learn_more": "معرفة المزيد"
} }
} }

View file

@ -175,7 +175,16 @@
"backup_from": "Copia de seguridad de {{date}}", "backup_from": "Copia de seguridad de {{date}}",
"custom_backup_location_set": "Se configuró la carpeta de copia de seguridad", "custom_backup_location_set": "Se configuró la carpeta de copia de seguridad",
"clear": "Limpiar", "clear": "Limpiar",
"no_directory_selected": "No se seleccionó un directorio" "no_directory_selected": "No se seleccionó un directorio",
"launch_options": "Opciones de Inicio",
"launch_options_description": "Los usuarios avanzados pueden introducir sus propias modificaciones de opciones de inicio (característica experimental)",
"launch_options_placeholder": "Sin parámetro específicado",
"no_write_permission": "No se puede descargar en este directorio. Presiona aquí para aprender más.",
"reset_achievements": "Reiniciar logros",
"reset_achievements_description": "Esto reiniciará todos los logros de {{game}}",
"reset_achievements_title": "¿Estás seguro?",
"reset_achievements_success": "Logros reiniciados exitosamente",
"reset_achievements_error": "Se produjo un error al reiniciar los logros"
}, },
"activation": { "activation": {
"title": "Activar Hydra", "title": "Activar Hydra",
@ -271,7 +280,23 @@
"launch_minimized": "Iniciar Hydra minimizado", "launch_minimized": "Iniciar Hydra minimizado",
"disable_nsfw_alert": "Desactivar alerta NSFW", "disable_nsfw_alert": "Desactivar alerta NSFW",
"seed_after_download_complete": "Realizar seeding después de que se completa la descarga", "seed_after_download_complete": "Realizar seeding después de que se completa la descarga",
"show_hidden_achievement_description": "Ocultar descripción de logros ocultos antes de desbloquearlos" "show_hidden_achievement_description": "Ocultar descripción de logros ocultos antes de desbloquearlos",
"account": "Cuenta",
"account_data_updated_successfully": "Datos de la cuenta actualizados",
"bill_sent_until": "Tú próxima factura se enviará el {{date}}",
"current_email": "Correo actual:",
"manage_subscription": "Gestionar suscripción",
"no_email_account": "No has configurado un correo aún",
"no_subscription": "Disfruta Hydra de la mejor manera",
"no_users_blocked": "No tienes usuarios bloqueados",
"notifications": "Notificaciones",
"renew_subscription": "Renovar Hydra Cloud",
"subscription_active_until": "Tu Hydra Cloud está activa hasta {{date}}",
"subscription_expired_at": "Tú suscripción expiró el {{date}}",
"subscription_renew_cancelled": "Está desactivada la renovación automática",
"subscription_renews_on": "Tú suscripción se renueva el {{date}}",
"update_email": "Actualizar correo",
"update_password": "Actualizar contraseña"
}, },
"notifications": { "notifications": {
"download_complete": "Descarga completada", "download_complete": "Descarga completada",

View file

@ -3,15 +3,14 @@ import { db, levelKeys } from "@main/level";
import type { UserPreferences } from "@types"; import type { UserPreferences } from "@types";
export const getDownloadsPath = async () => { export const getDownloadsPath = async () => {
const userPreferences = await db.get<string, UserPreferences>( const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ {
valueEncoding: "json", valueEncoding: "json",
} }
); );
if (userPreferences && userPreferences.downloadsPath) if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
return userPreferences.downloadsPath;
return defaultDownloadsPath; return defaultDownloadsPath;
}; };

View file

@ -10,7 +10,7 @@ const publishNewRepacksNotification = async (
) => { ) => {
if (newRepacksCount < 1) return; if (newRepacksCount < 1) return;
const userPreferences = await db.get<string, UserPreferences>( const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ {
valueEncoding: "json", valueEncoding: "json",

View file

@ -5,11 +5,11 @@ import type { UserPreferences } from "@types";
const getUserPreferences = async () => const getUserPreferences = async () =>
db db
.get<string, UserPreferences>(levelKeys.userPreferences, { .get<string, UserPreferences | null>(levelKeys.userPreferences, {
valueEncoding: "json", valueEncoding: "json",
}) })
.then((userPreferences) => { .then((userPreferences) => {
if (userPreferences.realDebridApiToken) { if (userPreferences?.realDebridApiToken) {
userPreferences.realDebridApiToken = Crypto.decrypt( userPreferences.realDebridApiToken = Crypto.decrypt(
userPreferences.realDebridApiToken userPreferences.realDebridApiToken
); );

View file

@ -3,13 +3,14 @@ import { registerEvent } from "../register-event";
import type { UserPreferences } from "@types"; import type { UserPreferences } from "@types";
import i18next from "i18next"; import i18next from "i18next";
import { db, levelKeys } from "@main/level"; import { db, levelKeys } from "@main/level";
import { Crypto } from "@main/services";
import { patchUserProfile } from "../profile/update-profile"; import { patchUserProfile } from "../profile/update-profile";
const updateUserPreferences = async ( const updateUserPreferences = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
preferences: Partial<UserPreferences> preferences: Partial<UserPreferences>
) => { ) => {
const userPreferences = await db.get<string, UserPreferences>( const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ valueEncoding: "json" } { valueEncoding: "json" }
); );
@ -23,6 +24,16 @@ const updateUserPreferences = async (
patchUserProfile({ language: preferences.language }).catch(() => {}); patchUserProfile({ language: preferences.language }).catch(() => {});
} }
if (preferences.realDebridApiToken) {
preferences.realDebridApiToken = Crypto.encrypt(
preferences.realDebridApiToken
);
}
if (!preferences.downloadsPath) {
preferences.downloadsPath = null;
}
await db.put<string, UserPreferences>( await db.put<string, UserPreferences>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ {

View file

@ -10,7 +10,7 @@ const getComparedUnlockedAchievements = async (
shop: GameShop, shop: GameShop,
userId: string userId: string
) => { ) => {
const userPreferences = await db.get<string, UserPreferences>( const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ {
valueEncoding: "json", valueEncoding: "json",
@ -25,7 +25,7 @@ const getComparedUnlockedAchievements = async (
{ {
shop, shop,
objectId, objectId,
language: userPreferences?.language || "en", language: userPreferences?.language ?? "en",
} }
).then((achievements) => { ).then((achievements) => {
const sortedAchievements = achievements.achievements const sortedAchievements = achievements.achievements

View file

@ -12,7 +12,7 @@ export const getUnlockedAchievements = async (
levelKeys.game(shop, objectId) levelKeys.game(shop, objectId)
); );
const userPreferences = await db.get<string, UserPreferences>( const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ {
valueEncoding: "json", valueEncoding: "json",

View file

@ -27,7 +27,7 @@ export const loadState = async () => {
valueEncoding: "json", valueEncoding: "json",
}); });
return db.get<string, UserPreferences>(levelKeys.userPreferences, { return db.get<string, UserPreferences | null>(levelKeys.userPreferences, {
valueEncoding: "json", valueEncoding: "json",
}); });
}); });
@ -114,24 +114,29 @@ const migrateFromSqlite = async () => {
if (userPreferences.length > 0) { if (userPreferences.length > 0) {
const { realDebridApiToken, ...rest } = userPreferences[0]; const { realDebridApiToken, ...rest } = userPreferences[0];
await db.put(levelKeys.userPreferences, { await db.put<string, UserPreferences>(
...rest, levelKeys.userPreferences,
realDebridApiToken: realDebridApiToken {
? Crypto.encrypt(realDebridApiToken) ...rest,
: null, realDebridApiToken: realDebridApiToken
preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1, ? Crypto.encrypt(realDebridApiToken)
runAtStartup: rest.runAtStartup === 1, : null,
startMinimized: rest.startMinimized === 1, preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1,
disableNsfwAlert: rest.disableNsfwAlert === 1, runAtStartup: rest.runAtStartup === 1,
seedAfterDownloadComplete: rest.seedAfterDownloadComplete === 1, startMinimized: rest.startMinimized === 1,
showHiddenAchievementsDescription: disableNsfwAlert: rest.disableNsfwAlert === 1,
rest.showHiddenAchievementsDescription === 1, seedAfterDownloadComplete: rest.seedAfterDownloadComplete === 1,
downloadNotificationsEnabled: rest.downloadNotificationsEnabled === 1, showHiddenAchievementsDescription:
repackUpdatesNotificationsEnabled: rest.showHiddenAchievementsDescription === 1,
rest.repackUpdatesNotificationsEnabled === 1, downloadNotificationsEnabled:
achievementNotificationsEnabled: rest.downloadNotificationsEnabled === 1,
rest.achievementNotificationsEnabled === 1, repackUpdatesNotificationsEnabled:
}); rest.repackUpdatesNotificationsEnabled === 1,
achievementNotificationsEnabled:
rest.achievementNotificationsEnabled === 1,
},
{ valueEncoding: "json" }
);
if (rest.language) { if (rest.language) {
await db.put(levelKeys.language, rest.language); await db.put(levelKeys.language, rest.language);

View file

@ -27,17 +27,20 @@ export class DownloadManager {
) { ) {
PythonRPC.spawn( PythonRPC.spawn(
download?.status === "active" download?.status === "active"
? await this.getDownloadPayload(download).catch(() => undefined) ? await this.getDownloadPayload(download).catch((err) => {
logger.error("Error getting download payload", err);
return undefined;
})
: undefined, : undefined,
downloadsToSeed?.map((download) => ({ downloadsToSeed?.map((download) => ({
game_id: `${download.shop}-${download.objectId}`, game_id: levelKeys.game(download.shop, download.objectId),
url: download.uri, url: download.uri,
save_path: download.downloadPath, save_path: download.downloadPath,
})) }))
); );
if (download) { if (download) {
this.downloadingGameId = `${download.shop}-${download.objectId}`; this.downloadingGameId = levelKeys.game(download.shop, download.objectId);
} }
} }
@ -280,7 +283,7 @@ export class DownloadManager {
return { return {
action: "start", action: "start",
game_id: downloadId, game_id: downloadId,
url: `https://pixeldrain.com/api/file/${id}?download`, url: `https://cdn.pd5-gamedriveorg.workers.dev/api/file/${id}`,
save_path: download.downloadPath, save_path: download.downloadPath,
out: name, out: name,
}; };

View file

@ -29,6 +29,7 @@ import { downloadSourcesWorker } from "./workers";
import { downloadSourcesTable } from "./dexie"; import { downloadSourcesTable } from "./dexie";
import { useSubscription } from "./hooks/use-subscription"; import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { SPACING_UNIT } from "./theme.css";
export interface AppProps { export interface AppProps {
children: React.ReactNode; children: React.ReactNode;
@ -212,22 +213,22 @@ export function App() {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const channel = new BroadcastChannel(`download_sources:sync:${id}`); const channel = new BroadcastChannel(`download_sources:sync:${id}`);
channel.onmessage = (event: MessageEvent<number>) => { channel.onmessage = async (event: MessageEvent<number>) => {
const newRepacksCount = event.data; const newRepacksCount = event.data;
window.electron.publishNewRepacksNotification(newRepacksCount); window.electron.publishNewRepacksNotification(newRepacksCount);
updateRepacks(); updateRepacks();
downloadSourcesTable.toArray().then((downloadSources) => { const downloadSources = await downloadSourcesTable.toArray();
downloadSources
.filter((source) => !source.fingerprint) downloadSources
.forEach((downloadSource) => { .filter((source) => !source.fingerprint)
window.electron .forEach(async (downloadSource) => {
.putDownloadSource(downloadSource.objectIds) const { fingerprint } = await window.electron.putDownloadSource(
.then(({ fingerprint }) => { downloadSource.objectIds
downloadSourcesTable.update(downloadSource.id, { fingerprint }); );
});
}); downloadSourcesTable.update(downloadSource.id, { fingerprint });
}); });
}; };
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
@ -255,7 +256,7 @@ export function App() {
return ( return (
<> <>
{/* {window.electron.platform === "win32" && ( {window.electron.platform === "win32" && (
<div className={styles.titleBar}> <div className={styles.titleBar}>
<h4> <h4>
Hydra Hydra
@ -264,22 +265,25 @@ export function App() {
)} )}
</h4> </h4>
</div> </div>
)} */} )}
<div className={styles.titleBar}>
<h4>
Hydra
{hasActiveSubscription && (
<span className={styles.cloudText}> Cloud</span>
)}
</h4>
</div>
<Toast <div
visible={toast.visible} style={{
message={toast.message} position: "absolute",
type={toast.type} bottom: `${26 + SPACING_UNIT * 2}px`,
onClose={handleToastClose} right: "16px",
/> maxWidth: "420px",
width: "420px",
}}
>
<Toast
visible={toast.visible}
title={toast.title}
message={toast.message}
type={toast.type}
onClose={handleToastClose}
/>
</div>
<HydraCloudModal <HydraCloudModal
visible={isHydraCloudModalVisible} visible={isHydraCloudModalVisible}

View file

@ -1,152 +0,0 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const sidebar = recipe({
base: {
backgroundColor: vars.color.darkBackground,
color: vars.color.muted,
flexDirection: "column",
display: "flex",
transition: "opacity ease 0.2s",
borderRight: `solid 1px ${vars.color.border}`,
position: "relative",
overflow: "hidden",
justifyContent: "space-between",
},
variants: {
resizing: {
true: {
opacity: vars.opacity.active,
pointerEvents: "none",
},
},
darwin: {
true: {
paddingTop: `${SPACING_UNIT * 6}px`,
},
false: {
paddingTop: `${SPACING_UNIT}px`,
},
},
},
});
export const content = style({
display: "flex",
flexDirection: "column",
padding: `${SPACING_UNIT * 2}px`,
gap: `${SPACING_UNIT * 2}px`,
width: "100%",
overflow: "auto",
});
export const handle = style({
width: "5px",
height: "100%",
cursor: "col-resize",
position: "absolute",
right: "0",
});
export const menu = style({
listStyle: "none",
padding: "0",
margin: "0",
gap: `${SPACING_UNIT / 2}px`,
display: "flex",
flexDirection: "column",
overflow: "hidden",
});
export const menuItem = recipe({
base: {
transition: "all ease 0.1s",
cursor: "pointer",
textWrap: "nowrap",
display: "flex",
color: vars.color.muted,
borderRadius: "4px",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
},
variants: {
active: {
true: {
backgroundColor: "rgba(255, 255, 255, 0.1)",
},
},
muted: {
true: {
opacity: vars.opacity.disabled,
":hover": {
opacity: "1",
},
},
},
},
});
export const menuItemButton = style({
color: "inherit",
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
cursor: "pointer",
overflow: "hidden",
width: "100%",
padding: `9px ${SPACING_UNIT}px`,
});
export const menuItemButtonLabel = style({
textOverflow: "ellipsis",
overflow: "hidden",
});
export const gameIcon = style({
width: "20px",
height: "20px",
minWidth: "20px",
minHeight: "20px",
borderRadius: "4px",
backgroundSize: "cover",
});
export const sectionTitle = style({
textTransform: "uppercase",
fontWeight: "bold",
});
export const section = style({
gap: `${SPACING_UNIT * 2}px`,
display: "flex",
flexDirection: "column",
paddingBottom: `${SPACING_UNIT}px`,
});
export const helpButton = style({
color: vars.color.muted,
padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`,
gap: "9px",
display: "flex",
alignItems: "center",
cursor: "pointer",
borderTop: `solid 1px ${vars.color.border}`,
transition: "background-color ease 0.1s",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const helpButtonIcon = style({
background: "linear-gradient(0deg, #16B195 50%, #3E62C0 100%)",
width: "24px",
height: "24px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
borderRadius: "50%",
});

View file

@ -0,0 +1,136 @@
@use "../../scss/globals.scss";
.sidebar {
background-color: globals.$dark-background-color;
color: globals.$muted-color;
flex-direction: column;
display: flex;
transition: opacity ease 0.2s;
border-right: solid 1px globals.$border-color;
position: relative;
overflow: hidden;
padding-top: globals.$spacing-unit;
&--resizing {
opacity: globals.$active-opacity;
pointer-events: none;
}
&--darwin {
padding-top: calc(globals.$spacing-unit * 6);
}
&__content {
display: flex;
flex-direction: column;
padding: calc(globals.$spacing-unit * 2);
gap: calc(globals.$spacing-unit * 2);
width: 100%;
overflow: auto;
}
&__handle {
width: 5px;
height: 100%;
cursor: col-resize;
position: absolute;
right: 0;
}
&__menu {
list-style: none;
padding: 0;
margin: 0;
gap: calc(globals.$spacing-unit / 2);
display: flex;
flex-direction: column;
overflow: hidden;
}
&__menu-item {
transition: all ease 0.1s;
cursor: pointer;
text-wrap: nowrap;
display: flex;
color: globals.$muted-color;
border-radius: 4px;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
}
&--active {
background-color: rgba(255, 255, 255, 0.1);
}
&--muted {
opacity: globals.$disabled-opacity;
&:hover {
opacity: 1;
}
}
}
&__menu-item-button {
color: inherit;
display: flex;
align-items: center;
gap: globals.$spacing-unit;
cursor: pointer;
overflow: hidden;
width: 100%;
padding: 9px globals.$spacing-unit;
}
&__menu-item-button-label {
text-overflow: ellipsis;
overflow: hidden;
}
&__game-icon {
width: 20px;
height: 20px;
min-width: 20px;
min-height: 20px;
border-radius: 4px;
background-size: cover;
}
&__section-title {
text-transform: uppercase;
font-weight: bold;
}
&__section {
gap: calc(globals.$spacing-unit * 2);
display: flex;
flex-direction: column;
padding-bottom: globals.$spacing-unit;
}
&__help-button {
color: globals.$muted-color;
padding: globals.$spacing-unit calc(globals.$spacing-unit * 2);
gap: 9px;
display: flex;
align-items: center;
cursor: pointer;
border-top: solid 1px globals.$border-color;
transition: background-color ease 0.1s;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
}
}
&__help-button-icon {
background: linear-gradient(0deg, #16b195 50%, #3e62c0 100%);
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
border-radius: 50%;
}
}

View file

@ -14,12 +14,14 @@ import {
import { routes } from "./routes"; import { routes } from "./routes";
import * as styles from "./sidebar.css"; import "./sidebar.scss";
import { buildGameDetailsPath } from "@renderer/helpers"; import { buildGameDetailsPath } from "@renderer/helpers";
import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { SidebarProfile } from "./sidebar-profile"; import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es"; import { sortBy } from "lodash-es";
import cn from "classnames";
import { CommentDiscussionIcon } from "@primer/octicons-react"; import { CommentDiscussionIcon } from "@primer/octicons-react";
const SIDEBAR_MIN_WIDTH = 200; const SIDEBAR_MIN_WIDTH = 200;
@ -168,9 +170,9 @@ export function Sidebar() {
return ( return (
<aside <aside
ref={sidebarRef} ref={sidebarRef}
className={styles.sidebar({ className={cn("sidebar", {
resizing: isResizing, "sidebar--resizing": isResizing,
darwin: window.electron.platform === "darwin", "sidebar--darwin": window.electron.platform === "darwin",
})} })}
style={{ style={{
width: sidebarWidth, width: sidebarWidth,
@ -179,23 +181,28 @@ export function Sidebar() {
}} }}
> >
<div <div
style={{ display: "flex", flexDirection: "column", overflow: "hidden" }} style={{
display: "flex",
flexDirection: "column",
overflow: "hidden",
flex: 1,
}}
> >
<SidebarProfile /> <SidebarProfile />
<div className={styles.content}> <div className="sidebar__content">
<section className={styles.section}> <section className="sidebar__section">
<ul className={styles.menu}> <ul className="sidebar__menu">
{routes.map(({ nameKey, path, render }) => ( {routes.map(({ nameKey, path, render }) => (
<li <li
key={nameKey} key={nameKey}
className={styles.menuItem({ className={cn("sidebar__menu-item", {
active: location.pathname === path, "sidebar__menu-item--active": location.pathname === path,
})} })}
> >
<button <button
type="button" type="button"
className={styles.menuItemButton} className="sidebar__menu-item-button"
onClick={() => handleSidebarItemClick(path)} onClick={() => handleSidebarItemClick(path)}
> >
{render()} {render()}
@ -206,8 +213,8 @@ export function Sidebar() {
</ul> </ul>
</section> </section>
<section className={styles.section}> <section className="sidebar__section">
<small className={styles.sectionTitle}>{t("my_library")}</small> <small className="sidebar__section-title">{t("my_library")}</small>
<TextField <TextField
ref={filterRef} ref={filterRef}
@ -216,34 +223,35 @@ export function Sidebar() {
theme="dark" theme="dark"
/> />
<ul className={styles.menu}> <ul className="sidebar__menu">
{filteredLibrary.map((game) => ( {filteredLibrary.map((game) => (
<li <li
key={`${game.shop}-${game.objectId}`} key={game.id}
className={styles.menuItem({ className={cn("sidebar__menu-item", {
active: "sidebar__menu-item--active":
location.pathname === location.pathname ===
`/game/${game.shop}/${game.objectId}`, `/game/${game.shop}/${game.objectId}`,
muted: game.download?.status === "removed", "sidebar__menu-item--muted":
game.download?.status === "removed",
})} })}
> >
<button <button
type="button" type="button"
className={styles.menuItemButton} className="sidebar__menu-item-button"
onClick={(event) => handleSidebarGameClick(event, game)} onClick={(event) => handleSidebarGameClick(event, game)}
> >
{game.iconUrl ? ( {game.iconUrl ? (
<img <img
className={styles.gameIcon} className="sidebar__game-icon"
src={game.iconUrl} src={game.iconUrl}
alt={game.title} alt={game.title}
loading="lazy" loading="lazy"
/> />
) : ( ) : (
<SteamLogo className={styles.gameIcon} /> <SteamLogo className="sidebar__game-icon" />
)} )}
<span className={styles.menuItemButtonLabel}> <span className="sidebar__menu-item-button-label">
{getGameTitle(game)} {getGameTitle(game)}
</span> </span>
</button> </button>
@ -257,10 +265,10 @@ export function Sidebar() {
{hasActiveSubscription && ( {hasActiveSubscription && (
<button <button
type="button" type="button"
className={styles.helpButton} className="sidebar__help-button"
data-open-support-chat data-open-support-chat
> >
<div className={styles.helpButtonIcon}> <div className="sidebar__help-button-icon">
<CommentDiscussionIcon size={14} /> <CommentDiscussionIcon size={14} />
</div> </div>
<span>{t("need_help")}</span> <span>{t("need_help")}</span>
@ -269,7 +277,7 @@ export function Sidebar() {
<button <button
type="button" type="button"
className={styles.handle} className="sidebar__handle"
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
/> />
</aside> </aside>

View file

@ -1,87 +0,0 @@
import { keyframes, style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
import { recipe } from "@vanilla-extract/recipes";
const TOAST_HEIGHT = 80;
export const slideIn = keyframes({
"0%": { transform: `translateY(${TOAST_HEIGHT + SPACING_UNIT * 2}px)` },
"100%": { transform: "translateY(0)" },
});
export const slideOut = keyframes({
"0%": { transform: `translateY(0)` },
"100%": { transform: `translateY(${TOAST_HEIGHT + SPACING_UNIT * 2}px)` },
});
export const toast = recipe({
base: {
animationDuration: "0.2s",
animationTimingFunction: "ease-in-out",
maxHeight: TOAST_HEIGHT,
position: "fixed",
backgroundColor: vars.color.background,
borderRadius: "4px",
border: `solid 1px ${vars.color.border}`,
right: `${SPACING_UNIT * 2}px`,
/* Bottom panel height + 16px */
bottom: `${26 + SPACING_UNIT * 2}px`,
overflow: "hidden",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
zIndex: vars.zIndex.toast,
maxWidth: "500px",
},
variants: {
closing: {
true: {
animationName: slideOut,
transform: `translateY(${TOAST_HEIGHT + SPACING_UNIT * 2}px)`,
},
false: {
animationName: slideIn,
transform: `translateY(0)`,
},
},
},
});
export const toastContent = style({
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
justifyContent: "center",
alignItems: "center",
});
export const progress = style({
width: "100%",
height: "5px",
"::-webkit-progress-bar": {
backgroundColor: vars.color.darkBackground,
},
"::-webkit-progress-value": {
backgroundColor: vars.color.muted,
},
});
export const closeButton = style({
color: vars.color.body,
cursor: "pointer",
padding: "0",
margin: "0",
});
export const successIcon = style({
color: vars.color.success,
});
export const errorIcon = style({
color: vars.color.danger,
});
export const warningIcon = style({
color: vars.color.warning,
});

View file

@ -0,0 +1,85 @@
@use "../../scss/globals.scss";
.toast {
animation-duration: 0.2s;
animation-timing-function: ease-in-out;
position: absolute;
background-color: globals.$dark-background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
right: 0;
bottom: 0;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
z-index: globals.$toast-z-index;
max-width: 420px;
animation-name: enter;
transform: translateY(0);
&--closing {
animation-name: exit;
transform: translateY(100%);
}
&__content {
display: flex;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 2);
justify-content: center;
align-items: center;
}
&__progress {
width: 100%;
height: 5px;
&::-webkit-progress-bar {
background-color: globals.$dark-background-color;
}
&::-webkit-progress-value {
background-color: globals.$muted-color;
}
}
&__close-button {
color: globals.$body-color;
cursor: pointer;
padding: 0;
margin: 0;
transition: color 0.2s ease-in-out;
&:hover {
color: globals.$muted-color;
}
}
&__icon {
&--success {
color: globals.$success-color;
}
&--error {
color: globals.$danger-color;
}
&--warning {
color: globals.$warning-color;
}
}
}
@keyframes enter {
0% {
opacity: 0;
transform: translateY(100%);
}
}
@keyframes exit {
0% {
opacity: 1;
transform: translateY(0);
}
}

View file

@ -6,19 +6,28 @@ import {
XIcon, XIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import * as styles from "./toast.css"; import "./toast.scss";
import { SPACING_UNIT } from "@renderer/theme.css"; import cn from "classnames";
export interface ToastProps { export interface ToastProps {
visible: boolean; visible: boolean;
message: string; title: string;
message?: string;
type: "success" | "error" | "warning"; type: "success" | "error" | "warning";
duration?: number;
onClose: () => void; onClose: () => void;
} }
const INITIAL_PROGRESS = 100; const INITIAL_PROGRESS = 100;
export function Toast({ visible, message, type, onClose }: ToastProps) { export function Toast({
visible,
title,
message,
type,
duration = 2500,
onClose,
}: Readonly<ToastProps>) {
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [progress, setProgress] = useState(INITIAL_PROGRESS); const [progress, setProgress] = useState(INITIAL_PROGRESS);
@ -31,7 +40,7 @@ export function Toast({ visible, message, type, onClose }: ToastProps) {
closingAnimation.current = requestAnimationFrame( closingAnimation.current = requestAnimationFrame(
function animateClosing(time) { function animateClosing(time) {
if (time - zero <= 200) { if (time - zero <= 150) {
closingAnimation.current = requestAnimationFrame(animateClosing); closingAnimation.current = requestAnimationFrame(animateClosing);
} else { } else {
onClose(); onClose();
@ -43,17 +52,13 @@ export function Toast({ visible, message, type, onClose }: ToastProps) {
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
const zero = performance.now(); const zero = performance.now();
progressAnimation.current = requestAnimationFrame( progressAnimation.current = requestAnimationFrame(
function animateProgress(time) { function animateProgress(time) {
const elapsed = time - zero; const elapsed = time - zero;
const progress = Math.min(elapsed / duration, 1);
const progress = Math.min(elapsed / 2500, 1);
const currentValue = const currentValue =
INITIAL_PROGRESS + (0 - INITIAL_PROGRESS) * progress; INITIAL_PROGRESS + (0 - INITIAL_PROGRESS) * progress;
setProgress(currentValue); setProgress(currentValue);
if (progress < 1) { if (progress < 1) {
progressAnimation.current = requestAnimationFrame(animateProgress); progressAnimation.current = requestAnimationFrame(animateProgress);
} else { } else {
@ -70,37 +75,62 @@ export function Toast({ visible, message, type, onClose }: ToastProps) {
setIsClosing(false); setIsClosing(false);
}; };
} }
return () => {}; return () => {};
}, [startAnimateClosing, visible]); }, [startAnimateClosing, duration, visible]);
if (!visible) return null; if (!visible) return null;
return ( return (
<div className={styles.toast({ closing: isClosing })}> <div
<div className={styles.toastContent}> className={cn("toast", {
<div style={{ display: "flex", gap: `${SPACING_UNIT}px` }}> "toast--closing": isClosing,
{type === "success" && ( })}
<CheckCircleFillIcon className={styles.successIcon} /> >
)} <div className="toast__content">
<div
{type === "error" && <XCircleFillIcon className={styles.errorIcon} />} style={{
display: "flex",
{type === "warning" && <AlertIcon className={styles.warningIcon} />} gap: `8px`,
<span style={{ fontWeight: "bold" }}>{message}</span> flexDirection: "column",
</div> }}
<button
type="button"
className={styles.closeButton}
onClick={startAnimateClosing}
aria-label="Close toast"
> >
<XIcon /> <div
</button> style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: `8px`,
}}
>
{type === "success" && (
<CheckCircleFillIcon className="toast__icon--success" />
)}
{type === "error" && (
<XCircleFillIcon className="toast__icon--error" />
)}
{type === "warning" && (
<AlertIcon className="toast__icon--warning" />
)}
<span style={{ fontWeight: "bold", flex: 1 }}>{title}</span>
<button
type="button"
className="toast__close-button"
onClick={startAnimateClosing}
aria-label="Close toast"
>
<XIcon />
</button>
</div>
{message && <p>{message}</p>}
</div>
</div> </div>
<progress className={styles.progress} value={progress} max={100} /> <progress className="toast__progress" value={progress} max={100} />
</div> </div>
); );
} }

View file

@ -3,12 +3,14 @@ import type { PayloadAction } from "@reduxjs/toolkit";
import { ToastProps } from "@renderer/components/toast/toast"; import { ToastProps } from "@renderer/components/toast/toast";
export interface ToastState { export interface ToastState {
message: string; title: string;
message?: string;
type: ToastProps["type"]; type: ToastProps["type"];
visible: boolean; visible: boolean;
} }
const initialState: ToastState = { const initialState: ToastState = {
title: "",
message: "", message: "",
type: "success", type: "success",
visible: false, visible: false,
@ -19,6 +21,7 @@ export const toastSlice = createSlice({
initialState, initialState,
reducers: { reducers: {
showToast: (state, action: PayloadAction<Omit<ToastState, "visible">>) => { showToast: (state, action: PayloadAction<Omit<ToastState, "visible">>) => {
state.title = action.payload.title;
state.message = action.payload.message; state.message = action.payload.message;
state.type = action.payload.type; state.type = action.payload.type;
state.visible = true; state.visible = true;

View file

@ -6,9 +6,10 @@ export function useToast() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const showSuccessToast = useCallback( const showSuccessToast = useCallback(
(message: string) => { (title: string, message?: string) => {
dispatch( dispatch(
showToast({ showToast({
title,
message, message,
type: "success", type: "success",
}) })
@ -18,9 +19,10 @@ export function useToast() {
); );
const showErrorToast = useCallback( const showErrorToast = useCallback(
(message: string) => { (title: string, message?: string) => {
dispatch( dispatch(
showToast({ showToast({
title,
message, message,
type: "error", type: "error",
}) })
@ -30,9 +32,10 @@ export function useToast() {
); );
const showWarningToast = useCallback( const showWarningToast = useCallback(
(message: string) => { (title: string, message?: string) => {
dispatch( dispatch(
showToast({ showToast({
title,
message, message,
type: "warning", type: "warning",
}) })

View file

@ -1,43 +1,38 @@
import { useDate } from "@renderer/hooks"; import { useDate } from "@renderer/hooks";
import type { UserAchievement } from "@types"; import type { UserAchievement } from "@types";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as styles from "./achievements.css"; import "./achievements.scss";
import { EyeClosedIcon } from "@primer/octicons-react"; import { EyeClosedIcon } from "@primer/octicons-react";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react"; import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useSubscription } from "@renderer/hooks/use-subscription"; import { useSubscription } from "@renderer/hooks/use-subscription";
import { vars } from "@renderer/theme.css";
interface AchievementListProps { interface AchievementListProps {
achievements: UserAchievement[]; achievements: UserAchievement[];
} }
export function AchievementList({ achievements }: AchievementListProps) { export function AchievementList({
achievements,
}: Readonly<AchievementListProps>) {
const { t } = useTranslation("achievement"); const { t } = useTranslation("achievement");
const { showHydraCloudModal } = useSubscription(); const { showHydraCloudModal } = useSubscription();
const { formatDateTime } = useDate(); const { formatDateTime } = useDate();
return ( return (
<ul className={styles.list}> <ul className="achievements__list">
{achievements.map((achievement) => ( {achievements.map((achievement) => (
<li <li key={achievement.name} className="achievements__item">
key={achievement.name}
className={styles.listItem}
style={{ display: "flex" }}
>
<img <img
className={styles.listItemImage({ className={`achievements__item-image ${!achievement.unlocked ? "achievements__item-image--locked" : ""}`}
unlocked: achievement.unlocked,
})}
src={achievement.icon} src={achievement.icon}
alt={achievement.displayName} alt={achievement.displayName}
loading="lazy" loading="lazy"
/> />
<div style={{ flex: 1 }}> <div className="achievements__item-content">
<h4 style={{ display: "flex", alignItems: "center", gap: "4px" }}> <h4 className="achievements__item-title">
{achievement.hidden && ( {achievement.hidden && (
<span <span
style={{ display: "flex" }} className="achievements__item-hidden-icon"
title={t("hidden_achievement_tooltip")} title={t("hidden_achievement_tooltip")}
> >
<EyeClosedIcon size={12} /> <EyeClosedIcon size={12} />
@ -47,48 +42,36 @@ export function AchievementList({ achievements }: AchievementListProps) {
</h4> </h4>
<p>{achievement.description}</p> <p>{achievement.description}</p>
</div> </div>
<div
style={{ <div className="achievements__item-meta">
display: "flex",
flexDirection: "column",
gap: "8px",
alignItems: "flex-end",
}}
>
{achievement.points != undefined ? ( {achievement.points != undefined ? (
<div <div
style={{ display: "flex", alignItems: "center", gap: "4px" }} className="achievements__item-points"
title={t("achievement_earn_points", { title={t("achievement_earn_points", {
points: achievement.points, points: achievement.points,
})} })}
> >
<HydraIcon width={20} height={20} /> <HydraIcon className="achievements__item-points-icon" />
<p style={{ fontSize: "1.1em" }}>{achievement.points}</p> <p className="achievements__item-points-value">
{achievement.points}
</p>
</div> </div>
) : ( ) : (
<button <button
onClick={() => showHydraCloudModal("achievements")} onClick={() => showHydraCloudModal("achievements")}
style={{ className="achievements__item-points achievements__item-points--locked"
display: "flex", title={t("achievement_earn_points", { points: "???" })}
alignItems: "center",
gap: "4px",
cursor: "pointer",
color: vars.color.warning,
}}
title={t("achievement_earn_points", {
points: "???",
})}
> >
<HydraIcon width={20} height={20} /> <HydraIcon className="achievements__item-points-icon" />
<p style={{ fontSize: "1.1em" }}>???</p> <p className="achievements__item-points-value">???</p>
</button> </button>
)} )}
{achievement.unlockTime != null && ( {achievement.unlockTime != null && (
<div <div
className="achievements__item-unlock-time"
title={t("unlocked_at", { title={t("unlocked_at", {
date: formatDateTime(achievement.unlockTime), date: formatDateTime(achievement.unlockTime),
})} })}
style={{ whiteSpace: "nowrap", gap: "4px", display: "flex" }}
> >
<small>{formatDateTime(achievement.unlockTime)}</small> <small>{formatDateTime(achievement.unlockTime)}</small>
</div> </div>

View file

@ -0,0 +1,262 @@
@use "../../scss/globals.scss";
@use "sass:math";
$hero-height: 150px;
$logo-height: 100px;
$logo-max-width: 200px;
.achievements {
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
height: 100%;
transition: all ease 0.3s;
&__hero {
width: 100%;
height: $hero-height;
min-height: $hero-height;
display: flex;
flex-direction: column;
position: relative;
transition: all ease 0.2s;
&-content {
padding: globals.$spacing-unit * 2;
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
&-logo-backdrop {
width: 100%;
height: 100%;
position: absolute;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
&-image-skeleton {
height: 150px;
}
}
&__game-logo {
width: $logo-max-width;
height: $logo-height;
object-fit: contain;
transition: all ease 0.2s;
&:hover {
transform: scale(1.05);
}
}
&__container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: auto;
z-index: 1;
}
&__table-header {
width: 100%;
background-color: var(--color-dark-background);
transition: all ease 0.2s;
border-bottom: solid 1px var(--color-border);
position: sticky;
top: 0;
z-index: 1;
&--stuck {
box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8);
}
}
&__list {
list-style: none;
margin: 0;
display: flex;
flex-direction: column;
gap: globals.$spacing-unit * 2;
padding: globals.$spacing-unit * 2;
width: 100%;
background-color: var(--color-background);
}
&__item {
display: flex;
transition: all ease 0.1s;
color: var(--color-muted);
width: 100%;
overflow: hidden;
border-radius: 4px;
padding: globals.$spacing-unit globals.$spacing-unit;
gap: globals.$spacing-unit * 2;
align-items: center;
text-align: left;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
text-decoration: none;
}
&-image {
width: 54px;
height: 54px;
border-radius: 4px;
object-fit: cover;
&--locked {
filter: grayscale(100%);
}
}
&-content {
flex: 1;
}
&-title {
display: flex;
align-items: center;
gap: 4px;
}
&-hidden-icon {
display: flex;
color: var(--color-warning);
opacity: 0.8;
&:hover {
opacity: 1;
}
svg {
width: 12px;
height: 12px;
}
}
&-eye-closed {
width: 12px;
height: 12px;
color: globals.$warning-color;
scale: 4;
}
&-meta {
display: flex;
flex-direction: column;
gap: 8px;
}
&-points {
display: flex;
align-items: center;
gap: 4px;
margin-right: 4px;
font-weight: 600;
&--locked {
cursor: pointer;
color: var(--color-warning);
}
&-icon {
width: 18px;
height: 18px;
}
&-value {
font-size: 1.1em;
}
}
&-unlock-time {
white-space: nowrap;
gap: 4px;
display: flex;
}
&-compared {
display: grid;
grid-template-columns: 3fr 1fr 1fr;
&--no-owner {
grid-template-columns: 3fr 2fr;
}
}
&-main {
display: flex;
flex-direction: row;
align-items: center;
gap: globals.$spacing-unit;
}
&-status {
display: flex;
padding: globals.$spacing-unit;
justify-content: center;
&--unlocked {
white-space: nowrap;
flex-direction: row;
gap: globals.$spacing-unit;
padding: 0;
}
}
}
&__progress-bar {
width: 100%;
height: 8px;
transition: all ease 0.2s;
&::-webkit-progress-bar {
background-color: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
&::-webkit-progress-value {
background-color: var(--color-muted);
border-radius: 4px;
}
}
&__profile-avatar {
height: 54px;
width: 54px;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--color-background);
position: relative;
object-fit: cover;
&--small {
height: 32px;
width: 32px;
}
}
&__subscription-button {
text-decoration: none;
display: flex;
justify-content: center;
width: 100%;
gap: math.div(globals.$spacing-unit, 2);
color: var(--color-body);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}

View file

@ -1,109 +0,0 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const downloadTitleWrapper = style({
display: "flex",
alignItems: "center",
marginBottom: `${SPACING_UNIT}px`,
gap: `${SPACING_UNIT}px`,
});
export const downloadTitle = style({
fontWeight: "bold",
cursor: "pointer",
color: vars.color.body,
textAlign: "left",
fontSize: "16px",
display: "block",
":hover": {
textDecoration: "underline",
},
});
export const downloads = style({
width: "100%",
gap: `${SPACING_UNIT * 2}px`,
display: "flex",
flexDirection: "column",
margin: "0",
padding: "0",
marginTop: `${SPACING_UNIT}px`,
});
export const downloadCover = style({
width: "280px",
minWidth: "280px",
height: "auto",
borderRight: `solid 1px ${vars.color.border}`,
position: "relative",
zIndex: "1",
});
export const downloadCoverContent = style({
width: "100%",
height: "100%",
padding: `${SPACING_UNIT}px`,
display: "flex",
alignItems: "flex-end",
justifyContent: "flex-end",
});
export const downloadCoverBackdrop = style({
width: "100%",
height: "100%",
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.8) 5%, transparent 100%)",
display: "flex",
overflow: "hidden",
zIndex: "1",
});
export const downloadCoverImage = style({
width: "100%",
height: "100%",
position: "absolute",
zIndex: "-1",
});
export const download = style({
width: "100%",
backgroundColor: vars.color.background,
display: "flex",
borderRadius: "8px",
border: `solid 1px ${vars.color.border}`,
overflow: "hidden",
boxShadow: "0px 0px 5px 0px #000000",
transition: "all ease 0.2s",
height: "140px",
minHeight: "140px",
maxHeight: "140px",
});
export const downloadDetails = style({
display: "flex",
flexDirection: "column",
flex: "1",
justifyContent: "center",
gap: `${SPACING_UNIT / 2}px`,
fontSize: "14px",
});
export const downloadRightContent = style({
display: "flex",
padding: `${SPACING_UNIT * 2}px`,
flex: "1",
gap: `${SPACING_UNIT}px`,
background: "linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%)",
});
export const downloadActions = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
});
export const downloadGroup = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
});

View file

@ -0,0 +1,140 @@
@use "../../scss/globals.scss";
.download-group {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(globals.$spacing-unit * 2);
&-divider {
flex: 1;
background-color: globals.$border-color;
height: 1px;
}
&-count {
font-weight: 400;
}
}
&__title-wrapper {
display: flex;
align-items: center;
margin-bottom: globals.$spacing-unit;
gap: globals.$spacing-unit;
}
&__title {
font-weight: bold;
cursor: pointer;
color: globals.$body-color;
text-align: left;
font-size: 16px;
display: block;
&:hover {
text-decoration: underline;
}
}
&__downloads {
width: 100%;
gap: calc(globals.$spacing-unit * 2);
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
margin-top: globals.$spacing-unit;
}
&__item {
width: 100%;
background-color: globals.$background-color;
display: flex;
border-radius: 8px;
border: solid 1px globals.$border-color;
overflow: hidden;
box-shadow: 0px 0px 5px 0px #000000;
transition: all ease 0.2s;
height: 140px;
min-height: 140px;
max-height: 140px;
position: relative;
}
&__cover {
width: 280px;
min-width: 280px;
height: auto;
border-right: solid 1px globals.$border-color;
position: relative;
z-index: 1;
&-content {
width: 100%;
height: 100%;
padding: globals.$spacing-unit;
display: flex;
align-items: flex-end;
justify-content: flex-end;
}
&-backdrop {
width: 100%;
height: 100%;
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0.8) 5%,
transparent 100%
);
display: flex;
overflow: hidden;
z-index: 1;
}
&-image {
width: 100%;
height: 100%;
position: absolute;
z-index: -1;
}
}
&__right-content {
display: flex;
padding: calc(globals.$spacing-unit * 2);
flex: 1;
gap: globals.$spacing-unit;
background: linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%);
}
&__details {
display: flex;
flex-direction: column;
flex: 1;
justify-content: center;
gap: calc(globals.$spacing-unit / 2);
font-size: 14px;
}
&__actions {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
}
&__menu-button {
position: absolute;
top: 12px;
right: 12px;
border-radius: 50%;
border: none;
padding: 8px;
min-height: unset;
}
}

View file

@ -12,9 +12,8 @@ import { Downloader, formatBytes, steamUrlBuilder } from "@shared";
import { DOWNLOADER_NAME } from "@renderer/constants"; import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useDownload } from "@renderer/hooks"; import { useAppSelector, useDownload } from "@renderer/hooks";
import * as styles from "./download-group.css"; import "./download-group.scss";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
DropdownMenu, DropdownMenu,
@ -262,44 +261,26 @@ export function DownloadGroup({
if (!library.length) return null; if (!library.length) return null;
return ( return (
<div className={styles.downloadGroup}> <div className="download-group">
<div <div className="download-group__header">
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h2>{title}</h2> <h2>{title}</h2>
<div className="download-group__header-divider" />
<div <h3 className="download-group__header-count">{library.length}</h3>
style={{
flex: 1,
backgroundColor: vars.color.border,
height: "1px",
}}
/>
<h3 style={{ fontWeight: "400" }}>{library.length}</h3>
</div> </div>
<ul className={styles.downloads}> <ul className="download-group__downloads">
{library.map((game) => { {library.map((game) => {
return ( return (
<li <li key={game.id} className="download-group__item">
key={game.id} <div className="download-group__cover">
className={styles.download} <div className="download-group__cover-backdrop">
style={{ position: "relative" }}
>
<div className={styles.downloadCover}>
<div className={styles.downloadCoverBackdrop}>
<img <img
src={steamUrlBuilder.library(game.objectId)} src={steamUrlBuilder.library(game.objectId)}
className={styles.downloadCoverImage} className="download-group__cover-image"
alt={game.title} alt={game.title}
/> />
<div className={styles.downloadCoverContent}> <div className="download-group__cover-content">
{game.download?.downloader === Downloader.TorBox ? ( {game.download?.downloader === Downloader.TorBox ? (
<div <div
style={{ style={{
@ -327,12 +308,12 @@ export function DownloadGroup({
</div> </div>
</div> </div>
</div> </div>
<div className={styles.downloadRightContent}> <div className="download-group__right-content">
<div className={styles.downloadDetails}> <div className="download-group__details">
<div className={styles.downloadTitleWrapper}> <div className="download-group__title-wrapper">
<button <button
type="button" type="button"
className={styles.downloadTitle} className="download-group__title"
onClick={() => onClick={() =>
navigate( navigate(
buildGameDetailsPath({ buildGameDetailsPath({
@ -356,15 +337,7 @@ export function DownloadGroup({
sideOffset={-75} sideOffset={-75}
> >
<Button <Button
style={{ className="download-group__menu-button"
position: "absolute",
top: "12px",
right: "12px",
borderRadius: "50%",
border: "none",
padding: "8px",
minHeight: "unset",
}}
theme="outline" theme="outline"
> >
<ThreeBarsIcon /> <ThreeBarsIcon />

View file

@ -0,0 +1,15 @@
@use "../../../scss/globals.scss";
.hero-panel-playtime {
&__download-details {
gap: globals.$spacing-unit;
display: flex;
color: globals.$body-color;
align-items: center;
}
&__downloads-link {
color: globals.$body-color;
text-decoration: underline;
}
}

View file

@ -1,24 +1,18 @@
import { useContext, useEffect, useMemo, useState } from "react"; import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as styles from "./hero-panel.css";
import { formatDownloadProgress } from "@renderer/helpers"; import { formatDownloadProgress } from "@renderer/helpers";
import { useDate, useDownload, useFormat } from "@renderer/hooks"; import { useDate, useDownload, useFormat } from "@renderer/hooks";
import { Link } from "@renderer/components"; import { Link } from "@renderer/components";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import "./hero-panel-playtime.scss";
export function HeroPanelPlaytime() { export function HeroPanelPlaytime() {
const [lastTimePlayed, setLastTimePlayed] = useState(""); const [lastTimePlayed, setLastTimePlayed] = useState("");
const { game, isGameRunning } = useContext(gameDetailsContext); const { game, isGameRunning } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const { numberFormatter } = useFormat(); const { numberFormatter } = useFormat();
const { progress, lastPacket } = useDownload(); const { progress, lastPacket } = useDownload();
const { formatDistance } = useDate(); const { formatDistance } = useDate();
useEffect(() => { useEffect(() => {
@ -56,8 +50,8 @@ export function HeroPanelPlaytime() {
game.download?.status === "active" && lastPacket?.gameId === game.id; game.download?.status === "active" && lastPacket?.gameId === game.id;
const downloadInProgressInfo = ( const downloadInProgressInfo = (
<div className={styles.downloadDetailsRow}> <div className="hero-panel-playtime__download-details">
<Link to="/downloads" className={styles.downloadsLink}> <Link to="/downloads" className="hero-panel-playtime__downloads-link">
{game.download?.status === "active" {game.download?.status === "active"
? t("download_in_progress") ? t("download_in_progress")
: t("download_paused")} : t("download_paused")}
@ -84,7 +78,6 @@ export function HeroPanelPlaytime() {
return ( return (
<> <>
<p>{t("playing_now")}</p> <p>{t("playing_now")}</p>
{hasDownload && downloadInProgressInfo} {hasDownload && downloadInProgressInfo}
</> </>
); );

View file

@ -1,77 +0,0 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../../theme.css";
export const panel = recipe({
base: {
width: "100%",
height: "72px",
minHeight: "72px",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
backgroundColor: vars.color.darkBackground,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
transition: "all ease 0.2s",
borderBottom: `solid 1px ${vars.color.border}`,
position: "sticky",
overflow: "hidden",
top: "0",
zIndex: "2",
},
variants: {
stuck: {
true: {
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.8)",
},
},
},
});
export const content = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
});
export const actions = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});
export const downloadDetailsRow = style({
gap: `${SPACING_UNIT}px`,
display: "flex",
color: vars.color.body,
alignItems: "center",
});
export const downloadsLink = style({
color: vars.color.body,
textDecoration: "underline",
});
export const progressBar = recipe({
base: {
position: "absolute",
bottom: "0",
left: "0",
width: "100%",
height: "3px",
transition: "all ease 0.2s",
"::-webkit-progress-bar": {
backgroundColor: "transparent",
},
"::-webkit-progress-value": {
backgroundColor: vars.color.muted,
},
},
variants: {
disabled: {
true: {
opacity: vars.opacity.disabled,
},
},
},
});

View file

@ -0,0 +1,66 @@
@use "../../../scss/globals.scss";
.hero-panel {
width: 100%;
height: 72px;
min-height: 72px;
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 3);
background-color: globals.$dark-background-color;
display: flex;
align-items: center;
justify-content: space-between;
transition: all ease 0.2s;
border-bottom: solid 1px globals.$border-color;
position: sticky;
overflow: hidden;
top: 0;
z-index: 2;
&--stuck {
box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8);
}
&__content {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
}
&__actions {
display: flex;
gap: globals.$spacing-unit;
}
&__download-details {
gap: globals.$spacing-unit;
display: flex;
color: globals.$body-color;
align-items: center;
}
&__downloads-link {
color: globals.$body-color;
text-decoration: underline;
}
&__progress-bar {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
transition: all ease 0.2s;
&::-webkit-progress-bar {
background-color: transparent;
}
&::-webkit-progress-value {
background-color: globals.$muted-color;
}
&--disabled {
opacity: globals.$disabled-opacity;
}
}
}

View file

@ -4,10 +4,10 @@ import { useTranslation } from "react-i18next";
import { useDate, useDownload } from "@renderer/hooks"; import { useDate, useDownload } from "@renderer/hooks";
import { HeroPanelActions } from "./hero-panel-actions"; import { HeroPanelActions } from "./hero-panel-actions";
import * as styles from "./hero-panel.css";
import { HeroPanelPlaytime } from "./hero-panel-playtime"; import { HeroPanelPlaytime } from "./hero-panel-playtime";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import "./hero-panel.scss";
export interface HeroPanelProps { export interface HeroPanelProps {
isHeaderStuck: boolean; isHeaderStuck: boolean;
@ -54,30 +54,28 @@ export function HeroPanel({ isHeaderStuck }: HeroPanelProps) {
game?.download?.status === "paused"; game?.download?.status === "paused";
return ( return (
<> <div
<div style={{ backgroundColor: gameColor }}
style={{ backgroundColor: gameColor }} className={`hero-panel ${isHeaderStuck ? "hero-panel--stuck" : ""}`}
className={styles.panel({ stuck: isHeaderStuck })} >
> <div className="hero-panel__content">{getInfo()}</div>
<div className={styles.content}>{getInfo()}</div> <div className="hero-panel__actions">
<div className={styles.actions}> <HeroPanelActions />
<HeroPanelActions />
</div>
{showProgressBar && (
<progress
max={1}
value={
isGameDownloading
? lastPacket?.progress
: game?.download?.progress
}
className={styles.progressBar({
disabled: game?.download?.status === "paused",
})}
/>
)}
</div> </div>
</>
{showProgressBar && (
<progress
max={1}
value={
isGameDownloading ? lastPacket?.progress : game?.download?.progress
}
className={`hero-panel__progress-bar ${
game?.download?.status === "paused"
? "hero-panel__progress-bar--disabled"
: ""
}`}
/>
)}
</div>
); );
} }

View file

@ -123,8 +123,8 @@ export function DownloadSettingsModal({
.then(() => { .then(() => {
onClose(); onClose();
}) })
.catch(() => { .catch((error) => {
showErrorToast(t("download_error")); showErrorToast(t("download_error"), error.message);
}) })
.finally(() => { .finally(() => {
setDownloadStarting(false); setDownloadStarting(false);

View file

@ -21,7 +21,7 @@ export function GameOptionsModal({
visible, visible,
game, game,
onClose, onClose,
}: GameOptionsModalProps) { }: Readonly<GameOptionsModalProps>) {
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const { showSuccessToast, showErrorToast } = useToast(); const { showSuccessToast, showErrorToast } = useToast();

View file

@ -0,0 +1,174 @@
@use "../../../scss/globals.scss";
.content-sidebar {
border-left: solid 1px globals.$border-color;
background-color: globals.$dark-background-color;
width: 100%;
height: 100%;
@media (min-width: 1024px) {
max-width: 300px;
width: 100%;
}
@media (min-width: 1280px) {
width: 100%;
max-width: 400px;
}
}
.requirement {
&__button-container {
width: 100%;
display: flex;
}
&__button {
border: solid 1px globals.$border-color;
border-left: none;
border-right: none;
border-radius: 0;
width: 100%;
}
&__details {
padding: calc(globals.$spacing-unit * 2);
line-height: 22px;
font-size: globals.$body-font-size;
a {
display: flex;
color: globals.$body-color;
}
}
&__details-skeleton {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
padding: calc(globals.$spacing-unit * 2);
font-size: globals.$body-font-size;
}
}
.how-long-to-beat {
&__categories-list {
margin: 0;
padding: calc(globals.$spacing-unit * 2);
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
&__category {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
background: linear-gradient(
90deg,
transparent 20%,
rgb(255 255 255 / 2%) 100%
);
border-radius: 4px;
padding: globals.$spacing-unit calc(globals.$spacing-unit * 2);
border: solid 1px globals.$border-color;
}
&__category-label {
color: globals.$muted-color;
}
&__category-skeleton {
border: solid 1px globals.$border-color;
border-radius: 4px;
height: 76px;
}
}
.stats {
&__section {
display: flex;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2);
justify-content: space-between;
transition: max-height ease 0.5s;
overflow: hidden;
@media (min-width: 1024px) {
flex-direction: column;
}
@media (min-width: 1280px) {
flex-direction: row;
}
}
&__category-title {
font-size: globals.$small-font-size;
font-weight: bold;
display: flex;
align-items: center;
gap: globals.$spacing-unit;
}
&__category {
display: flex;
flex-direction: row;
gap: calc(globals.$spacing-unit / 2);
justify-content: space-between;
align-items: center;
}
}
.list {
list-style: none;
margin: 0;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2);
&__item {
display: flex;
cursor: pointer;
transition: all ease 0.1s;
color: globals.$muted-color;
width: 100%;
overflow: hidden;
border-radius: 4px;
padding: globals.$spacing-unit;
gap: calc(globals.$spacing-unit * 2);
align-items: center;
text-align: left;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
text-decoration: none;
}
}
&__item-image {
width: 54px;
height: 54px;
border-radius: 4px;
object-fit: cover;
&--locked {
filter: grayscale(100%);
}
}
}
.subscription-required-button {
text-decoration: none;
display: flex;
justify-content: center;
width: 100%;
gap: calc(globals.$spacing-unit / 2);
color: globals.$warning-color;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}

View file

@ -7,7 +7,6 @@ import type {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button, Link } from "@renderer/components"; import { Button, Link } from "@renderer/components";
import * as styles from "./sidebar.css";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import { useDate, useFormat, useUserDetails } from "@renderer/hooks"; import { useDate, useFormat, useUserDetails } from "@renderer/hooks";
import { import {
@ -20,8 +19,8 @@ import { HowLongToBeatSection } from "./how-long-to-beat-section";
import { howLongToBeatEntriesTable } from "@renderer/dexie"; import { howLongToBeatEntriesTable } from "@renderer/dexie";
import { SidebarSection } from "../sidebar-section/sidebar-section"; import { SidebarSection } from "../sidebar-section/sidebar-section";
import { buildGameAchievementPath } from "@renderer/helpers"; import { buildGameAchievementPath } from "@renderer/helpers";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useSubscription } from "@renderer/hooks/use-subscription"; import { useSubscription } from "@renderer/hooks/use-subscription";
import "./sidebar.scss";
const achievementsPlaceholder: UserAchievement[] = [ const achievementsPlaceholder: UserAchievement[] = [
{ {
@ -64,7 +63,6 @@ export function Sidebar() {
}>({ isLoading: true, data: null }); }>({ isLoading: true, data: null });
const { userDetails, hasActiveSubscription } = useUserDetails(); const { userDetails, hasActiveSubscription } = useUserDetails();
const [activeRequirement, setActiveRequirement] = const [activeRequirement, setActiveRequirement] =
useState<keyof SteamAppDetails["pc_requirements"]>("minimum"); useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
@ -72,10 +70,8 @@ export function Sidebar() {
useContext(gameDetailsContext); useContext(gameDetailsContext);
const { showHydraCloudModal } = useSubscription(); const { showHydraCloudModal } = useSubscription();
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const { formatDateTime } = useDate(); const { formatDateTime } = useDate();
const { numberFormatter } = useFormat(); const { numberFormatter } = useFormat();
useEffect(() => { useEffect(() => {
@ -118,7 +114,7 @@ export function Sidebar() {
}, [objectId, shop, gameTitle]); }, [objectId, shop, gameTitle]);
return ( return (
<aside className={styles.contentSidebar}> <aside className="content-sidebar">
{userDetails === null && ( {userDetails === null && (
<SidebarSection title={t("achievements")}> <SidebarSection title={t("achievements")}>
<div <div
@ -133,21 +129,21 @@ export function Sidebar() {
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
flexDirection: "column", flexDirection: "column",
gap: `${SPACING_UNIT}px`, gap: "8px",
}} }}
> >
<LockIcon size={36} /> <LockIcon size={36} />
<h3>{t("sign_in_to_see_achievements")}</h3> <h3>{t("sign_in_to_see_achievements")}</h3>
</div> </div>
<ul className={styles.list} style={{ filter: "blur(4px)" }}> <ul className="list" style={{ filter: "blur(4px)" }}>
{achievementsPlaceholder.map((achievement, index) => ( {achievementsPlaceholder.map((achievement) => (
<li key={index}> <li key={achievement.displayName}>
<div className={styles.listItem}> <div className="list__item">
<img <img
style={{ filter: "blur(8px)" }} style={{ filter: "blur(8px)" }}
className={styles.listItemImage({ className={`list__item-image ${
unlocked: achievement.unlocked, achievement.unlocked ? "" : "list__item-image--locked"
})} }`}
src={achievement.icon} src={achievement.icon}
alt={achievement.displayName} alt={achievement.displayName}
/> />
@ -164,6 +160,7 @@ export function Sidebar() {
</ul> </ul>
</SidebarSection> </SidebarSection>
)} )}
{userDetails && achievements && achievements.length > 0 && ( {userDetails && achievements && achievements.length > 0 && (
<SidebarSection <SidebarSection
title={t("achievements_count", { title={t("achievements_count", {
@ -171,10 +168,10 @@ export function Sidebar() {
achievementsCount: achievements.length, achievementsCount: achievements.length,
})} })}
> >
<ul className={styles.list}> <ul className="list">
{!hasActiveSubscription && ( {!hasActiveSubscription && (
<button <button
className={styles.subscriptionRequiredButton} className="subscription-required-button"
onClick={() => showHydraCloudModal("achievements")} onClick={() => showHydraCloudModal("achievements")}
> >
<CloudOfflineIcon size={16} /> <CloudOfflineIcon size={16} />
@ -182,21 +179,21 @@ export function Sidebar() {
</button> </button>
)} )}
{achievements.slice(0, 4).map((achievement, index) => ( {achievements.slice(0, 4).map((achievement) => (
<li key={index}> <li key={achievement.displayName}>
<Link <Link
to={buildGameAchievementPath({ to={buildGameAchievementPath({
shop: shop, shop: shop,
objectId: objectId!, objectId: objectId!,
title: gameTitle, title: gameTitle,
})} })}
className={styles.listItem} className="list__item"
title={achievement.description} title={achievement.description}
> >
<img <img
className={styles.listItemImage({ className={`list__item-image ${
unlocked: achievement.unlocked, achievement.unlocked ? "" : "list__item-image--locked"
})} }`}
src={achievement.icon} src={achievement.icon}
alt={achievement.displayName} alt={achievement.displayName}
/> />
@ -226,17 +223,17 @@ export function Sidebar() {
{stats && ( {stats && (
<SidebarSection title={t("stats")}> <SidebarSection title={t("stats")}>
<div className={styles.statsSection}> <div className="stats__section">
<div className={styles.statsCategory}> <div className="stats__category">
<p className={styles.statsCategoryTitle}> <p className="stats__category-title">
<DownloadIcon size={18} /> <DownloadIcon size={18} />
{t("download_count")} {t("download_count")}
</p> </p>
<p>{numberFormatter.format(stats?.downloadCount)}</p> <p>{numberFormatter.format(stats?.downloadCount)}</p>
</div> </div>
<div className={styles.statsCategory}> <div className="stats__category">
<p className={styles.statsCategoryTitle}> <p className="stats__category-title">
<PeopleIcon size={18} /> <PeopleIcon size={18} />
{t("player_count")} {t("player_count")}
</p> </p>
@ -252,9 +249,9 @@ export function Sidebar() {
/> />
<SidebarSection title={t("requirements")}> <SidebarSection title={t("requirements")}>
<div className={styles.requirementButtonContainer}> <div className="requirement__button-container">
<Button <Button
className={styles.requirementButton} className="requirement__button"
onClick={() => setActiveRequirement("minimum")} onClick={() => setActiveRequirement("minimum")}
theme={activeRequirement === "minimum" ? "primary" : "outline"} theme={activeRequirement === "minimum" ? "primary" : "outline"}
> >
@ -262,7 +259,7 @@ export function Sidebar() {
</Button> </Button>
<Button <Button
className={styles.requirementButton} className="requirement__button"
onClick={() => setActiveRequirement("recommended")} onClick={() => setActiveRequirement("recommended")}
theme={activeRequirement === "recommended" ? "primary" : "outline"} theme={activeRequirement === "recommended" ? "primary" : "outline"}
> >
@ -271,7 +268,7 @@ export function Sidebar() {
</div> </div>
<div <div
className={styles.requirementsDetails} className="requirement__details"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: __html:
shopDetails?.pc_requirements?.[activeRequirement] ?? shopDetails?.pc_requirements?.[activeRequirement] ??

View file

@ -28,13 +28,15 @@ export function SettingsBehavior() {
useEffect(() => { useEffect(() => {
if (userPreferences) { if (userPreferences) {
setForm({ setForm({
preferQuitInsteadOfHiding: userPreferences.preferQuitInsteadOfHiding, preferQuitInsteadOfHiding:
runAtStartup: userPreferences.runAtStartup, userPreferences.preferQuitInsteadOfHiding ?? false,
startMinimized: userPreferences.startMinimized, runAtStartup: userPreferences.runAtStartup ?? false,
disableNsfwAlert: userPreferences.disableNsfwAlert, startMinimized: userPreferences.startMinimized ?? false,
seedAfterDownloadComplete: userPreferences.seedAfterDownloadComplete, disableNsfwAlert: userPreferences.disableNsfwAlert ?? false,
seedAfterDownloadComplete:
userPreferences.seedAfterDownloadComplete ?? false,
showHiddenAchievementsDescription: showHiddenAchievementsDescription:
userPreferences.showHiddenAchievementsDescription, userPreferences.showHiddenAchievementsDescription ?? false,
}); });
} }
}, [userPreferences]); }, [userPreferences]);

View file

@ -65,18 +65,20 @@ export function SettingsGeneral() {
(language) => language === userPreferences.language (language) => language === userPreferences.language
) ?? ) ??
languageKeys.find((language) => { languageKeys.find((language) => {
return language.startsWith(userPreferences.language.split("-")[0]); return language.startsWith(
userPreferences.language?.split("-")[0] ?? "en"
);
}); });
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
downloadsPath: userPreferences.downloadsPath ?? defaultDownloadsPath, downloadsPath: userPreferences.downloadsPath ?? defaultDownloadsPath,
downloadNotificationsEnabled: downloadNotificationsEnabled:
userPreferences.downloadNotificationsEnabled, userPreferences.downloadNotificationsEnabled ?? false,
repackUpdatesNotificationsEnabled: repackUpdatesNotificationsEnabled:
userPreferences.repackUpdatesNotificationsEnabled, userPreferences.repackUpdatesNotificationsEnabled ?? false,
achievementNotificationsEnabled: achievementNotificationsEnabled:
userPreferences.achievementNotificationsEnabled, userPreferences.achievementNotificationsEnabled ?? false,
language: language ?? "en", language: language ?? "en",
})); }));
} }

View file

@ -16,6 +16,11 @@ $spacing-unit: 8px;
$toast-z-index: 5; $toast-z-index: 5;
$bottom-panel-z-index: 3; $bottom-panel-z-index: 3;
$title-bar-z-index: 1900000001; $title-bar-z-index: 4;
$backdrop-z-index: 4; $backdrop-z-index: 4;
$modal-z-index: 5; $modal-z-index: 5;
$body-font-size: 14px;
$small-font-size: 12px;
$app-container: app-container;

View file

@ -39,7 +39,7 @@ export const pipe =
fns.reduce((prev, fn) => fn(prev), arg); fns.reduce((prev, fn) => fn(prev), arg);
export const removeReleaseYearFromName = (name: string) => export const removeReleaseYearFromName = (name: string) =>
name.replace(/\([0-9]{4}\)/g, ""); name.replace(/\(\d{4}\)/g, "");
export const removeSymbolsFromName = (name: string) => export const removeSymbolsFromName = (name: string) =>
name.replace(/[^A-Za-z 0-9]/g, ""); name.replace(/[^A-Za-z 0-9]/g, "");

View file

@ -66,16 +66,16 @@ export interface GameAchievement {
} }
export interface UserPreferences { export interface UserPreferences {
downloadsPath: string | null; downloadsPath?: string | null;
language: string; language?: string;
realDebridApiToken: string | null; realDebridApiToken?: string | null;
preferQuitInsteadOfHiding: boolean; preferQuitInsteadOfHiding?: boolean;
runAtStartup: boolean; runAtStartup?: boolean;
startMinimized: boolean; startMinimized?: boolean;
disableNsfwAlert: boolean; disableNsfwAlert?: boolean;
seedAfterDownloadComplete: boolean; seedAfterDownloadComplete?: boolean;
showHiddenAchievementsDescription: boolean; showHiddenAchievementsDescription?: boolean;
downloadNotificationsEnabled: boolean; downloadNotificationsEnabled?: boolean;
repackUpdatesNotificationsEnabled: boolean; repackUpdatesNotificationsEnabled?: boolean;
achievementNotificationsEnabled: boolean; achievementNotificationsEnabled?: boolean;
} }

View file

@ -6,7 +6,9 @@
"src/renderer/src/**/*.tsx", "src/renderer/src/**/*.tsx",
"src/preload/*.d.ts", "src/preload/*.d.ts",
"src/locales/index.ts", "src/locales/index.ts",
"src/shared/**/*" "src/shared/**/*",
"src/stories/**/*",
".storybook/**/*"
], ],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,