Merge branch 'main' into Feature/Game-Card-Sources

This commit is contained in:
Shisuys 2025-02-28 00:07:53 -03:00 committed by GitHub
commit e12c6daa16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
96 changed files with 2289 additions and 455 deletions

View file

@ -1,5 +1,9 @@
name: Build
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: pull_request
jobs:

View file

@ -1,5 +1,9 @@
name: Lint
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: pull_request
jobs:

View file

@ -1,5 +1,9 @@
name: Release
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: main

View file

@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.1.5",
"version": "3.2.2",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@ -36,6 +36,7 @@
"@electron-toolkit/utils": "^3.0.0",
"@fontsource/noto-sans": "^5.1.0",
"@hookform/resolvers": "^3.9.1",
"@monaco-editor/react": "^4.6.0",
"@primer/octicons-react": "^19.9.0",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@reduxjs/toolkit": "^2.2.3",
@ -59,6 +60,7 @@
"i18next-browser-languagedetector": "^7.2.1",
"jsdom": "^24.0.0",
"jsonwebtoken": "^9.0.2",
"kill-port": "^2.0.1",
"knex": "^3.1.0",
"lodash-es": "^4.17.21",
"parse-torrent": "^11.0.17",

View file

@ -159,6 +159,8 @@ def action():
downloader = downloads.get(game_id)
if downloader:
downloader.pause_download()
if downloading_game_id == game_id:
downloading_game_id = -1
elif action == 'cancel':
downloader = downloads.get(game_id)

View file

@ -107,7 +107,10 @@ const copyAria2Macos = async () => {
};
const copyAria2 = () => {
if (fs.existsSync("aria2")) {
const aria2Path =
process.platform === "win32" ? "aria2/aria2c.exe" : "aria2/aria2c";
if (fs.existsSync(aria2Path)) {
console.log("Aria2 already exists, skipping download...");
return;
}

View file

@ -7,18 +7,18 @@
"featured": "مميز",
"surprise_me": "مفاجئني",
"no_results": "لم يتم العثور على نتائج",
"start_typing": "ابدأ الكتابة للبحث...",
"hot": "الأكثر شيوعًا الآن",
"start_typing": "ابدأ بالكتابة للبحث...",
"hot": "الأكثر شهرة الآن",
"weekly": "📅 أفضل ألعاب الأسبوع",
"achievements": "🏆 ألعاب للتغلب عليها"
"achievements": "🏆 ألعاب يجب إكمالها"
},
"sidebar": {
"catalogue": "الكـتالوج",
"catalogue": "الفهرس",
"downloads": "التنزيلات",
"settings": "الإعدادات",
"my_library": "مكتبتي",
"downloading_metadata": "{{title}} (جارٍ تنزيل البيانات الوصفية...)",
"paused": "{{title}} (معلّق)",
"downloading_metadata": "{{title}} (جاري تنزيل البيانات الوصفية...)",
"paused": "{{title}} (معلق)",
"downloading": "{{title}} ({{percentage}} - جاري التنزيل...)",
"filter": "تصفية المكتبة",
"home": "الرئيسية",
@ -26,12 +26,13 @@
"game_has_no_executable": "اللعبة لا تحتوي على ملف تشغيل",
"sign_in": "تسجيل الدخول",
"friends": "الأصدقاء",
"need_help": "تحتاج مساعدة؟"
"need_help": "تحتاج مساعدة؟",
"favorites": "المفضلة"
},
"header": {
"search": "ابحث عن الألعاب",
"search": "بحث الألعاب",
"home": "الرئيسية",
"catalogue": "الكـتالوج",
"catalogue": "الفهرس",
"downloads": "التنزيلات",
"search_results": "نتائج البحث",
"settings": "الإعدادات",
@ -40,16 +41,16 @@
},
"bottom_panel": {
"no_downloads_in_progress": "لا توجد تنزيلات قيد التقدم",
"downloading_metadata": "جارٍ تنزيل البيانات الوصفية لـ {{title}}...",
"downloading": "جارٍ تنزيل {{title}}... ({{percentage}} اكتمال) - الوقت المتبقي {{eta}} - السرعة {{speed}}",
"calculating_eta": "جارٍ تنزيل {{title}}... ({{percentage}} اكتمال) - جاري حساب الوقت المتبقي...",
"checking_files": "جارٍ فحص ملفات {{title}}... ({{percentage}} اكتمال)"
"downloading_metadata": "جاري تنزيل بيانات {{title}} الوصفية...",
"downloading": "جاري تنزيل {{title}}... ({{percentage}} مكتمل) - الوقت المتبقي {{eta}} - السرعة {{speed}}",
"calculating_eta": "جاري تنزيل {{title}}... ({{percentage}} مكتمل) - جاري حساب الوقت المتبقي...",
"checking_files": "جاري فحص ملفات {{title}}... ({{percentage}} مكتمل)"
},
"catalogue": {
"search": "تصفية...",
"developers": "المطورون",
"genres": "الأنواع",
"tags": "العلامات",
"tags": "الوسوم",
"publishers": "الناشرون",
"download_sources": "مصادر التنزيل",
"result_count": "{{resultCount}} نتيجة",
@ -68,34 +69,34 @@
"cancel": "إلغاء",
"remove": "إزالة",
"space_left_on_disk": "{{space}} متبقي على القرص",
"eta": "الانتهاء {{eta}}",
"calculating_eta": "جارٍ حساب الوقت المتبقي...",
"downloading_metadata": "جارٍ تنزيل البيانات الوصفية...",
"filter": "تصفية الحزم المعاد تعبئتها",
"eta": "الانتهاء المتوقع {{eta}}",
"calculating_eta": "جاري حساب الوقت المتبقي...",
"downloading_metadata": "جاري تنزيل البيانات الوصفية...",
"filter": "تصفية الإصدارات المعادة",
"requirements": "متطلبات النظام",
"minimum": "الحد الأدنى",
"recommended": ُوصى به",
"paused": "معلّق",
"recommended": ستحسن",
"paused": "معلق",
"release_date": "تاريخ الإصدار {{date}}",
"publisher": "نشر بواسطة {{publisher}}",
"hours": "ساعات",
"minutes": "دقائق",
"amount_hours": "{{amount}} ساعات",
"amount_minutes": "{{amount}} دقائق",
"amount_hours": "{{amount}} ساعة",
"amount_minutes": "{{amount}} دقيقة",
"accuracy": "دقة {{accuracy}}%",
"add_to_library": "إضافة إلى المكتبة",
"remove_from_library": "إزالة من المكتبة",
"no_downloads": "لا توجد تنزيلات متاحة",
"play_time": "لعب لمدة {{amount}}",
"last_time_played": "آخر تشغيل {{period}}",
"play_time": "وقت اللعب {{amount}}",
"last_time_played": "آخر مرة لعب {{period}}",
"not_played_yet": "لم تلعب {{title}} بعد",
"next_suggestion": "الاقتراح التالي",
"play": "تشغيل",
"deleting": "جارٍ حذف المثبت...",
"deleting": "جاري حذف المثبت...",
"close": "إغلاق",
"playing_now": "يتم التشغيل الآن",
"playing_now": "جاري التشغيل الآن",
"change": "تغيير",
"repacks_modal_description": "اختر الحزمة المعاد تعبئتها التي تريد تنزيلها",
"repacks_modal_description": "اختر الإصدار المعاد الذي تريد تنزيله",
"select_folder_hint": "لتغيير المجلد الافتراضي، انتقل إلى <0>الإعدادات</0>",
"download_now": "تنزيل الآن",
"no_shop_details": "تعذر الحصول على تفاصيل المتجر.",
@ -110,12 +111,12 @@
"select_executable": "تحديد",
"no_executable_selected": "لم يتم تحديد ملف تشغيل",
"open_folder": "فتح المجلد",
"open_download_location": "عرض الملفات المحملة",
"open_download_location": "عرض الملفات المنزلة",
"create_shortcut": "إنشاء اختصار على سطح المكتب",
"clear": "مسح",
"remove_files": "إزالة الملفات",
"remove_from_library_title": "هل أنت متأكد؟",
"remove_from_library_description": "سيؤدي هذا إلى إزالة {{game}} من مكتبتك",
"remove_from_library_description": "سيتم إزالة {{game}} من مكتبتك",
"options": "خيارات",
"executable_section_title": "ملف التشغيل",
"executable_section_description": "مسار الملف الذي سيتم تشغيله عند النقر على \"تشغيل\"",
@ -123,35 +124,35 @@
"downloads_section_description": "تحقق من التحديثات أو الإصدارات الأخرى لهذه اللعبة",
"danger_zone_section_title": "منطقة الخطر",
"danger_zone_section_description": "إزالة هذه اللعبة من مكتبتك أو الملفات التي تم تنزيلها بواسطة Hydra",
"download_in_progress": "تنزيل قيد التقدم",
"download_in_progress": "جاري التنزيل",
"download_paused": "التنزيل معلق",
"last_downloaded_option": "خيار التنزيل الأخير",
"create_shortcut_success": "تم إنشاء الاختصار بنجاح",
"create_shortcut_error": "خطأ في إنشاء الاختصار",
"nsfw_content_title": "هذه اللعبة تحتوي على محتوى غير لائق",
"nsfw_content_description": "{{title}} يحتوي على محتوى قد لا يناسب جميع الأعمار. هل تريد المتابعة؟",
"nsfw_content_description": "{{title}} يحتوي على محتوى قد لا يكون مناسبًا لجميع الأعمار. هل تريد المتابعة؟",
"allow_nsfw_content": "متابعة",
"refuse_nsfw_content": "رجوع",
"stats": "الإحصائيات",
"download_count": "مرات التنزيل",
"download_count": "التنزيلات",
"player_count": "اللاعبون النشطون",
"download_error": "خيار التنزيل هذا غير متاح",
"download": "تنزيل",
"executable_path_in_use": "مسار التشغيل مستخدم بالفعل بواسطة \"{{game}}\"",
"warning": "تحذير:",
"hydra_needs_to_remain_open": "لهذا التنزيل، يجب أن يبقى Hydra مفتوحًا حتى اكتماله. إذا أغلق Hydra قبل الاكتمال، ستفقد تقدمك.",
"hydra_needs_to_remain_open": "لهذا التنزيل، يجب أن يظل Hydra مفتوحًا حتى اكتماله. إذا تم إغلاق Hydra قبل الاكتمال، ستفقد تقدمك.",
"achievements": "الإنجازات",
"achievements_count": "الإنجازات {{unlockedCount}}/{{achievementsCount}}",
"cloud_save": "حفظ سحابي",
"cloud_save_description": "احفظ تقدمك على السحابة واستمر في اللعب من أي جهاز",
"cloud_save_description": "احفظ تقدمك في السحابة واستمر في اللعب من أي جهاز",
"backups": "النسخ الاحتياطية",
"install_backup": "تثبيت",
"delete_backup": "حذف",
"create_backup": "نسخة احتياطية جديدة",
"last_backup_date": "آخر نسخة احتياطية في {{date}}",
"no_backup_preview": "لم يتم العثور على حفظات لهذا العنوان",
"restoring_backup": "جارٍ استعادة النسخة الاحتياطية ({{progress}} اكتمال)...",
"uploading_backup": "جارٍ رفع النسخة الاحتياطية...",
"restoring_backup": "جاري استعادة النسخة الاحتياطية ({{progress}} مكتمل)...",
"uploading_backup": "جاري رفع النسخة الاحتياطية...",
"no_backups": "لم تقم بإنشاء أي نسخ احتياطية لهذه اللعبة بعد",
"backup_uploaded": "تم رفع النسخة الاحتياطية",
"backup_deleted": "تم حذف النسخة الاحتياطية",
@ -164,61 +165,67 @@
"files_automatically_mapped": "تم تعيين الملفات تلقائيًا",
"no_backups_created": "لم يتم إنشاء نسخ احتياطية لهذه اللعبة",
"manage_files": "إدارة الملفات",
"loading_save_preview": "جارٍ البحث عن حفظات الألعاب...",
"loading_save_preview": "جاري البحث عن حفظات اللعبة...",
"wine_prefix": "بادئة Wine",
"wine_prefix_description": "بادئة Wine المستخدمة لتشغيل هذه اللعبة",
"launch_options": "خيارات التشغيل",
"launch_options_description": "يمكن للمستخدمين المتقدمين إدخال تعديلات على خيارات التشغيل (ميزة تجريبية)",
"launch_options_placeholder": م يتم تحديد أي معاملات",
"launch_options_placeholder": ا توجد معلمات محددة",
"no_download_option_info": "لا توجد معلومات متاحة",
"backup_deletion_failed": "فشل حذف النسخة الاحتياطية",
"max_number_of_artifacts_reached": "تم الوصول إلى الحد الأقصى لعدد النسخ الاحتياطية لهذه اللعبة",
"achievements_not_sync": "تعرف على كيفية مزامنة إنجازاتك",
"backup_deletion_failed": "فشل في حذف النسخة الاحتياطية",
"max_number_of_artifacts_reached": "تم الوصول إلى الحد الأقصى من النسخ الاحتياطية لهذه اللعبة",
"achievements_not_sync": "شاهد كيفية مزامنة إنجازاتك",
"manage_files_description": "إدارة الملفات التي سيتم نسخها احتياطيًا واستعادتها",
"select_folder": "حدد المجلد",
"backup_from": "نسخة احتياطية من {{date}}",
"custom_backup_location_set": "تم تعيين موقع نسخ احتياطي مخصص",
"no_directory_selected": "لم يتم تحديد مجلد",
"no_write_permission": "لا يمكن التنزيل إلى هذا المجلد. انقر هنا لمعرفة المزيد.",
"no_write_permission": "لا يمكن التنزيل إلى هذا المجلد. انقر هنا للمزيد من المعلومات.",
"reset_achievements": "إعادة تعيين الإنجازات",
"reset_achievements_description": "سيؤدي هذا إلى إعادة تعيين جميع إنجازات {{game}}",
"reset_achievements_title": "هل أنت متأكد؟",
"reset_achievements_success": "تم إعادة تعيين الإنجازات بنجاح",
"reset_achievements_error": "فشل إعادة تعيين الإنجازات"
"reset_achievements_error": "فشل في إعادة تعيين الإنجازات",
"download_error_gofile_quota_exceeded": "لقد تجاوزت الحصة الشهرية لـ Gofile. يرجى الانتظار حتى إعادة تعيين الحصة.",
"download_error_real_debrid_account_not_authorized": "حساب Real-Debrid الخاص بك غير مصرح له بإجراء تنزيلات جديدة. يرجى مراجعة إعدادات الحساب والمحاولة مرة أخرى.",
"download_error_not_cached_in_real_debrid": "هذا التنزيل غير متوفر على Real-Debrid وجلب حالة التنزيل من Real-Debrid غير متاح حاليًا.",
"download_error_not_cached_in_torbox": "هذا التنزيل غير متوفر على Torbox وجلب حالة التنزيل من Torbox غير متاح حاليًا.",
"game_removed_from_favorites": "تمت إزالة اللعبة من المفضلة",
"game_added_to_favorites": "تمت إضافة اللعبة إلى المفضلة"
},
"activation": {
"title": "تفعيل Hydra",
"installation_id": "معرف التثبيت:",
"enter_activation_code": "أدخل رمز التفعيل الخاص بك",
"message": "إذا كنت لا تعرف أين تطلب هذا، فلا يجب أن يكون لديك هذا.",
"message": "إذا كنت لا تعرف أين تطلب هذا، فأنت لا يجب أن يكون لديك هذا.",
"activate": "تفعيل",
"loading": "جارٍ التحميل..."
"loading": "جاري التحميل..."
},
"downloads": {
"resume": "استئناف",
"pause": "إيقاف مؤقت",
"eta": "الانتهاء {{eta}}",
"paused": "معلّق",
"verifying": "جارٍ التحقق...",
"eta": "الانتهاء المتوقع {{eta}}",
"paused": "معلق",
"verifying": "جاري التحقق...",
"completed": "مكتمل",
"removed": "غير محمل",
"removed": "غير منزّل",
"cancel": "إلغاء",
"filter": "تصفية الألعاب المحملة",
"filter": "تصفية الألعاب المنزلة",
"remove": "إزالة",
"downloading_metadata": "جارٍ تنزيل البيانات الوصفية...",
"deleting": "جارٍ حذف المثبت...",
"delete": "إزالة المثبت",
"downloading_metadata": "جاري تنزيل البيانات الوصفية...",
"deleting": "جاري حذف المثبت...",
"delete": "حذف المثبت",
"delete_modal_title": "هل أنت متأكد؟",
"delete_modal_description": "سيؤدي هذا إلى إزالة جميع ملفات التثبيت من جهازك",
"install": "تثبيت",
"download_in_progress": "قيد التقدم",
"queued_downloads": "التنزيلات في قائمة الانتظار",
"downloads_completed": "مكتمل",
"downloads_completed": "مكتملة",
"queued": "في قائمة الانتظار",
"no_downloads_title": "فارغ جدًا",
"no_downloads_title": "لا شيء هنا",
"no_downloads_description": "لم تقم بتنزيل أي شيء باستخدام Hydra بعد، ولكن لم يفت الأوان للبدء.",
"checking_files": "جارٍ فحص الملفات...",
"seeding": "التوزيع",
"checking_files": "جاري فحص الملفات...",
"seeding": "جاري التوزيع",
"stop_seeding": "إيقاف التوزيع",
"resume_seeding": "استئناف التوزيع",
"options": "إدارة"
@ -228,8 +235,8 @@
"change": "تحديث",
"notifications": "الإشعارات",
"enable_download_notifications": "عند اكتمال التنزيل",
"enable_repack_list_notifications": "عند إضافة حزمة معاد تعبئتها جديدة",
"real_debrid_api_token_label": "رمز واجهة برمجة تطبيقات Real-Debrid",
"enable_repack_list_notifications": "عند إضافة إصدار معاد جديد",
"real_debrid_api_token_label": "رمز Real-Debrid API",
"quit_app_instead_hiding": "لا تخفي Hydra عند الإغلاق",
"launch_with_system": "تشغيل Hydra مع بدء النظام",
"general": "عام",
@ -238,21 +245,21 @@
"language": "اللغة",
"api_token": "رمز API",
"enable_real_debrid": "تفعيل Real-Debrid",
"real_debrid_description": "Real-Debrid هو أداة تنزيل غير مقيدة تتيح لك تنزيل الملفات بسرعة، مقيدة فقط بسرعة الإنترنت لديك.",
"real_debrid_description": "Real-Debrid هو أداة تنزيل غير مقيدة تتيح لك تنزيل الملفات بسرعة، محدودة فقط بسرعة اتصالك بالإنترنت.",
"debrid_invalid_token": "رمز API غير صالح",
"debrid_api_token_hint": "يمكنك الحصول على رمز API الخاص بك <0>هنا</0>",
"real_debrid_free_account_error": "الحساب \"{{username}}\" هو حساب مجاني. يرجى الاشتراك في Real-Debrid",
"real_debrid_free_account_error": "الحساب \"{{username}}\" حساب مجاني. يرجى الاشتراك في Real-Debrid",
"debrid_linked_message": "تم ربط الحساب \"{{username}}\"",
"save_changes": "حفظ التغييرات",
"changes_saved": "تم حفظ التغييرات بنجاح",
"download_sources_description": "سيقوم Hydra بجلب روابط التنزيل من هذه المصادر. يجب أن يكون عنوان URL المصدر رابطًا مباشرًا لملف .json يحتوي على روابط التنزيل.",
"download_sources_description": "سيقوم Hydra بجلب روابط التنزيل من هذه المصادر. يجب أن يكون عنوان URL للمصدر رابطًا مباشرًا لملف .json يحتوي على روابط التنزيل.",
"validate_download_source": "تحقق",
"remove_download_source": "إزالة",
"add_download_source": "إضافة مصدر",
"download_count_zero": "لا توجد خيارات تنزيل",
"download_count_one": "{{countFormatted}} خيار تنزيل",
"download_count_other": "{{countFormatted}} خيارات تنزيل",
"download_source_url": "عنوان URL لمصدر التنزيل",
"download_source_url": "عنوان مصدر التنزيل",
"add_download_source_description": "أدخل عنوان URL لملف .json",
"download_source_up_to_date": "محدث",
"download_source_errored": "خطأ",
@ -272,13 +279,13 @@
"profile_visibility": "رؤية الملف الشخصي",
"profile_visibility_description": "اختر من يمكنه رؤية ملفك الشخصي ومكتبتك",
"required_field": "هذا الحقل مطلوب",
"source_already_exists": "تمت إضافة هذا المصدر مسبقًا",
"must_be_valid_url": "يجب أن يكون المصدر عنوان URL صالحًا",
"source_already_exists": "هذا المصدر مضاف مسبقًا",
"must_be_valid_url": "يجب أن يكون المصدر عنوان URL صالح",
"blocked_users": "المستخدمون المحظورون",
"user_unblocked": "تم إلغاء حظر المستخدم",
"enable_achievement_notifications": "عند فتح إنجاز",
"launch_minimized": "تشغيل Hydra مصغرًا",
"disable_nsfw_alert": "تعطيل تنبيه المحتوى غير اللائق",
"disable_nsfw_alert": "تعطيل تنبيهات المحتوى غير اللائق",
"seed_after_download_complete": "التوزيع بعد اكتمال التنزيل",
"show_hidden_achievement_description": "عرض وصف الإنجازات المخفية قبل فتحها",
"account": "الحساب",
@ -296,18 +303,47 @@
"become_subscriber": "كن مشتركًا في Hydra Cloud",
"subscription_renew_cancelled": "تم تعطيل التجديد التلقائي",
"subscription_renews_on": "سيتم تجديد اشتراكك في {{date}}",
"bill_sent_until": "سيتم إرسال فاتورتك التالية حتى هذا اليوم"
"bill_sent_until": "سيتم إرسال فاتورتك القادمة حتى هذا اليوم",
"no_themes": "يبدو أنه ليس لديك أي سمات بعد، لكن لا تقلق، انقر هنا لإنشاء أول تحفة فنية لك.",
"editor_tab_code": "الكود",
"editor_tab_info": "معلومات",
"editor_tab_save": "حفظ",
"web_store": "المتجر الإلكتروني",
"clear_themes": "مسح",
"create_theme": "إنشاء",
"create_theme_modal_title": "إنشاء سمة مخصصة",
"create_theme_modal_description": "إنشاء سمة جديدة لتخصيص مظهر Hydra",
"theme_name": "الاسم",
"insert_theme_name": "أدخل اسم السمة",
"set_theme": "تعيين السمة",
"unset_theme": "إلغاء تعيين السمة",
"delete_theme": "حذف السمة",
"edit_theme": "تعديل السمة",
"delete_all_themes": "حذف جميع السمات",
"delete_all_themes_description": "سيؤدي هذا إلى حذف جميع السمات المخصصة الخاصة بك",
"delete_theme_description": "سيؤدي هذا إلى حذف السمة {{theme}}",
"cancel": "إلغاء",
"appearance": "المظهر",
"enable_torbox": "تفعيل Torbox",
"torbox_description": "TorBox هي خدمة seedbox متميزة تنافس أفضل الخوادم في السوق.",
"torbox_account_linked": "تم ربط حساب TorBox",
"real_debrid_account_linked": "تم ربط حساب Real-Debrid",
"name_min_length": "يجب أن يكون اسم السمة على الأقل 3 أحرف",
"import_theme": "استيراد سمة",
"import_theme_description": "ستقوم باستيراد {{theme}} من متجر السمات",
"error_importing_theme": "خطأ في استيراد السمة",
"theme_imported": "تم استيراد السمة بنجاح"
},
"notifications": {
"download_complete": "اكتمل التنزيل",
"game_ready_to_install": "{{title}} جاهز للتثبيت",
"repack_list_updated": "تم تحديث قائمة الحزم المعاد تعبئتها",
"repack_count_one": "تمت إضافة {{count}} حزمة معاد تعبئتها",
"repack_count_other": "تمت إضافة {{count}} حزم معاد تعبئتها",
"repack_list_updated": "تم تحديث قائمة الإصدارات المعادة",
"repack_count_one": "تمت إضافة {{count}} إصدار معاد",
"repack_count_other": "تمت إضافة {{count}} إصدارات معادة",
"new_update_available": "الإصدار {{version}} متوفر",
"restart_to_install_update": "أعد تشغيل Hydra لتثبيت التحديث",
"notification_achievement_unlocked_title": "تم فتح إنجاز لـ {{game}}",
"notification_achievement_unlocked_body": "{{achievement}} و {{count}} آخرين تم فتحهم"
"notification_achievement_unlocked_body": "{{achievement}} و {{count}} أخرى تم فتحها"
},
"system_tray": {
"open": "فتح Hydra",
@ -319,7 +355,7 @@
"binary_not_found_modal": {
"title": "البرامج غير مثبتة",
"description": "لم يتم العثور على ملفات تشغيل Wine أو Lutris على نظامك",
"instructions": "تحقق من الطريقة الصحيحة لتثبيت أي منها على توزيعة لينكس الخاصة بك حتى تعمل اللعبة بشكل طبيعي"
"instructions": "تحقق من الطريقة الصحيحة لتثبيت أي منها على توزيعة Linux الخاصة بك حتى تعمل اللعبة بشكل طبيعي"
},
"modal": {
"close": "زر الإغلاق"
@ -328,16 +364,16 @@
"toggle_password_visibility": "تبديل رؤية كلمة المرور"
},
"user_profile": {
"amount_hours": "{{amount}} ساعات",
"amount_minutes": "{{amount}} دقائق",
"last_time_played": "آخر تشغيل {{period}}",
"amount_hours": "{{amount}} ساعة",
"amount_minutes": "{{amount}} دقيقة",
"last_time_played": "آخر مرة لعب {{period}}",
"activity": "النشاط الأخير",
"library": "المكتبة",
"total_play_time": "إجمالي وقت اللعب",
"no_recent_activity_title": "همم... لا شيء هنا",
"no_recent_activity_title": "لا شيء هنا...",
"no_recent_activity_description": "لم تلعب أي ألعاب مؤخرًا. حان الوقت لتغيير ذلك!",
"display_name": "اسم العرض",
"saving": "جارٍ الحفظ",
"saving": "جاري الحفظ",
"save": "حفظ",
"edit_profile": "تعديل الملف الشخصي",
"saved_successfully": "تم الحفظ بنجاح",
@ -346,13 +382,13 @@
"cancel": "إلغاء",
"successfully_signed_out": "تم تسجيل الخروج بنجاح",
"sign_out": "تسجيل الخروج",
"playing_for": "يلعب لمدة {{amount}}",
"sign_out_modal_text": "مكتبتك مرتبطة بحسابك الحالي. عند تسجيل الخروج، لن تكون مكتبتك مرئية بعد الآن، ولن يتم حفظ أي تقدم. هل تتابع تسجيل الخروج؟",
"playing_for": "جاري اللعب لمدة {{amount}}",
"sign_out_modal_text": "مكتبتك مرتبطة بحسابك الحالي. عند تسجيل الخروج، لن تكون مكتبتك مرئية، ولن يتم حفظ أي تقدم. هل تتابع تسجيل الخروج؟",
"add_friends": "إضافة أصدقاء",
"add": "إضافة",
"friend_code": "رمز الصديق",
"see_profile": "عرض الملف الشخصي",
"sending": "جارٍ الإرسال",
"sending": "جاري الإرسال",
"friend_request_sent": "تم إرسال طلب الصداقة",
"friends": "الأصدقاء",
"friends_list": "قائمة الأصدقاء",
@ -371,19 +407,19 @@
"blocked_users": "المستخدمون المحظورون",
"unblock": "إلغاء الحظر",
"no_friends_added": "ليس لديك أصدقاء مضافون",
"pending": "قيد الانتظار",
"pending": "معلق",
"no_pending_invites": "ليس لديك دعوات معلقة",
"no_blocked_users": "ليس لديك مستخدمون محظورون",
"friend_code_copied": "تم نسخ رمز الصديق",
"undo_friendship_modal_text": "سيؤدي هذا إلى إلغاء صداقتك مع {{displayName}}",
"privacy_hint": "لضبط من يمكنه رؤية هذا، انتقل إلى <0>الإعدادات</0>",
"locked_profile": "هذا الملف الشخصي خاص",
"image_process_failure": "فشل معالجة الصورة",
"image_process_failure": "فشل في معالجة الصورة",
"required_field": "هذا الحقل مطلوب",
"displayname_min_length": "يجب أن يكون اسم العرض على الأقل 3 أحرف",
"displayname_max_length": "يجب ألا يتجاوز اسم العرض 50 حرفًا",
"report_profile": "الإبلاغ عن هذا الملف الشخصي",
"report_reason": "لماذا تقوم بالإبلاغ عن هذا الملف الشخصي؟",
"displayname_max_length": "يجب أن لا يتجاوز اسم العرض 50 حرفًا",
"report_profile": "الإبلاغ عن هذا الملف",
"report_reason": "لماذا تقوم بالإبلاغ عن هذا الملف؟",
"report_description": "معلومات إضافية",
"report_description_placeholder": "معلومات إضافية",
"report": "الإبلاغ",
@ -393,32 +429,32 @@
"report_reason_spam": "بريد عشوائي",
"report_reason_other": "أخرى",
"profile_reported": "تم الإبلاغ عن الملف الشخصي",
"your_friend_code": "رمز صديقك:",
"upload_banner": "تحميل بانر",
"uploading_banner": "جارٍ تحميل البانر...",
"your_friend_code": "رمز الصديق الخاص بك:",
"upload_banner": "رفع بانر",
"uploading_banner": "جاري رفع البانر...",
"background_image_updated": "تم تحديث صورة الخلفية",
"stats": "الإحصائيات",
"achievements": "إنجازات",
"achievements": "الإنجازات",
"games": "الألعاب",
"top_percentile": "ال{{percentile}}% الأعلى",
"top_percentile": "الأعلى {{percentile}}%",
"ranking_updated_weekly": "يتم تحديث التصنيف أسبوعيًا",
"playing": "يلعب {{game}}",
"playing": "جاري لعب {{game}}",
"achievements_unlocked": "الإنجازات المفتوحة",
"earned_points": "النقاط المكتسبة",
"show_achievements_on_profile": "عرض إنجازاتك على ملفك الشخصي",
"show_points_on_profile": "عرض نقاطك المكتسبة على ملفك الشخصي"
"show_achievements_on_profile": "عرض إنجازاتك في ملفك الشخصي",
"show_points_on_profile": "عرض نقاطك المكتسبة في ملفك الشخصي"
},
"achievement": {
"achievement_unlocked": "تم فتح الإنجاز",
"user_achievements": "إنجازات {{displayName}}",
"your_achievements": "إنجازاتك",
"unlocked_at": "تم الفتح في: {{date}}",
"subscription_needed": "يحتاج إلى اشتراك Hydra Cloud لرؤية هذا المحتوى",
"subscription_needed": "يحتاج إلى اشتراك Hydra Cloud لعرض هذا المحتوى",
"new_achievements_unlocked": "تم فتح {{achievementCount}} إنجازات جديدة من {{gameCount}} ألعاب",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} إنجازات",
"achievements_unlocked_for_game": "تم فتح {{achievementCount}} إنجازات جديدة لـ {{gameTitle}}",
"hidden_achievement_tooltip": "هذا إنجاز مخفي",
"achievement_earn_points": "اكسب {{points}} نقطة مع هذا الإنجاز",
"achievement_earn_points": "احصل على {{points}} نقاط مع هذا الإنجاز",
"earned_points": "النقاط المكتسبة:",
"available_points": "النقاط المتاحة:",
"how_to_earn_achievements_points": "كيفية كسب نقاط الإنجازات؟"
@ -428,10 +464,10 @@
"subscribe_now": "اشترك الآن",
"cloud_saving": "حفظ سحابي",
"cloud_achievements": "احفظ إنجازاتك على السحابة",
"animated_profile_picture": "صورة ملف شخصي متحركة",
"animated_profile_picture": "صورة ملف متحركة",
"premium_support": "دعم ممتاز",
"show_and_compare_achievements": "اعرض وقارن إنجازاتك مع المستخدمين الآخرين",
"animated_profile_banner": "بانر ملف شخصي متحرك",
"animated_profile_banner": "بانر ملف متحرك",
"hydra_cloud": "Hydra Cloud",
"hydra_cloud_feature_found": "لقد اكتشفت ميزة Hydra Cloud!",
"learn_more": "معرفة المزيد"

View file

@ -189,9 +189,10 @@
"download_error_gofile_quota_exceeded": "You have exceeded your Gofile monthly quota. Please await the quota to reset.",
"download_error_real_debrid_account_not_authorized": "Your Real-Debrid account is not authorized to make new downloads. Please check your account settings and try again.",
"download_error_not_cached_in_real_debrid": "This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available.",
"download_error_not_cached_in_torbox": "This download is not available on Torbox and polling download status from Torbox is not yet available."
"download_error_not_cached_in_torbox": "This download is not available on Torbox and polling download status from Torbox is not yet available.",
"game_removed_from_favorites": "Game removed from favorites",
"game_added_to_favorites": "Game added to favorites"
},
"activation": {
"title": "Activate Hydra",
"installation_id": "Installation ID:",
@ -303,10 +304,35 @@
"subscription_renew_cancelled": "Automatic renewal is disabled",
"subscription_renews_on": "Your subscription renews on {{date}}",
"bill_sent_until": "Your next bill will be sent until this day",
"no_themes": "Seems like you don't have any themes yet, but no worries, click here to create your first masterpiece.",
"editor_tab_code": "Code",
"editor_tab_info": "Info",
"editor_tab_save": "Save",
"web_store": "Web store",
"clear_themes": "Clear",
"create_theme": "Create",
"create_theme_modal_title": "Create custom theme",
"create_theme_modal_description": "Create a new theme to customize Hydra's appearance",
"theme_name": "Name",
"insert_theme_name": "Insert theme name",
"set_theme": "Set theme",
"unset_theme": "Unset theme",
"delete_theme": "Delete theme",
"edit_theme": "Edit theme",
"delete_all_themes": "Delete all themes",
"delete_all_themes_description": "This will delete all your custom themes",
"delete_theme_description": "This will delete the theme {{theme}}",
"cancel": "Cancel",
"appearance": "Appearance",
"enable_torbox": "Enable Torbox",
"torbox_description": "TorBox is your premium seedbox service rivaling even the best servers on the market.",
"torbox_account_linked": "TorBox account linked",
"real_debrid_account_linked": "Real-Debrid account linked"
"real_debrid_account_linked": "Real-Debrid account linked",
"name_min_length": "Theme name must be at least 3 characters long",
"import_theme": "Import theme",
"import_theme_description": "You will import {{theme}} from the theme store",
"error_importing_theme": "Error importing theme",
"theme_imported": "Theme imported successfully"
},
"notifications": {
"download_complete": "Download complete",
@ -418,6 +444,9 @@
"show_achievements_on_profile": "Show your achievements on your profile",
"show_points_on_profile": "Show your earned points on your profile"
},
"badge": {
"badge_description_theme_creator": "Awarded to those who created a custom theme"
},
"achievement": {
"achievement_unlocked": "Achievement unlocked",
"user_achievements": "{{displayName}}'s Achievements",

View file

@ -179,9 +179,10 @@
"download_error_gofile_quota_exceeded": "Você excedeu sua cota mensal do Gofile. Por favor, aguarde a cota resetar.",
"download_error_real_debrid_account_not_authorized": "Sua conta do Real-Debrid não está autorizada a fazer novos downloads. Por favor, verifique sua assinatura e tente novamente.",
"download_error_not_cached_in_real_debrid": "Este download não está disponível no Real-Debrid e a verificação do status do download não está disponível.",
"download_error_not_cached_in_torbox": "Este download não está disponível no Torbox e a verificação do status do download não está disponível."
"download_error_not_cached_in_torbox": "Este download não está disponível no Torbox e a verificação do status do download não está disponível.",
"game_removed_from_favorites": "Jogo removido dos favoritos",
"game_added_to_favorites": "Jogo adicionado aos favoritos"
},
"activation": {
"title": "Ativação",
"installation_id": "ID da instalação:",
@ -293,10 +294,33 @@
"subscription_renew_cancelled": "A renovação automática está desativada",
"subscription_renews_on": "Sua assinatura renova dia {{date}}",
"bill_sent_until": "Sua próxima cobrança será enviada até esse dia",
"no_themes": "Parece que você ainda não tem nenhum tema. Não se preocupe, clique aqui para criar sua primeira obra de arte.",
"editor_tab_save": "Salvar",
"web_store": "Loja de temas",
"clear_themes": "Limpar",
"create_theme": "Criar",
"create_theme_modal_title": "Criar tema customizado",
"create_theme_modal_description": "Criar novo tema para customizar a aparência do Hydra",
"theme_name": "Nome",
"insert_theme_name": "Insira o nome do tema",
"set_theme": "Habilitar tema",
"unset_theme": "Desabilitar tema",
"delete_theme": "Deletar tema",
"edit_theme": "Editar tema",
"delete_all_themes": "Deletar todos os temas",
"delete_all_themes_description": "Isso irá deletar todos os seus temas",
"delete_theme_description": "Isso irá deletar o tema {{theme}}",
"cancel": "Cancelar",
"appearance": "Aparência",
"enable_torbox": "Habilitar Torbox",
"torbox_description": "TorBox é o seu serviço de seedbox premium que rivaliza até com os melhores servidores do mercado.",
"torbox_account_linked": "Conta do TorBox vinculada",
"real_debrid_account_linked": "Conta Real-Debrid associada"
"real_debrid_account_linked": "Conta Real-Debrid associada",
"name_min_length": "O nome do tema deve ter pelo menos 3 caracteres",
"import_theme": "Importar tema",
"import_theme_description": "Você irá importar {{theme}} da loja de temas",
"error_importing_theme": "Erro ao importar tema",
"theme_imported": "Tema importado com sucesso"
},
"notifications": {
"download_complete": "Download concluído",
@ -418,6 +442,9 @@
"show_achievements_on_profile": "Exiba suas conquistas no perfil",
"show_points_on_profile": "Exiba seus pontos ganhos no perfil"
},
"badge": {
"badge_description_theme_creator": "Concedido àqueles que criaram um tema customizado"
},
"achievement": {
"achievement_unlocked": "Conquista desbloqueada",
"your_achievements": "Suas Conquistas",

View file

@ -183,7 +183,13 @@
"no_write_permission": "Невозможно загрузить в эту директорию. Нажмите здесь, чтобы узнать больше.",
"reset_achievements_title": "Вы уверены?",
"reset_achievements_success": "Достижения успешно сброшены",
"reset_achievements_error": "Не удалось сбросить достижения"
"reset_achievements_error": "Не удалось сбросить достижения",
"download_error_gofile_quota_exceeded": "Вы превысили месячную квоту Gofile. Пожалуйста, подождите, пока квота не будет восстановлена.",
"download_error_real_debrid_account_not_authorized": "Ваш аккаунт Real-Debrid не авторизован для осуществления новых загрузок. Пожалуйста, проверьте настройки учетной записи и повторите попытку.",
"download_error_not_cached_in_real_debrid": "Эта загрузка недоступна на Real-Debrid, а опрос статуса загрузки с Real-Debrid пока недоступен.",
"download_error_not_cached_in_torbox": "Эта загрузка недоступна на Torbox, и опросить статус загрузки с Torbox пока невозможно.",
"game_added_to_favorites": "Игра добавлена в избранное",
"game_removed_from_favorites": "Игра удалена из избранного"
},
"activation": {
"title": "Активировать Hydra",
@ -295,7 +301,36 @@
"become_subscriber": "Станьте обладателем Hydra Cloud",
"subscription_renew_cancelled": "Автоматическое продление отключено",
"subscription_renews_on": "Ваша подписка продлевается на {{date}}",
"bill_sent_until": "Ваш следующий счет будет отправлен до этого дня"
"bill_sent_until": "Ваш следующий счет будет отправлен до этого дня",
"no_themes": "Похоже, что у вас еще нет тем, но не волнуйтесь, нажмите здесь, чтобы создать свой первый шедевр",
"editor_tab_code": "Код",
"editor_tab_info": "Информация",
"editor_tab_save": "Сохранить",
"web_store": "Веб-магазин",
"clear_themes": "Очистить",
"create_theme": "Создать",
"create_theme_modal_title": "Создать пользовательскую тему",
"create_theme_modal_description": "Создать новую тему для настройки внешнего вида Hydra",
"theme_name": "Название",
"insert_theme_name": "Вставить название темы",
"set_theme": "Установить тему",
"unset_theme": "Снять тему",
"delete_theme": "Удалить тему",
"edit_theme": "Редактировать тему",
"delete_all_themes": "Удалить все темы",
"delete_all_themes_description": "Это удалит все ваши пользовательские темы",
"delete_theme_description": "Это приведет к удалению темы {{theme}}",
"cancel": "Отменить",
"appearance": "Внешний вид",
"enable_torbox": "Включить Torbox",
"torbox_description": "TorBox - это ваш премиум-сервис, конкурирующий даже с лучшими серверами на рынке.",
"torbox_account_linked": "Аккаунт TorBox привязан",
"real_debrid_account_linked": "Аккаунт Real-Debrid привязан",
"name_min_length": "Название темы должно содержать не менее 3 символов",
"import_theme": "Импортировать тему",
"import_theme_description": "Вы импортируете {{theme}} из магазина тем",
"error_importing_theme": "Ошибка при импорте темы",
"theme_imported": "Тема успешно импортирована"
},
"notifications": {
"download_complete": "Загрузка завершена",
@ -405,6 +440,9 @@
"show_achievements_on_profile": "Покажите свои достижения в профиле",
"show_points_on_profile": "Показывать заработанные очки в своем профиле"
},
"badge": {
"badge_description_theme_creator": "Награждается тот, кто создал пользовательскую тему"
},
"achievement": {
"achievement_unlocked": "Достижение разблокировано",
"user_achievements": "Достижения {{displayName}}",

View file

@ -3,7 +3,6 @@ import jwt from "jsonwebtoken";
import { registerEvent } from "../register-event";
import { db, levelKeys } from "@main/level";
import type { Auth } from "@types";
import { Crypto } from "@main/services";
const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
const auth = await db.get<string, Auth>(levelKeys.auth, {
@ -11,9 +10,7 @@ const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
});
if (!auth) return null;
const payload = jwt.decode(
Crypto.decrypt(auth.accessToken)
) as jwt.JwtPayload;
const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload;
if (!payload) return null;

View file

@ -77,6 +77,16 @@ import "./cloud-save/upload-save-game";
import "./cloud-save/delete-game-artifact";
import "./cloud-save/select-game-backup-path";
import "./notifications/publish-new-repacks-notification";
import "./themes/add-custom-theme";
import "./themes/delete-custom-theme";
import "./themes/get-all-custom-themes";
import "./themes/delete-all-custom-themes";
import "./themes/update-custom-theme";
import "./themes/open-editor-window";
import "./themes/get-custom-theme-by-id";
import "./themes/get-active-custom-theme";
import "./themes/close-editor-window";
import "./themes/toggle-custom-theme";
import { isPortableVersion } from "@main/helpers";
ipcMain.handle("ping", () => "pong");

View file

@ -5,7 +5,7 @@ import type { Game, GameShop } from "@types";
import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared";
import { updateLocalUnlockedAchivements } from "@main/services/achievements/update-local-unlocked-achivements";
import { updateLocalUnlockedAchievements } from "@main/services/achievements/update-local-unlocked-achivements";
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
const addGameToLibrary = async (
@ -46,9 +46,9 @@ const addGameToLibrary = async (
await gamesSublevel.put(levelKeys.game(shop, objectId), game);
updateLocalUnlockedAchivements(game);
await createGame(game).catch(() => {});
createGame(game).catch(() => {});
updateLocalUnlockedAchievements(game);
}
};

View file

@ -1,6 +1,6 @@
import { shell } from "electron";
import { registerEvent } from "../register-event";
import { Crypto, HydraApi } from "@main/services";
import { HydraApi } from "@main/services";
import { db, levelKeys } from "@main/level";
import type { Auth } from "@types";
@ -14,7 +14,7 @@ const openCheckout = async (_event: Electron.IpcMainInvokeEvent) => {
}
const paymentToken = await HydraApi.post("/auth/payment", {
refreshToken: Crypto.decrypt(auth.refreshToken),
refreshToken: auth.refreshToken,
}).then((response) => response.accessToken);
const params = new URLSearchParams({

View file

@ -0,0 +1,12 @@
import { Theme } from "@types";
import { registerEvent } from "../register-event";
import { themesSublevel } from "@main/level";
const addCustomTheme = async (
_event: Electron.IpcMainInvokeEvent,
theme: Theme
) => {
await themesSublevel.put(theme.id, theme);
};
registerEvent("addCustomTheme", addCustomTheme);

View file

@ -0,0 +1,11 @@
import { WindowManager } from "@main/services";
import { registerEvent } from "../register-event";
const closeEditorWindow = async (
_event: Electron.IpcMainInvokeEvent,
themeId?: string
) => {
WindowManager.closeEditorWindow(themeId);
};
registerEvent("closeEditorWindow", closeEditorWindow);

View file

@ -0,0 +1,8 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const deleteAllCustomThemes = async (_event: Electron.IpcMainInvokeEvent) => {
await themesSublevel.clear();
};
registerEvent("deleteAllCustomThemes", deleteAllCustomThemes);

View file

@ -0,0 +1,11 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const deleteCustomTheme = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string
) => {
await themesSublevel.del(themeId);
};
registerEvent("deleteCustomTheme", deleteCustomTheme);

View file

@ -0,0 +1,9 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const getActiveCustomTheme = async () => {
const allThemes = await themesSublevel.values().all();
return allThemes.find((theme) => theme.isActive);
};
registerEvent("getActiveCustomTheme", getActiveCustomTheme);

View file

@ -0,0 +1,8 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const getAllCustomThemes = async (_event: Electron.IpcMainInvokeEvent) => {
return themesSublevel.values().all();
};
registerEvent("getAllCustomThemes", getAllCustomThemes);

View file

@ -0,0 +1,11 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const getCustomThemeById = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string
) => {
return themesSublevel.get(themeId);
};
registerEvent("getCustomThemeById", getCustomThemeById);

View file

@ -0,0 +1,11 @@
import { WindowManager } from "@main/services";
import { registerEvent } from "../register-event";
const openEditorWindow = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string
) => {
WindowManager.openEditorWindow(themeId);
};
registerEvent("openEditorWindow", openEditorWindow);

View file

@ -0,0 +1,22 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const toggleCustomTheme = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string,
isActive: boolean
) => {
const theme = await themesSublevel.get(themeId);
if (!theme) {
throw new Error("Theme not found");
}
await themesSublevel.put(themeId, {
...theme,
isActive,
updatedAt: new Date(),
});
};
registerEvent("toggleCustomTheme", toggleCustomTheme);

View file

@ -0,0 +1,27 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
import { WindowManager } from "@main/services";
const updateCustomTheme = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string,
code: string
) => {
const theme = await themesSublevel.get(themeId);
if (!theme) {
throw new Error("Theme not found");
}
await themesSublevel.put(themeId, {
...theme,
code,
updatedAt: new Date(),
});
if (theme.isActive) {
WindowManager.mainWindow?.webContents.send("css-injected", code);
}
};
registerEvent("updateCustomTheme", updateCustomTheme);

View file

@ -13,7 +13,14 @@ const cancelGameDownload = async (
await DownloadManager.cancelDownload(downloadKey);
await downloadsSublevel.del(downloadKey);
const download = await downloadsSublevel.get(downloadKey);
if (!download) return;
await downloadsSublevel.put(downloadKey, {
...download,
status: "removed",
});
};
registerEvent("cancelGameDownload", cancelGameDownload);

View file

@ -15,6 +15,7 @@ const pauseGameSeed = async (
await downloadsSublevel.put(downloadKey, {
...download,
status: "complete",
shouldSeed: false,
});

View file

@ -8,12 +8,14 @@ const resumeGameSeed = async (
shop: GameShop,
objectId: string
) => {
const download = await downloadsSublevel.get(levelKeys.game(shop, objectId));
const downloadKey = levelKeys.game(shop, objectId);
const download = await downloadsSublevel.get(downloadKey);
if (!download) return;
await downloadsSublevel.put(levelKeys.game(shop, objectId), {
await downloadsSublevel.put(downloadKey, {
...download,
status: "seeding",
shouldSeed: true,
});

View file

@ -1,27 +1,10 @@
import { registerEvent } from "../register-event";
import { db, levelKeys } from "@main/level";
import { Crypto } from "@main/services";
import type { UserPreferences } from "@types";
const getUserPreferences = async () =>
db
.get<string, UserPreferences | null>(levelKeys.userPreferences, {
valueEncoding: "json",
})
.then((userPreferences) => {
if (userPreferences?.realDebridApiToken) {
userPreferences.realDebridApiToken = Crypto.decrypt(
userPreferences.realDebridApiToken
);
}
if (userPreferences?.torBoxApiToken) {
userPreferences.torBoxApiToken = Crypto.decrypt(
userPreferences.torBoxApiToken
);
}
return userPreferences;
});
db.get<string, UserPreferences | null>(levelKeys.userPreferences, {
valueEncoding: "json",
});
registerEvent("getUserPreferences", getUserPreferences);

View file

@ -3,7 +3,6 @@ import { registerEvent } from "../register-event";
import type { UserPreferences } from "@types";
import i18next from "i18next";
import { db, levelKeys } from "@main/level";
import { Crypto } from "@main/services";
import { patchUserProfile } from "../profile/update-profile";
const updateUserPreferences = async (
@ -24,16 +23,6 @@ const updateUserPreferences = async (
patchUserProfile({ language: preferences.language }).catch(() => {});
}
if (preferences.realDebridApiToken) {
preferences.realDebridApiToken = Crypto.encrypt(
preferences.realDebridApiToken
);
}
if (preferences.torBoxApiToken) {
preferences.torBoxApiToken = Crypto.encrypt(preferences.torBoxApiToken);
}
if (!preferences.downloadsPath) {
preferences.downloadsPath = null;
}

View file

@ -32,7 +32,7 @@ export const getUnlockedAchievements = async (
return achievementsData
.map((achievementData) => {
const unlockedAchiementData = unlockedAchievements.find(
const unlockedAchievementData = unlockedAchievements.find(
(localAchievement) => {
return (
localAchievement.name.toUpperCase() ==
@ -45,11 +45,11 @@ export const getUnlockedAchievements = async (
? achievementData.icon
: achievementData.icongray;
if (unlockedAchiementData) {
if (unlockedAchievementData) {
return {
...achievementData,
unlocked: true,
unlockTime: unlockedAchiementData.unlockTime,
unlockTime: unlockedAchievementData.unlockTime,
};
}

View file

@ -3,6 +3,7 @@ import updater from "electron-updater";
import i18n from "i18next";
import path from "node:path";
import url from "node:url";
import kill from "kill-port";
import { electronApp, optimizer } from "@electron-toolkit/utils";
import { logger, WindowManager } from "@main/services";
import resources from "@locales";
@ -58,7 +59,7 @@ app.whenReady().then(async () => {
return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString());
});
await loadState();
await kill(PythonRPC.RPC_PORT).finally(() => loadState());
const language = await db.get<string, string>(levelKeys.language, {
valueEncoding: "utf-8",
@ -85,6 +86,29 @@ const handleDeepLinkPath = (uri?: string) => {
if (url.host === "install-source") {
WindowManager.redirect(`settings${url.search}`);
return;
}
if (url.host === "profile") {
const userId = url.searchParams.get("userId");
if (userId) {
WindowManager.redirect(`profile/${userId}`);
}
return;
}
if (url.host === "install-theme") {
const themeName = url.searchParams.get("theme");
const authorId = url.searchParams.get("authorId");
const authorName = url.searchParams.get("authorName");
if (themeName && authorId && authorName) {
WindowManager.redirect(
`settings?theme=${themeName}&authorId=${authorId}&authorName=${authorName}`
);
}
}
} catch (error) {
logger.error("Error handling deep link", uri, error);

View file

@ -3,3 +3,4 @@ export * from "./games";
export * from "./game-shop-cache";
export * from "./game-achievements";
export * from "./keys";
export * from "./themes";

View file

@ -5,6 +5,7 @@ export const levelKeys = {
game: (shop: GameShop, objectId: string) => `${shop}:${objectId}`,
user: "user",
auth: "auth",
themes: "themes",
gameShopCache: "gameShopCache",
gameShopCacheItem: (shop: GameShop, objectId: string, language: string) =>
`${shop}:${objectId}:${language}`,

View file

@ -0,0 +1,7 @@
import type { Theme } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const themesSublevel = db.sublevel<string, Theme>(levelKeys.themes, {
valueEncoding: "json",
});

View file

@ -1,10 +1,4 @@
import {
Crypto,
DownloadManager,
logger,
Ludusavi,
startMainLoop,
} from "./services";
import { DownloadManager, logger, Ludusavi, startMainLoop } from "./services";
import { RealDebridClient } from "./services/download/real-debrid";
import { HydraApi } from "./services/hydra-api";
import { uploadGamesBatch } from "./services/library-sync";
@ -38,13 +32,11 @@ export const loadState = async () => {
Aria2.spawn();
if (userPreferences?.realDebridApiToken) {
RealDebridClient.authorize(
Crypto.decrypt(userPreferences.realDebridApiToken)
);
RealDebridClient.authorize(userPreferences.realDebridApiToken);
}
if (userPreferences?.torBoxApiToken) {
TorBoxClient.authorize(Crypto.decrypt(userPreferences.torBoxApiToken));
TorBoxClient.authorize(userPreferences.torBoxApiToken);
}
Ludusavi.addManifestToLudusaviConfig();
@ -57,23 +49,21 @@ export const loadState = async () => {
.values()
.all()
.then((games) => {
return sortBy(
games.filter((game) => game.queued),
"timestamp",
"DESC"
);
return sortBy(games, "timestamp", "DESC");
});
const [nextItemOnQueue] = downloads;
const [nextItemOnQueue] = downloads.filter((game) => game.queued);
const downloadsToSeed = downloads.filter(
(download) =>
download.shouldSeed &&
download.downloader === Downloader.Torrent &&
download.progress === 1 &&
download.uri !== null
(game) =>
game.shouldSeed &&
game.downloader === Downloader.Torrent &&
game.progress === 1 &&
game.uri !== null
);
console.log("downloadsToSeed", downloadsToSeed);
await DownloadManager.startRPC(nextItemOnQueue, downloadsToSeed);
startMainLoop();
@ -123,9 +113,7 @@ const migrateFromSqlite = async () => {
levelKeys.userPreferences,
{
...rest,
realDebridApiToken: realDebridApiToken
? Crypto.encrypt(realDebridApiToken)
: null,
realDebridApiToken,
preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1,
runAtStartup: rest.runAtStartup === 1,
startMinimized: rest.startMinimized === 1,
@ -191,8 +179,8 @@ const migrateFromSqlite = async () => {
await db.put<string, Auth>(
levelKeys.auth,
{
accessToken: Crypto.encrypt(users[0].accessToken),
refreshToken: Crypto.encrypt(users[0].refreshToken),
accessToken: users[0].accessToken,
refreshToken: users[0].refreshToken,
tokenExpirationTimestamp: users[0].tokenExpirationTimestamp,
},
{

View file

@ -23,23 +23,21 @@ const saveAchievementsOnLocal = async (
return gameAchievementsSublevel
.get(levelKey)
.then(async (gameAchievement) => {
if (gameAchievement) {
await gameAchievementsSublevel.put(levelKey, {
...gameAchievement,
unlockedAchievements: unlockedAchievements,
});
await gameAchievementsSublevel.put(levelKey, {
achievements: gameAchievement?.achievements ?? [],
unlockedAchievements: unlockedAchievements,
});
if (!sendUpdateEvent) return;
if (!sendUpdateEvent) return;
return getUnlockedAchievements(objectId, shop, true)
.then((achievements) => {
WindowManager.mainWindow?.webContents.send(
`on-update-achievements-${objectId}-${shop}`,
achievements
);
})
.catch(() => {});
}
return getUnlockedAchievements(objectId, shop, true)
.then((achievements) => {
WindowManager.mainWindow?.webContents.send(
`on-update-achievements-${objectId}-${shop}`,
achievements
);
})
.catch(() => {});
});
};
@ -133,7 +131,7 @@ export const mergeAchievements = async (
);
})
.catch((err) => {
if (err! instanceof SubscriptionRequiredError) {
if (err instanceof SubscriptionRequiredError) {
achievementsLogger.log(
"Achievements not synchronized on API due to lack of subscription",
game.objectId,

View file

@ -6,7 +6,7 @@ import { parseAchievementFile } from "./parse-achievement-file";
import { mergeAchievements } from "./merge-achievements";
import type { Game, UnlockedAchievement } from "@types";
export const updateLocalUnlockedAchivements = async (game: Game) => {
export const updateLocalUnlockedAchievements = async (game: Game) => {
const gameAchievementFiles = findAchievementFiles(game);
const achievementFileInsideDirectory =

View file

@ -1,28 +0,0 @@
import { safeStorage } from "electron";
import { logger } from "./logger";
export class Crypto {
public static encrypt(str: string) {
if (safeStorage.isEncryptionAvailable()) {
return safeStorage.encryptString(str).toString("base64");
} else {
logger.warn(
"Encrypt method returned raw string because encryption is not available"
);
return str;
}
}
public static decrypt(b64: string) {
if (safeStorage.isEncryptionAvailable()) {
return safeStorage.decryptString(Buffer.from(b64, "base64"));
} else {
logger.warn(
"Decrypt method returned raw string because encryption is not available"
);
return b64;
}
}
}

View file

@ -219,8 +219,10 @@ export class DownloadManager {
} as PauseDownloadPayload)
.catch(() => {});
WindowManager.mainWindow?.setProgressBar(-1);
this.downloadingGameId = null;
if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1);
this.downloadingGameId = null;
}
}
static async resumeDownload(download: Download) {
@ -228,14 +230,17 @@ export class DownloadManager {
}
static async cancelDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc.post("/action", {
action: "cancel",
game_id: downloadKey,
});
WindowManager.mainWindow?.setProgressBar(-1);
await PythonRPC.rpc
.post("/action", {
action: "cancel",
game_id: downloadKey,
})
.catch((err) => {
logger.error("Failed to cancel game download", err);
});
if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1);
WindowManager.mainWindow?.webContents.send("on-download-progress", null);
this.downloadingGameId = null;
}

View file

@ -12,7 +12,6 @@ import { isFuture, isToday } from "date-fns";
import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels";
import type { Auth, User } from "@types";
import { Crypto } from "./crypto";
interface HydraApiOptions {
needsAuth?: boolean;
@ -32,8 +31,9 @@ export class HydraApi {
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static readonly ADD_LOG_INTERCEPTOR = true;
private static readonly secondsToMilliseconds = (seconds: number) =>
seconds * 1000;
private static secondsToMilliseconds(seconds: number) {
return seconds * 1000;
}
private static userAuth: HydraApiUserAuth = {
authToken: "",
@ -81,8 +81,8 @@ export class HydraApi {
db.put<string, Auth>(
levelKeys.auth,
{
accessToken: Crypto.encrypt(accessToken),
refreshToken: Crypto.encrypt(refreshToken),
accessToken,
refreshToken,
tokenExpirationTimestamp,
},
{ valueEncoding: "json" }
@ -204,12 +204,8 @@ export class HydraApi {
const user = result.at(1) as User | undefined;
this.userAuth = {
authToken: userAuth?.accessToken
? Crypto.decrypt(userAuth.accessToken)
: "",
refreshToken: userAuth?.refreshToken
? Crypto.decrypt(userAuth.refreshToken)
: "",
authToken: userAuth?.accessToken ?? "",
refreshToken: userAuth?.refreshToken ?? "",
expirationTimestamp: userAuth?.tokenExpirationTimestamp ?? 0,
subscription: user?.subscription
? { expiresAt: user.subscription?.expiresAt }
@ -258,7 +254,7 @@ export class HydraApi {
levelKeys.auth,
{
...auth,
accessToken: Crypto.encrypt(accessToken),
accessToken,
tokenExpirationTimestamp,
},
{ valueEncoding: "json" }

View file

@ -1,4 +1,3 @@
export * from "./crypto";
export * from "./logger";
export * from "./steam";
export * from "./steam-250";

View file

@ -24,7 +24,7 @@ export const mergeWithRemoteGames = async () => {
? game.playTimeInMilliseconds
: localGame.playTimeInMilliseconds;
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
await gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...localGame,
remoteId: game.id,
lastTimePlayed: updatedLastTimePlayed,
@ -39,7 +39,7 @@ export const mergeWithRemoteGames = async () => {
? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon)
: null;
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
await gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
objectId: game.objectId,
title: steamGame?.name,
remoteId: game.id,

View file

@ -24,6 +24,8 @@ import { isStaging } from "@main/constants";
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
private static readonly editorWindows: Map<string, BrowserWindow> = new Map();
private static loadMainWindowURL(hash = "") {
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
@ -55,7 +57,7 @@ export class WindowManager {
trafficLightPosition: { x: 16, y: 16 },
titleBarOverlay: {
symbolColor: "#DADBE1",
color: "#151515",
color: "#00000000",
height: 34,
},
webPreferences: {
@ -201,6 +203,87 @@ export class WindowManager {
}
}
public static openEditorWindow(themeId: string) {
if (this.mainWindow) {
const existingWindow = this.editorWindows.get(themeId);
if (existingWindow) {
if (existingWindow.isMinimized()) {
existingWindow.restore();
}
existingWindow.focus();
return;
}
const editorWindow = new BrowserWindow({
width: 600,
height: 720,
minWidth: 600,
minHeight: 540,
backgroundColor: "#1c1c1c",
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
...(process.platform === "linux" ? { icon } : {}),
trafficLightPosition: { x: 16, y: 16 },
titleBarOverlay: {
symbolColor: "#DADBE1",
color: "#151515",
height: 34,
},
webPreferences: {
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false,
},
show: false,
});
this.editorWindows.set(themeId, editorWindow);
editorWindow.removeMenu();
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
editorWindow.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}#/theme-editor?themeId=${themeId}`
);
} else {
editorWindow.loadFile(path.join(__dirname, "../renderer/index.html"), {
hash: `theme-editor?themeId=${themeId}`,
});
}
editorWindow.once("ready-to-show", () => {
editorWindow.show();
this.mainWindow?.webContents.openDevTools();
if (isStaging) {
editorWindow.webContents.openDevTools();
}
});
editorWindow.webContents.on("before-input-event", (event, input) => {
if (input.key === "F12") {
event.preventDefault();
this.mainWindow?.webContents.toggleDevTools();
}
});
editorWindow.on("close", () => {
this.mainWindow?.webContents.closeDevTools();
this.editorWindows.delete(themeId);
});
}
}
public static closeEditorWindow(themeId?: string) {
if (themeId) {
const editorWindow = this.editorWindows.get(themeId);
if (editorWindow) {
editorWindow.close();
}
} else {
this.editorWindows.forEach((editorWindow) => {
editorWindow.close();
});
}
}
public static redirect(hash: string) {
if (!this.mainWindow) this.createMainWindow();
this.loadMainWindowURL(hash);

View file

@ -14,6 +14,7 @@ import type {
CatalogueSearchPayload,
SeedingStatus,
GameAchievement,
Theme,
} from "@types";
import type { AuthPage, CatalogueCategory } from "@shared";
import type { AxiosProgressEvent } from "axios";
@ -347,4 +348,30 @@ contextBridge.exposeInMainWorld("electron", {
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) =>
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
/* Themes */
addCustomTheme: (theme: Theme) => ipcRenderer.invoke("addCustomTheme", theme),
getAllCustomThemes: () => ipcRenderer.invoke("getAllCustomThemes"),
deleteAllCustomThemes: () => ipcRenderer.invoke("deleteAllCustomThemes"),
deleteCustomTheme: (themeId: string) =>
ipcRenderer.invoke("deleteCustomTheme", themeId),
updateCustomTheme: (themeId: string, code: string) =>
ipcRenderer.invoke("updateCustomTheme", themeId, code),
getCustomThemeById: (themeId: string) =>
ipcRenderer.invoke("getCustomThemeById", themeId),
getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"),
toggleCustomTheme: (themeId: string, isActive: boolean) =>
ipcRenderer.invoke("toggleCustomTheme", themeId, isActive),
/* Editor */
openEditorWindow: (themeId: string) =>
ipcRenderer.invoke("openEditorWindow", themeId),
onCssInjected: (cb: (cssString: string) => void) => {
const listener = (_event: Electron.IpcRendererEvent, cssString: string) =>
cb(cssString);
ipcRenderer.on("css-injected", listener);
return () => ipcRenderer.removeListener("css-injected", listener);
},
closeEditorWindow: (themeId?: string) =>
ipcRenderer.invoke("closeEditorWindow", themeId),
});

View file

@ -28,6 +28,7 @@ import { downloadSourcesTable } from "./dexie";
import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { injectCustomCss } from "./helpers";
import "./app.scss";
export interface AppProps {
@ -233,6 +234,17 @@ export function App() {
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
}, [updateRepacks]);
useEffect(() => {
const loadAndApplyTheme = async () => {
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme?.code) {
injectCustomCss(activeTheme.code);
}
};
loadAndApplyTheme();
}, []);
const playAudio = useCallback(() => {
const audio = new Audio(achievementSound);
audio.volume = 0.2;
@ -249,6 +261,14 @@ export function App() {
};
}, [playAudio]);
useEffect(() => {
const unsubscribe = window.electron.onCssInjected((cssString) => {
injectCustomCss(cssString);
});
return () => unsubscribe();
}, []);
const handleToastClose = useCallback(() => {
dispatch(closeToast());
}, [dispatch]);

View file

@ -0,0 +1,29 @@
<svg width="240" height="246" viewBox="0 0 240 246" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M117.681 8.44054C120.27 4.75592 125.73 4.75592 128.319 8.44054L149.273 38.2669C151.08 40.8399 154.301 42.0121 157.339 41.203L192.563 31.8236C196.914 30.6649 201.097 34.1747 200.712 38.6612L197.591 74.9784C197.322 78.1113 199.036 81.0795 201.884 82.4128L234.895 97.8691C238.974 99.7785 239.922 105.156 236.743 108.345L211.008 134.16C208.788 136.387 208.193 139.762 209.517 142.614L224.871 175.674C226.767 179.758 224.037 184.486 219.552 184.886L183.245 188.119C180.113 188.398 177.487 190.601 176.669 193.637L167.18 228.832C166.007 233.179 160.876 235.047 157.184 232.47L127.292 211.609C124.714 209.809 121.286 209.809 118.708 211.609L88.8163 232.47C85.1236 235.047 79.9927 233.179 78.8204 228.832L69.3314 193.637C68.5129 190.601 65.8873 188.398 62.7553 188.119L26.4479 184.886C21.9627 184.486 19.2326 179.758 21.1293 175.674L36.4827 142.614C37.8072 139.762 37.212 136.387 34.992 134.16L9.25738 108.345C6.07823 105.156 7.02639 99.7785 11.1045 97.8691L44.1164 82.4128C46.9642 81.0795 48.6778 78.1113 48.4087 74.9784L45.2883 38.6611C44.9028 34.1747 49.0856 30.6649 53.437 31.8236L88.6606 41.203C91.6992 42.0121 94.9198 40.8399 96.7274 38.2669L117.681 8.44054Z" fill="url(#paint0_linear_1378_2496)"/>
<path d="M117.681 8.44054C120.27 4.75592 125.73 4.75592 128.319 8.44054L149.273 38.2669C151.08 40.8399 154.301 42.0121 157.339 41.203L192.563 31.8236C196.914 30.6649 201.097 34.1747 200.712 38.6612L197.591 74.9784C197.322 78.1113 199.036 81.0795 201.884 82.4128L234.895 97.8691C238.974 99.7785 239.922 105.156 236.743 108.345L211.008 134.16C208.788 136.387 208.193 139.762 209.517 142.614L224.871 175.674C226.767 179.758 224.037 184.486 219.552 184.886L183.245 188.119C180.113 188.398 177.487 190.601 176.669 193.637L167.18 228.832C166.007 233.179 160.876 235.047 157.184 232.47L127.292 211.609C124.714 209.809 121.286 209.809 118.708 211.609L88.8163 232.47C85.1236 235.047 79.9927 233.179 78.8204 228.832L69.3314 193.637C68.5129 190.601 65.8873 188.398 62.7553 188.119L26.4479 184.886C21.9627 184.486 19.2326 179.758 21.1293 175.674L36.4827 142.614C37.8072 139.762 37.212 136.387 34.992 134.16L9.25738 108.345C6.07823 105.156 7.02639 99.7785 11.1045 97.8691L44.1164 82.4128C46.9642 81.0795 48.6778 78.1113 48.4087 74.9784L45.2883 38.6611C44.9028 34.1747 49.0856 30.6649 53.437 31.8236L88.6606 41.203C91.6992 42.0121 94.9198 40.8399 96.7274 38.2669L117.681 8.44054Z" fill="url(#paint1_linear_1378_2496)"/>
<path d="M117.681 8.44054C120.27 4.75592 125.73 4.75592 128.319 8.44054L149.273 38.2669C151.08 40.8399 154.301 42.0121 157.339 41.203L192.563 31.8236C196.914 30.6649 201.097 34.1747 200.712 38.6612L197.591 74.9784C197.322 78.1113 199.036 81.0795 201.884 82.4128L234.895 97.8691C238.974 99.7785 239.922 105.156 236.743 108.345L211.008 134.16C208.788 136.387 208.193 139.762 209.517 142.614L224.871 175.674C226.767 179.758 224.037 184.486 219.552 184.886L183.245 188.119C180.113 188.398 177.487 190.601 176.669 193.637L167.18 228.832C166.007 233.179 160.876 235.047 157.184 232.47L127.292 211.609C124.714 209.809 121.286 209.809 118.708 211.609L88.8163 232.47C85.1236 235.047 79.9927 233.179 78.8204 228.832L69.3314 193.637C68.5129 190.601 65.8873 188.398 62.7553 188.119L26.4479 184.886C21.9627 184.486 19.2326 179.758 21.1293 175.674L36.4827 142.614C37.8072 139.762 37.212 136.387 34.992 134.16L9.25738 108.345C6.07823 105.156 7.02639 99.7785 11.1045 97.8691L44.1164 82.4128C46.9642 81.0795 48.6778 78.1113 48.4087 74.9784L45.2883 38.6611C44.9028 34.1747 49.0856 30.6649 53.437 31.8236L88.6606 41.203C91.6992 42.0121 94.9198 40.8399 96.7274 38.2669L117.681 8.44054Z" stroke="url(#paint2_linear_1378_2496)"/>
<g opacity="0.9">
<g style="mix-blend-mode:overlay">
<path d="M113.207 107.103L113.873 107.307L114.295 106.754C120.652 98.4238 129.53 87.9999 139.582 79.6678C149.661 71.3136 160.772 65.1865 171.609 65.1865C172.421 65.1865 173.2 65.5092 173.775 66.0835C174.349 66.6578 174.672 67.4368 174.672 68.249C174.672 79.0868 168.546 90.1972 160.192 100.276C151.861 110.328 141.437 119.207 133.105 125.563L132.551 125.985L132.755 126.651C134.104 131.057 134.402 135.718 133.623 140.26C132.844 144.802 131.011 149.098 128.271 152.803C125.531 156.508 121.96 159.519 117.845 161.594C113.73 163.668 109.186 164.749 104.578 164.749H65.9851C65.3296 164.748 64.6916 164.538 64.1649 164.147C63.6381 163.757 63.2505 163.208 63.059 162.581C62.8674 161.954 62.8821 161.283 63.1008 160.665C63.3195 160.047 63.7307 159.515 64.274 159.149L64.2813 159.144C64.8739 158.736 75.1093 151.439 75.1093 135.28C75.1093 130.672 76.19 126.128 78.2646 122.013C80.3393 117.898 83.35 114.328 87.055 111.587C90.76 108.847 95.056 107.014 99.598 106.235C104.14 105.457 108.801 105.754 113.207 107.103ZM120.25 109.05L119.585 109.911L120.499 110.501C124.047 112.792 127.067 115.811 129.357 119.359L129.948 120.275L130.809 119.608C133.07 117.857 135.204 116.141 137.212 114.46L137.904 113.881L137.399 113.133C134.556 108.926 130.933 105.302 126.725 102.459L125.979 101.955L125.4 102.645C123.714 104.65 121.997 106.785 120.25 109.05ZM141.909 108.878L142.566 109.805L143.402 109.036C161.044 92.7992 166.529 80.4502 168.029 72.9963L168.328 71.5152L166.848 71.8195C159.403 73.3505 147.056 78.8076 130.817 96.4511L130.048 97.2872L130.975 97.9442C135.21 100.946 138.907 104.643 141.909 108.878Z" fill="black"/>
<path d="M113.207 107.103L113.873 107.307L114.295 106.754C120.652 98.4238 129.53 87.9999 139.582 79.6678C149.661 71.3136 160.772 65.1865 171.609 65.1865C172.421 65.1865 173.2 65.5092 173.775 66.0835C174.349 66.6578 174.672 67.4368 174.672 68.249C174.672 79.0868 168.546 90.1972 160.192 100.276C151.861 110.328 141.437 119.207 133.105 125.563L132.551 125.985L132.755 126.651C134.104 131.057 134.402 135.718 133.623 140.26C132.844 144.802 131.011 149.098 128.271 152.803C125.531 156.508 121.96 159.519 117.845 161.594C113.73 163.668 109.186 164.749 104.578 164.749H65.9851C65.3296 164.748 64.6916 164.538 64.1649 164.147C63.6381 163.757 63.2505 163.208 63.059 162.581C62.8674 161.954 62.8821 161.283 63.1008 160.665C63.3195 160.047 63.7307 159.515 64.274 159.149L64.2813 159.144C64.8739 158.736 75.1093 151.439 75.1093 135.28C75.1093 130.672 76.19 126.128 78.2646 122.013C80.3393 117.898 83.35 114.328 87.055 111.587C90.76 108.847 95.056 107.014 99.598 106.235C104.14 105.457 108.801 105.754 113.207 107.103ZM120.25 109.05L119.585 109.911L120.499 110.501C124.047 112.792 127.067 115.811 129.357 119.359L129.948 120.275L130.809 119.608C133.07 117.857 135.204 116.141 137.212 114.46L137.904 113.881L137.399 113.133C134.556 108.926 130.933 105.302 126.725 102.459L125.979 101.955L125.4 102.645C123.714 104.65 121.997 106.785 120.25 109.05ZM141.909 108.878L142.566 109.805L143.402 109.036C161.044 92.7992 166.529 80.4502 168.029 72.9963L168.328 71.5152L166.848 71.8195C159.403 73.3505 147.056 78.8076 130.817 96.4511L130.048 97.2872L130.975 97.9442C135.21 100.946 138.907 104.643 141.909 108.878Z" stroke="url(#paint3_linear_1378_2496)" stroke-width="2"/>
</g>
</g>
<defs>
<linearGradient id="paint0_linear_1378_2496" x1="5.63736e-07" y1="12.92" x2="246" y2="233.08" gradientUnits="userSpaceOnUse">
<stop stop-color="#0CF1CA"/>
<stop offset="1" stop-color="#1DCCEB"/>
</linearGradient>
<linearGradient id="paint1_linear_1378_2496" x1="19.8951" y1="-3.50306e-06" x2="226.105" y2="246" gradientUnits="userSpaceOnUse">
<stop stop-color="#0DDEBB"/>
<stop offset="1" stop-color="#052520"/>
</linearGradient>
<linearGradient id="paint2_linear_1378_2496" x1="-1.9947e-06" y1="18.0561" x2="246" y2="227.944" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.7"/>
<stop offset="1" stop-color="white" stop-opacity="0.1"/>
</linearGradient>
<linearGradient id="paint3_linear_1378_2496" x1="61.9253" y1="71.6411" x2="164.664" y2="169.814" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0.2"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 7.8 KiB

View file

@ -6,7 +6,10 @@ export interface BackdropProps {
children: React.ReactNode;
}
export function Backdrop({ isClosing = false, children }: BackdropProps) {
export function Backdrop({
isClosing = false,
children,
}: Readonly<BackdropProps>) {
return (
<div
className={cn("backdrop", {

View file

@ -15,7 +15,7 @@ export function Button({
theme = "primary",
className,
...props
}: ButtonProps) {
}: Readonly<ButtonProps>) {
return (
<button
type="button"

View file

@ -23,6 +23,7 @@
&__content {
display: flex;
flex-direction: column;
flex: 1;
padding: calc(globals.$spacing-unit * 2);
gap: calc(globals.$spacing-unit * 2);
width: 100%;
@ -54,6 +55,7 @@
display: flex;
color: globals.$muted-color;
border-radius: 4px;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
}
@ -104,13 +106,14 @@
&__container {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
&__section {
gap: calc(globals.$spacing-unit * 2);
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
padding-bottom: globals.$spacing-unit;
}

View file

@ -167,6 +167,10 @@ export function Sidebar() {
}
};
const favoriteGames = useMemo(() => {
return sortedLibrary.filter((game) => game.favorite);
}, [sortedLibrary]);
return (
<aside
ref={sidebarRef}
@ -206,13 +210,12 @@ export function Sidebar() {
</ul>
</section>
<section className="sidebar__section">
<small className="sidebar__section-title">{t("favorites")}</small>
{favoriteGames.length > 0 && (
<section className="sidebar__section">
<small className="sidebar__section-title">{t("favorites")}</small>
<ul className="sidebar__menu">
{sortedLibrary
.filter((game) => game.favorite)
.map((game) => (
<ul className="sidebar__menu">
{favoriteGames.map((game) => (
<SidebarGameItem
key={game.id}
game={game}
@ -220,8 +223,9 @@ export function Sidebar() {
getGameTitle={getGameTitle}
/>
))}
</ul>
</section>
</ul>
</section>
)}
<section className="sidebar__section">
<small className="sidebar__section-title">{t("my_library")}</small>

View file

@ -7,8 +7,9 @@
background-color: globals.$dark-background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
right: 16px;
bottom: 26px + globals.$spacing-unit;
right: calc(globals.$spacing-unit * 2);
// 28px is the height of the bottom panel
bottom: calc(28px + globals.$spacing-unit * 2);
overflow: hidden;
display: flex;
flex-direction: column;

View file

@ -1,6 +1,6 @@
import { Downloader } from "@shared";
export const VERSION_CODENAME = "Spectre";
export const VERSION_CODENAME = "Polychrome";
export const DOWNLOADER_NAME = {
[Downloader.RealDebrid]: "Real-Debrid",
@ -14,3 +14,5 @@ export const DOWNLOADER_NAME = {
};
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
export const THEME_WEB_STORE_URL = "https://hydrathemes.shop";

View file

@ -9,20 +9,32 @@ export interface SettingsContext {
updateUserPreferences: (values: Partial<UserPreferences>) => Promise<void>;
setCurrentCategoryIndex: React.Dispatch<React.SetStateAction<number>>;
clearSourceUrl: () => void;
clearTheme: () => void;
sourceUrl: string | null;
currentCategoryIndex: number;
blockedUsers: UserBlocks["blocks"];
fetchBlockedUsers: () => Promise<void>;
appearance: {
theme: string | null;
authorId: string | null;
authorName: string | null;
};
}
export const settingsContext = createContext<SettingsContext>({
updateUserPreferences: async () => {},
setCurrentCategoryIndex: () => {},
clearSourceUrl: () => {},
clearTheme: () => {},
sourceUrl: null,
currentCategoryIndex: 0,
blockedUsers: [],
fetchBlockedUsers: async () => {},
appearance: {
theme: null,
authorId: null,
authorName: null,
},
});
const { Provider } = settingsContext;
@ -34,15 +46,26 @@ export interface SettingsContextProviderProps {
export function SettingsContextProvider({
children,
}: SettingsContextProviderProps) {
}: Readonly<SettingsContextProviderProps>) {
const dispatch = useAppDispatch();
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
const [appearance, setAppearance] = useState<{
theme: string | null;
authorId: string | null;
authorName: string | null;
}>({
theme: null,
authorId: null,
authorName: null,
});
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
const [blockedUsers, setBlockedUsers] = useState<UserBlocks["blocks"]>([]);
const [searchParams] = useSearchParams();
const defaultSourceUrl = searchParams.get("urls");
const defaultAppearanceTheme = searchParams.get("theme");
const defaultAppearanceAuthorId = searchParams.get("authorId");
const defaultAppearanceAuthorName = searchParams.get("authorName");
useEffect(() => {
if (sourceUrl) setCurrentCategoryIndex(2);
@ -54,6 +77,36 @@ export function SettingsContextProvider({
}
}, [defaultSourceUrl]);
useEffect(() => {
if (appearance.theme) setCurrentCategoryIndex(3);
}, [appearance.theme]);
useEffect(() => {
if (
defaultAppearanceTheme &&
defaultAppearanceAuthorId &&
defaultAppearanceAuthorName
) {
setAppearance({
theme: defaultAppearanceTheme,
authorId: defaultAppearanceAuthorId,
authorName: defaultAppearanceAuthorName,
});
}
}, [
defaultAppearanceTheme,
defaultAppearanceAuthorId,
defaultAppearanceAuthorName,
]);
const clearTheme = useCallback(() => {
setAppearance({
theme: null,
authorId: null,
authorName: null,
});
}, []);
const fetchBlockedUsers = useCallback(async () => {
const blockedUsers = await window.electron.getBlockedUsers(12, 0);
setBlockedUsers(blockedUsers.blocks);
@ -79,9 +132,11 @@ export function SettingsContextProvider({
setCurrentCategoryIndex,
clearSourceUrl,
fetchBlockedUsers,
clearTheme,
currentCategoryIndex,
sourceUrl,
blockedUsers,
appearance,
}}
>
{children}

View file

@ -41,7 +41,7 @@ export interface UserProfileContextProviderProps {
export function UserProfileContextProvider({
children,
userId,
}: UserProfileContextProviderProps) {
}: Readonly<UserProfileContextProviderProps>) {
const { userDetails } = useAppSelector((state) => state.userDetails);
const [userStats, setUserStats] = useState<UserStats | null>(null);

View file

@ -29,6 +29,7 @@ import type {
LibraryGame,
GameRunning,
TorBoxUser,
Theme,
} from "@types";
import type { AxiosProgressEvent } from "axios";
import type disk from "diskusage";
@ -279,6 +280,23 @@ declare global {
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
/* Themes */
addCustomTheme: (theme: Theme) => Promise<void>;
getAllCustomThemes: () => Promise<Theme[]>;
deleteAllCustomThemes: () => Promise<void>;
deleteCustomTheme: (themeId: string) => Promise<void>;
updateCustomTheme: (themeId: string, code: string) => Promise<void>;
getCustomThemeById: (themeId: string) => Promise<Theme | null>;
getActiveCustomTheme: () => Promise<Theme | null>;
toggleCustomTheme: (themeId: string, isActive: boolean) => Promise<void>;
/* Editor */
openEditorWindow: (themeId: string) => Promise<void>;
onCssInjected: (
cb: (cssString: string) => void
) => () => Electron.IpcRenderer;
closeEditorWindow: (themeId?: string) => Promise<void>;
}
interface Window {

View file

@ -26,7 +26,7 @@ export const toastSlice = createSlice({
state.title = action.payload.title;
state.message = action.payload.message;
state.type = action.payload.type;
state.duration = action.payload.duration ?? 5000;
state.duration = action.payload.duration ?? 2000;
state.visible = true;
},
closeToast: (state) => {

View file

@ -1,6 +1,7 @@
import type { GameShop } from "@types";
import Color from "color";
import { THEME_WEB_STORE_URL } from "./constants";
export const formatDownloadProgress = (
progress?: number,
@ -53,3 +54,36 @@ export const buildGameAchievementPath = (
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
new Color(color).darken(amount).alpha(alpha).toString();
export const injectCustomCss = (css: string) => {
try {
const currentCustomCss = document.getElementById("custom-css");
if (currentCustomCss) {
currentCustomCss.remove();
}
if (css.startsWith(THEME_WEB_STORE_URL)) {
const link = document.createElement("link");
link.id = "custom-css";
link.rel = "stylesheet";
link.href = css;
document.head.appendChild(link);
} else {
const style = document.createElement("style");
style.id = "custom-css";
style.textContent = `
${css}
`;
document.head.appendChild(style);
}
} catch (error) {
console.error("failed to inject custom css:", error);
}
};
export const removeCustomCss = () => {
const currentCustomCss = document.getElementById("custom-css");
if (currentCustomCss) {
currentCustomCss.remove();
}
};

View file

@ -39,7 +39,7 @@ export function useDownload() {
const pauseDownload = async (shop: GameShop, objectId: string) => {
await window.electron.pauseGameDownload(shop, objectId);
await updateLibrary();
dispatch(clearDownload());
if (lastPacket?.gameId === `${shop}:${objectId}`) dispatch(clearDownload());
};
const resumeDownload = async (shop: GameShop, objectId: string) => {

View file

@ -1,18 +1,26 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
enum Feature {
CheckDownloadWritePermission = "CHECK_DOWNLOAD_WRITE_PERMISSION",
Torbox = "TORBOX",
}
export function useFeature() {
const [features, setFeatures] = useState<string[] | null>(null);
useEffect(() => {
window.electron.getFeatures().then((features) => {
localStorage.setItem("features", JSON.stringify(features || []));
setFeatures(features || []);
});
}, []);
const isFeatureEnabled = (feature: Feature) => {
const features = JSON.parse(localStorage.getItem("features") || "[]");
if (!features) {
const features = JSON.parse(localStorage.getItem("features") ?? "[]");
return features.includes(feature);
}
return features.includes(feature);
};

View file

@ -18,23 +18,17 @@ import { store } from "./store";
import resources from "@locales";
import { SuspenseWrapper } from "./components";
import { logger } from "./logger";
import { addCookieInterceptor } from "./cookies";
const Home = React.lazy(() => import("./pages/home/home"));
const GameDetails = React.lazy(
() => import("./pages/game-details/game-details")
);
const Downloads = React.lazy(() => import("./pages/downloads/downloads"));
const Settings = React.lazy(() => import("./pages/settings/settings"));
const Catalogue = React.lazy(() => import("./pages/catalogue/catalogue"));
const Profile = React.lazy(() => import("./pages/profile/profile"));
const Achievements = React.lazy(
() => import("./pages/achievements/achievements")
);
import * as Sentry from "@sentry/react";
import Catalogue from "./pages/catalogue/catalogue";
import Home from "./pages/home/home";
import Downloads from "./pages/downloads/downloads";
import GameDetails from "./pages/game-details/game-details";
import Settings from "./pages/settings/settings";
import Profile from "./pages/profile/profile";
import Achievements from "./pages/achievements/achievements";
import ThemeEditor from "./pages/theme-editor/theme-editor";
Sentry.init({
dsn: import.meta.env.RENDERER_VITE_SENTRY_DSN,
@ -79,32 +73,16 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<HashRouter>
<Routes>
<Route element={<App />}>
<Route path="/" element={<SuspenseWrapper Component={Home} />} />
<Route
path="/catalogue"
element={<SuspenseWrapper Component={Catalogue} />}
/>
<Route
path="/downloads"
element={<SuspenseWrapper Component={Downloads} />}
/>
<Route
path="/game/:shop/:objectId"
element={<SuspenseWrapper Component={GameDetails} />}
/>
<Route
path="/settings"
element={<SuspenseWrapper Component={Settings} />}
/>
<Route
path="/profile/:userId"
element={<SuspenseWrapper Component={Profile} />}
/>
<Route
path="/achievements"
element={<SuspenseWrapper Component={Achievements} />}
/>
<Route path="/" element={<Home />} />
<Route path="/catalogue" element={<Catalogue />} />
<Route path="/downloads" element={<Downloads />} />
<Route path="/game/:shop/:objectId" element={<GameDetails />} />
<Route path="/settings" element={<Settings />} />
<Route path="/profile/:userId" element={<Profile />} />
<Route path="/achievements" element={<Achievements />} />
</Route>
<Route path="/theme-editor" element={<ThemeEditor />} />
</Routes>
</HashRouter>
</Provider>

View file

@ -12,7 +12,7 @@ export function DeleteGameModal({
onClose,
visible,
deleteGame,
}: DeleteGameModalProps) {
}: Readonly<DeleteGameModalProps>) {
const { t } = useTranslation("downloads");
const handleDeleteGame = () => {

View file

@ -5,6 +5,14 @@
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
&__details-with-article {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
align-self: flex-start;
cursor: pointer;
}
&__header {
display: flex;
align-items: center;

View file

@ -24,6 +24,7 @@ import {
DownloadIcon,
LinkIcon,
PlayIcon,
QuestionIcon,
ThreeBarsIcon,
TrashIcon,
UnlinkIcon,
@ -31,6 +32,7 @@ import {
} from "@primer/octicons-react";
import torBoxLogo from "@renderer/assets/icons/torbox.webp";
export interface DownloadGroupProps {
library: LibraryGame[];
title: string;
@ -122,8 +124,12 @@ export function DownloadGroup({
</p>
{download.downloader === Downloader.Torrent && (
<small>
<small
className="download-group__details-with-article"
data-open-article="peers-and-seeds"
>
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
<QuestionIcon size={12} />
</small>
)}
</>
@ -136,7 +142,14 @@ export function DownloadGroup({
return download.status === "seeding" &&
download.downloader === Downloader.Torrent ? (
<>
<p>{t("seeding")}</p>
<p
data-open-article="seeding"
className="download-group__details-with-article"
>
{t("seeding")}
<QuestionIcon />
</p>
{uploadSpeed && <p>{uploadSpeed}/s</p>}
</>
) : (
@ -174,7 +187,7 @@ export function DownloadGroup({
const deleting = isGameDeleting(game.id);
if (download?.progress === 1) {
if (game.download?.progress === 1) {
return [
{
label: t("install"),
@ -189,8 +202,8 @@ export function DownloadGroup({
disabled: deleting,
icon: <UnlinkIcon />,
show:
download.status === "seeding" &&
download.downloader === Downloader.Torrent,
game.download?.status === "seeding" &&
game.download?.downloader === Downloader.Torrent,
onClick: () => {
pauseSeeding(game.shop, game.objectId);
},
@ -200,8 +213,8 @@ export function DownloadGroup({
disabled: deleting,
icon: <LinkIcon />,
show:
download.status !== "seeding" &&
download.downloader === Downloader.Torrent,
game.download?.status !== "seeding" &&
game.download?.downloader === Downloader.Torrent,
onClick: () => {
resumeSeeding(game.shop, game.objectId);
},
@ -217,7 +230,7 @@ export function DownloadGroup({
];
}
if (isGameDownloading || download?.status === "active") {
if (isGameDownloading) {
return [
{
label: t("pause"),

View file

@ -8,7 +8,7 @@ import "./downloads.scss";
import { DeleteGameModal } from "./delete-game-modal";
import { DownloadGroup } from "./download-group";
import type { GameShop, LibraryGame, SeedingStatus } from "@types";
import { orderBy, sortBy } from "lodash-es";
import { orderBy } from "lodash-es";
import { ArrowDownIcon } from "@primer/octicons-react";
export default function Downloads() {
@ -58,24 +58,24 @@ export default function Downloads() {
complete: [],
};
const result = sortBy(library, (game) => game.download?.timestamp).reduce(
(prev, next) => {
/* Game has been manually added to the library or has been canceled */
if (!next.download?.status || next.download?.status === "removed")
return prev;
const result = orderBy(
library,
(game) => game.download?.timestamp,
"desc"
).reduce((prev, next) => {
/* Game has been manually added to the library */
if (!next.download) return prev;
/* Is downloading */
if (lastPacket?.gameId === next.id)
return { ...prev, downloading: [...prev.downloading, next] };
/* Is downloading */
if (lastPacket?.gameId === next.id)
return { ...prev, downloading: [...prev.downloading, next] };
/* Is either queued or paused */
if (next.download.queued || next.download?.status === "paused")
return { ...prev, queued: [...prev.queued, next] };
/* Is either queued or paused */
if (next.download.queued || next.download?.status === "paused")
return { ...prev, queued: [...prev.queued, next] };
return { ...prev, complete: [...prev.complete, next] };
},
initialValue
);
return { ...prev, complete: [...prev.complete, next] };
}, initialValue);
const queued = orderBy(result.queued, (game) => game.download?.timestamp, [
"desc",

View file

@ -16,13 +16,8 @@ import { useUserDetails } from "@renderer/hooks";
import { useSubscription } from "@renderer/hooks/use-subscription";
import "./game-details.scss";
const HERO_HEIGHT = 300;
const HERO_ANIMATION_THRESHOLD = 25;
export function GameDetailsContent() {
const heroRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [isHeaderStuck, setIsHeaderStuck] = useState(false);
const { t } = useTranslation("game_details");
@ -61,7 +56,7 @@ export function GameDetailsContent() {
return t("no_shop_details");
}, [shopDetails, t]);
const [backdropOpactiy, setBackdropOpacity] = useState(1);
const [backdropOpacity, setBackdropOpacity] = useState(1);
const handleHeroLoad = async () => {
const output = await average(steamUrlBuilder.libraryHero(objectId!), {
@ -80,26 +75,6 @@ export function GameDetailsContent() {
setBackdropOpacity(1);
}, [objectId]);
const onScroll: React.UIEventHandler<HTMLElement> = (event) => {
const heroHeight = heroRef.current?.clientHeight ?? HERO_HEIGHT;
const scrollY = (event.target as HTMLDivElement).scrollTop;
const opacity = Math.max(
0,
1 - scrollY / (heroHeight - HERO_ANIMATION_THRESHOLD)
);
if (scrollY >= heroHeight && !isHeaderStuck) {
setIsHeaderStuck(true);
}
if (scrollY <= heroHeight && isHeaderStuck) {
setIsHeaderStuck(false);
}
setBackdropOpacity(opacity);
};
const handleCloudSaveButtonClick = () => {
if (!userDetails) {
window.electron.openAuthWindow(AuthPage.SignIn);
@ -122,31 +97,25 @@ export function GameDetailsContent() {
<div
className={`game-details__wrapper ${hasNSFWContentBlocked ? "game-details__wrapper--blurred" : ""}`}
>
<img
src={steamUrlBuilder.libraryHero(objectId!)}
className="game-details__hero-image"
alt={game?.title}
onLoad={handleHeroLoad}
/>
<section
ref={containerRef}
onScroll={onScroll}
className="game-details__container"
>
<section className="game-details__container">
<div ref={heroRef} className="game-details__hero">
<img
src={steamUrlBuilder.libraryHero(objectId!)}
className="game-details__hero-image"
alt={game?.title}
onLoad={handleHeroLoad}
/>
<div
className="game-details__hero-backdrop"
style={{
backgroundColor: gameColor,
flex: 1,
opacity: Math.min(1, 1 - backdropOpactiy),
}}
/>
<div
className="game-details__hero-logo-backdrop"
style={{ opacity: backdropOpactiy }}
style={{ opacity: backdropOpacity }}
>
<div className="game-details__hero-content">
<img
@ -173,7 +142,7 @@ export function GameDetailsContent() {
</div>
</div>
<HeroPanel isHeaderStuck={isHeaderStuck} />
<HeroPanel />
<div className="game-details__description-container">
<div className="game-details__description-content">

View file

@ -7,10 +7,11 @@ import {
PlusCircleIcon,
} from "@primer/octicons-react";
import { Button } from "@renderer/components";
import { useDownload, useLibrary } from "@renderer/hooks";
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { gameDetailsContext } from "@renderer/context";
import "./hero-panel-actions.scss";
export function HeroPanelActions() {
@ -39,6 +40,8 @@ export function HeroPanelActions() {
const { updateLibrary } = useLibrary();
const { showSuccessToast } = useToast();
const { t } = useTranslation("game_details");
const addGameToLibrary = async () => {
@ -54,25 +57,24 @@ export function HeroPanelActions() {
}
};
const addGameToFavorites = async () => {
const toggleGameFavorite = async () => {
setToggleLibraryGameDisabled(true);
try {
if (!objectId) throw new Error("objectId is required");
await window.electron.addGameToFavorites(shop, objectId);
updateLibrary();
updateGame();
} finally {
setToggleLibraryGameDisabled(false);
}
};
if (game?.favorite && objectId) {
await window.electron
.removeGameFromFavorites(shop, objectId)
.then(() => {
showSuccessToast(t("game_removed_from_favorites"));
});
} else {
if (!objectId) return;
const removeGameFromFavorites = async () => {
setToggleLibraryGameDisabled(true);
await window.electron.addGameToFavorites(shop, objectId).then(() => {
showSuccessToast(t("game_added_to_favorites"));
});
}
try {
if (!objectId) throw new Error("objectId is required");
await window.electron.removeGameFromFavorites(shop, objectId);
updateLibrary();
updateGame();
} finally {
@ -188,7 +190,7 @@ export function HeroPanelActions() {
{gameActionButton()}
<div className="hero-panel-actions__separator" />
<Button
onClick={game.favorite ? removeGameFromFavorites : addGameToFavorites}
onClick={toggleGameFavorite}
theme="outline"
disabled={deleting}
className="hero-panel-actions__action"
@ -196,7 +198,6 @@ export function HeroPanelActions() {
{game.favorite ? <HeartFillIcon /> : <HeartIcon />}
</Button>
<Button
onClick={() => setShowGameOptionsModal(true)}
theme="outline"

View file

@ -9,11 +9,7 @@ import { HeroPanelPlaytime } from "./hero-panel-playtime";
import { gameDetailsContext } from "@renderer/context";
import "./hero-panel.scss";
export interface HeroPanelProps {
isHeaderStuck: boolean;
}
export function HeroPanel({ isHeaderStuck }: HeroPanelProps) {
export function HeroPanel() {
const { t } = useTranslation("game_details");
const { formatDate } = useDate();
@ -54,10 +50,7 @@ export function HeroPanel({ isHeaderStuck }: HeroPanelProps) {
game?.download?.status === "paused";
return (
<div
style={{ backgroundColor: gameColor }}
className={`hero-panel ${isHeaderStuck ? "hero-panel--stuck" : ""}`}
>
<div style={{ backgroundColor: gameColor }} className="hero-panel">
<div className="hero-panel__content">{getInfo()}</div>
<div className="hero-panel__actions">
<HeroPanelActions />

View file

@ -44,10 +44,9 @@ export function DownloadSettingsModal({
(state) => state.userPreferences.value
);
const getDiskFreeSpace = (path: string) => {
window.electron.getDiskFreeSpace(path).then((result) => {
setDiskFreeSpace(result.free);
});
const getDiskFreeSpace = async (path: string) => {
const result = await window.electron.getDiskFreeSpace(path);
setDiskFreeSpace(result.free);
};
const checkFolderWritePermission = useCallback(
@ -100,6 +99,7 @@ export function DownloadSettingsModal({
userPreferences?.downloadsPath,
downloaders,
userPreferences?.realDebridApiToken,
userPreferences?.torBoxApiToken,
]);
const handleChooseDownloadsPath = async () => {
@ -155,25 +155,30 @@ export function DownloadSettingsModal({
<span>{t("downloader")}</span>
<div className="download-settings-modal__downloaders">
{downloaders.map((downloader) => (
<Button
key={downloader}
className="download-settings-modal__downloader-option"
theme={
selectedDownloader === downloader ? "primary" : "outline"
}
disabled={
downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken
}
onClick={() => setSelectedDownloader(downloader)}
>
{selectedDownloader === downloader && (
<CheckCircleFillIcon className="download-settings-modal__downloader-icon" />
)}
{DOWNLOADER_NAME[downloader]}
</Button>
))}
{downloaders.map((downloader) => {
const shouldDisableButton =
(downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken) ||
(downloader === Downloader.TorBox &&
!userPreferences?.torBoxApiToken);
return (
<Button
key={downloader}
className="download-settings-modal__downloader-option"
theme={
selectedDownloader === downloader ? "primary" : "outline"
}
disabled={shouldDisableButton}
onClick={() => setSelectedDownloader(downloader)}
>
{selectedDownloader === downloader && (
<CheckCircleFillIcon className="download-settings-modal__downloader-icon" />
)}
{DOWNLOADER_NAME[downloader]}
</Button>
);
})}
</div>
</div>

View file

@ -65,6 +65,12 @@
overflow: hidden;
}
&__display-name-container {
display: flex;
gap: globals.$spacing-unit;
align-items: center;
}
&__display-name {
font-weight: bold;
overflow: hidden;
@ -76,6 +82,12 @@
text-shadow: 0 0 5px rgb(0 0 0 / 40%);
}
&__display-name-badges-container {
display: flex;
gap: globals.$spacing-unit;
align-items: center;
}
&__current-game {
&-wrapper {
display: flex;

View file

@ -25,6 +25,7 @@ import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
import Skeleton from "react-loading-skeleton";
import { UploadBackgroundImageButton } from "../upload-background-image-button/upload-background-image-button";
import "./profile-hero.scss";
import { UserBadges } from "./user-badges";
type FriendAction =
| FriendRequestAction
@ -307,9 +308,12 @@ export function ProfileHero() {
<div className="profile-hero__information">
{userProfile ? (
<h2 className="profile-hero__display-name">
{userProfile?.displayName}
</h2>
<div className="profile-hero__display-name-container">
<h2 className="profile-hero__display-name">
{userProfile?.displayName}
</h2>
<UserBadges />
</div>
) : (
<Skeleton width={150} height={28} />
)}

View file

@ -0,0 +1,40 @@
import BadgeThemeCreator from "@renderer/assets/icons/badge-theme-creator.svg?react";
import "./profile-hero.scss";
import { useContext } from "react";
import { userProfileContext } from "@renderer/context";
import { UserBadge } from "@types";
import { useTranslation } from "react-i18next";
export function UserBadges() {
const { t } = useTranslation("badge");
const { userProfile } = useContext(userProfileContext);
if (!userProfile?.badges?.length) return null;
const getBadgeIcon = (badge: UserBadge) => {
if (badge === "THEME_CREATOR") {
return <BadgeThemeCreator width={24} height={24} />;
}
return null;
};
return (
<div className="profile-hero__display-name-badges-container">
{userProfile.badges.map((badge) => {
const badgeIcon = getBadgeIcon(badge);
if (!badgeIcon) return null;
return (
<div
className={`badge__${badge.toLowerCase()}`}
key={badge}
title={t(`badge_description_${badge.toLowerCase()}`)}
>
{badgeIcon}
</div>
);
})}
</div>
);
}

View file

@ -74,7 +74,10 @@ export function ReportProfile() {
title={t("report_profile")}
clickOutsideToClose={false}
>
<form className="report-profile__form">
<form
onSubmit={handleSubmit(onSubmit)}
className="report-profile__form"
>
<Controller
control={control}
name="reason"
@ -101,12 +104,7 @@ export function ReportProfile() {
error={errors.description?.message}
/>
<Button
className="report-profile__submit"
onClick={handleSubmit(onSubmit)}
>
{t("report")}
</Button>
<Button className="report-profile__submit">{t("report")}</Button>
</form>
</Modal>

View file

@ -26,7 +26,7 @@ export function AddDownloadSourceModal({
visible,
onClose,
onAddDownloadSource,
}: AddDownloadSourceModalProps) {
}: Readonly<AddDownloadSourceModalProps>) {
const [url, setUrl] = useState("");
const [isLoading, setIsLoading] = useState(false);

View file

@ -0,0 +1,25 @@
@use "../../../../scss/globals.scss";
.settings-appearance {
&__actions {
display: flex;
justify-content: space-between;
align-items: center;
&-left {
display: flex;
gap: 8px;
}
&-right {
display: flex;
gap: 8px;
}
}
&__button {
display: flex;
align-items: center;
gap: 8px;
}
}

View file

@ -0,0 +1,75 @@
import { GlobeIcon, TrashIcon, PlusIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components/button/button";
import { useTranslation } from "react-i18next";
import { AddThemeModal, DeleteAllThemesModal } from "../index";
import "./theme-actions.scss";
import { useState } from "react";
import { THEME_WEB_STORE_URL } from "@renderer/constants";
interface ThemeActionsProps {
onListUpdated: () => void;
themesCount: number;
}
export const ThemeActions = ({
onListUpdated,
themesCount,
}: ThemeActionsProps) => {
const { t } = useTranslation("settings");
const [addThemeModalVisible, setAddThemeModalVisible] = useState(false);
const [deleteAllThemesModalVisible, setDeleteAllThemesModalVisible] =
useState(false);
return (
<>
<AddThemeModal
visible={addThemeModalVisible}
onClose={() => setAddThemeModalVisible(false)}
onThemeAdded={onListUpdated}
/>
<DeleteAllThemesModal
visible={deleteAllThemesModalVisible}
onClose={() => setDeleteAllThemesModalVisible(false)}
onThemesDeleted={onListUpdated}
/>
<div className="settings-appearance__actions">
<div className="settings-appearance__actions-left">
<Button
theme="primary"
className="settings-appearance__button"
onClick={() => {
window.open(THEME_WEB_STORE_URL, "_blank");
}}
>
<GlobeIcon />
{t("web_store")}
</Button>
<Button
theme="danger"
className="settings-appearance__button"
onClick={() => setDeleteAllThemesModalVisible(true)}
disabled={themesCount < 1}
>
<TrashIcon />
{t("clear_themes")}
</Button>
</div>
<div className="settings-appearance__actions-right">
<Button
theme="outline"
className="settings-appearance__button"
onClick={() => setAddThemeModalVisible(true)}
>
<PlusIcon />
{t("create_theme")}
</Button>
</div>
</div>
</>
);
};

View file

@ -0,0 +1,97 @@
@use "../../../../scss/globals.scss";
.theme-card {
width: 100%;
min-height: 160px;
display: flex;
flex-direction: column;
background-color: rgba(globals.$border-color, 0.01);
border: 1px solid globals.$border-color;
border-radius: 12px;
gap: 4px;
transition: background-color 0.2s ease;
padding: 16px;
position: relative;
&--active {
background-color: rgba(globals.$border-color, 0.04);
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: row;
gap: 16px;
&__title {
font-size: 18px;
font-weight: 600;
color: globals.$muted-color;
text-transform: capitalize;
}
&__colors {
display: flex;
flex-direction: row;
gap: 8px;
&__color {
width: 16px;
height: 16px;
border-radius: 4px;
border: 1px solid globals.$border-color;
}
}
}
&__author {
font-size: 12px;
color: globals.$body-color;
font-weight: 400;
&__name {
font-weight: 600;
color: rgba(globals.$muted-color, 0.8);
margin-left: 4px;
&:hover {
color: globals.$muted-color;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
}
}
&__actions {
display: flex;
flex-direction: row;
position: absolute;
bottom: 16px;
left: 16px;
right: 16px;
gap: 8px;
justify-content: space-between;
&__left {
display: flex;
flex-direction: row;
gap: 8px;
}
&__right {
display: flex;
flex-direction: row;
gap: 8px;
&--external {
display: none;
}
Button {
padding: 8px 11px;
}
}
}
}

View file

@ -0,0 +1,130 @@
import { PencilIcon, TrashIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { Button } from "@renderer/components/button/button";
import type { Theme } from "@types";
import { useNavigate } from "react-router-dom";
import "./theme-card.scss";
import { useState } from "react";
import { DeleteThemeModal } from "../modals/delete-theme-modal";
import { injectCustomCss, removeCustomCss } from "@renderer/helpers";
import { THEME_WEB_STORE_URL } from "@renderer/constants";
interface ThemeCardProps {
theme: Theme;
onListUpdated: () => void;
}
export const ThemeCard = ({ theme, onListUpdated }: ThemeCardProps) => {
const { t } = useTranslation("settings");
const navigate = useNavigate();
const [deleteThemeModalVisible, setDeleteThemeModalVisible] = useState(false);
const handleSetTheme = async () => {
try {
const currentTheme = await window.electron.getCustomThemeById(theme.id);
if (!currentTheme) return;
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme) {
removeCustomCss();
await window.electron.toggleCustomTheme(activeTheme.id, false);
}
if (currentTheme.code) {
injectCustomCss(currentTheme.code);
}
await window.electron.toggleCustomTheme(currentTheme.id, true);
onListUpdated();
} catch (error) {
console.error(error);
}
};
const handleUnsetTheme = async () => {
try {
removeCustomCss();
await window.electron.toggleCustomTheme(theme.id, false);
onListUpdated();
} catch (error) {
console.error(error);
}
};
return (
<>
<DeleteThemeModal
visible={deleteThemeModalVisible}
onClose={() => setDeleteThemeModalVisible(false)}
onThemeDeleted={onListUpdated}
themeId={theme.id}
themeName={theme.name}
isActive={theme.isActive}
/>
<div
className={`theme-card ${theme.isActive ? "theme-card--active" : ""}`}
key={theme.name}
>
<div className="theme-card__header">
<div className="theme-card__header__title">{theme.name}</div>
</div>
{theme.authorName && (
<p className="theme-card__author">
{t("by")}
<button
className="theme-card__author__name"
onClick={() => navigate(`/profile/${theme.author}`)}
>
{theme.authorName}
</button>
</p>
)}
<div className="theme-card__actions">
<div className="theme-card__actions__left">
{theme.isActive ? (
<Button onClick={handleUnsetTheme} theme="dark">
{t("unset_theme")}
</Button>
) : (
<Button onClick={handleSetTheme} theme="outline">
{t("set_theme")}
</Button>
)}
</div>
<div className="theme-card__actions__right">
<Button
className={
theme.code.startsWith(THEME_WEB_STORE_URL)
? "theme-card__actions__right--external"
: ""
}
onClick={() => window.electron.openEditorWindow(theme.id)}
title={t("edit_theme")}
theme="outline"
>
<PencilIcon />
</Button>
<Button
onClick={() => setDeleteThemeModalVisible(true)}
title={t("delete_theme")}
theme="outline"
>
<TrashIcon />
</Button>
</div>
</div>
</div>
</>
);
};

View file

@ -0,0 +1,39 @@
@use "../../../../scss/globals.scss";
.theme-placeholder {
width: 100%;
min-height: 160px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 40px 24px;
background-color: rgba(globals.$border-color, 0.01);
cursor: pointer;
border: 1px dashed globals.$border-color;
border-radius: 12px;
gap: 12px;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(globals.$border-color, 0.03);
}
&__icon {
svg {
width: 32px;
height: 32px;
color: globals.$body-color;
opacity: 0.7;
}
}
&__text {
text-align: center;
max-width: 400px;
font-size: 14.5px;
line-height: 1.6;
font-weight: 400;
color: rgba(globals.$body-color, 0.85);
}
}

View file

@ -0,0 +1,36 @@
import { AlertIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import "./theme-placeholder.scss";
import { AddThemeModal } from "../modals/add-theme-modal";
import { useState } from "react";
interface ThemePlaceholderProps {
onListUpdated: () => void;
}
export const ThemePlaceholder = ({ onListUpdated }: ThemePlaceholderProps) => {
const { t } = useTranslation("settings");
const [addThemeModalVisible, setAddThemeModalVisible] = useState(false);
return (
<>
<AddThemeModal
visible={addThemeModalVisible}
onClose={() => setAddThemeModalVisible(false)}
onThemeAdded={onListUpdated}
/>
<button
className="theme-placeholder"
onClick={() => setAddThemeModalVisible(true)}
>
<div className="theme-placeholder__icon">
<AlertIcon />
</div>
<p className="theme-placeholder__text">{t("no_themes")}</p>
</button>
</>
);
};

View file

@ -0,0 +1,7 @@
export { SettingsAppearance } from "./settings-appearance";
export { AddThemeModal } from "./modals/add-theme-modal";
export { DeleteAllThemesModal } from "./modals/delete-all-themes-modal";
export { DeleteThemeModal } from "./modals/delete-theme-modal";
export { ThemeCard } from "./components/theme-card";
export { ThemePlaceholder } from "./components/theme-placeholder";
export { ThemeActions } from "./components/theme-actions";

View file

@ -0,0 +1,127 @@
import { Modal } from "@renderer/components/modal/modal";
import { TextField } from "@renderer/components/text-field/text-field";
import { Button } from "@renderer/components/button/button";
import { useTranslation } from "react-i18next";
import { useUserDetails } from "@renderer/hooks";
import { Theme } from "@types";
import { useForm } from "react-hook-form";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import { useCallback } from "react";
import "./modals.scss";
interface AddThemeModalProps {
visible: boolean;
onClose: () => void;
onThemeAdded: () => void;
}
interface FormValues {
name: string;
}
const DEFAULT_THEME_CODE = `
/*
Here you can edit CSS for your theme and apply it on Hydra.
There are a few classes already in place, you can use them to style the launcher.
If you want to learn more about how to run Hydra in dev mode (which will allow you to inspect the DOM and view the classes)
or how to publish your theme in the theme store, you can check the docs:
https://docs.hydralauncher.gg/
Happy hacking!
*/
/* Header */
.header {}
/* Sidebar */
.sidebar {}
/* Main content */
.container__content {}
/* Bottom panel */
.bottom-panel {}
/* Toast */
.toast {}
/* Button */
.button {}
`;
export function AddThemeModal({
visible,
onClose,
onThemeAdded,
}: Readonly<AddThemeModalProps>) {
const { t } = useTranslation("settings");
const { userDetails } = useUserDetails();
const schema = yup.object({
name: yup
.string()
.required(t("required_field"))
.min(3, t("name_min_length")),
});
const {
register,
handleSubmit,
reset,
formState: { isSubmitting, errors },
} = useForm<FormValues>({
resolver: yupResolver(schema),
});
const onSubmit = useCallback(
async (values: FormValues) => {
const theme: Theme = {
id: crypto.randomUUID(),
name: values.name,
isActive: false,
author: userDetails?.id,
authorName: userDetails?.username,
code: DEFAULT_THEME_CODE,
createdAt: new Date(),
updatedAt: new Date(),
};
await window.electron.addCustomTheme(theme);
onThemeAdded();
onClose();
reset();
},
[onClose, onThemeAdded, userDetails?.id, userDetails?.username, reset]
);
return (
<Modal
visible={visible}
title={t("create_theme_modal_title")}
description={t("create_theme_modal_description")}
onClose={onClose}
>
<form
onSubmit={handleSubmit(onSubmit)}
className="add-theme-modal__container"
>
<TextField
{...register("name")}
label={t("theme_name")}
placeholder={t("insert_theme_name")}
hint={errors.name?.message}
error={errors.name?.message}
/>
<Button type="submit" theme="primary" disabled={isSubmitting}>
{t("create_theme")}
</Button>
</form>
</Modal>
);
}

View file

@ -0,0 +1,51 @@
import { Button } from "@renderer/components/button/button";
import { Modal } from "@renderer/components/modal/modal";
import { useTranslation } from "react-i18next";
import "./modals.scss";
import { removeCustomCss } from "@renderer/helpers";
interface DeleteAllThemesModalProps {
visible: boolean;
onClose: () => void;
onThemesDeleted: () => void;
}
export const DeleteAllThemesModal = ({
visible,
onClose,
onThemesDeleted,
}: DeleteAllThemesModalProps) => {
const { t } = useTranslation("settings");
const handleDeleteAllThemes = async () => {
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme) {
removeCustomCss();
}
await window.electron.deleteAllCustomThemes();
await window.electron.closeEditorWindow();
onClose();
onThemesDeleted();
};
return (
<Modal
visible={visible}
title={t("delete_all_themes")}
description={t("delete_all_themes_description")}
onClose={onClose}
>
<div className="delete-all-themes-modal__container">
<Button theme="outline" onClick={handleDeleteAllThemes}>
{t("delete_all_themes")}
</Button>
<Button theme="primary" onClick={onClose}>
{t("cancel")}
</Button>
</div>
</Modal>
);
};

View file

@ -0,0 +1,54 @@
import { Button } from "@renderer/components/button/button";
import { Modal } from "@renderer/components/modal/modal";
import { useTranslation } from "react-i18next";
import "./modals.scss";
import { removeCustomCss } from "@renderer/helpers";
interface DeleteThemeModalProps {
visible: boolean;
onClose: () => void;
themeId: string;
isActive: boolean;
onThemeDeleted: () => void;
themeName: string;
}
export const DeleteThemeModal = ({
visible,
onClose,
themeId,
isActive,
onThemeDeleted,
themeName,
}: DeleteThemeModalProps) => {
const { t } = useTranslation("settings");
const handleDeleteTheme = async () => {
if (isActive) {
removeCustomCss();
}
await window.electron.deleteCustomTheme(themeId);
await window.electron.closeEditorWindow(themeId);
onThemeDeleted();
};
return (
<Modal
visible={visible}
title={t("delete_theme")}
description={t("delete_theme_description", { theme: themeName })}
onClose={onClose}
>
<div className="delete-all-themes-modal__container">
<Button theme="outline" onClick={handleDeleteTheme}>
{t("delete_theme")}
</Button>
<Button theme="primary" onClick={onClose}>
{t("cancel")}
</Button>
</div>
</Modal>
);
};

View file

@ -0,0 +1,90 @@
import { Button } from "@renderer/components/button/button";
import { Modal } from "@renderer/components/modal/modal";
import { useTranslation } from "react-i18next";
import "./modals.scss";
import { Theme } from "@types";
import { injectCustomCss, removeCustomCss } from "@renderer/helpers";
import { useToast } from "@renderer/hooks";
import { THEME_WEB_STORE_URL } from "@renderer/constants";
import { logger } from "@renderer/logger";
interface ImportThemeModalProps {
visible: boolean;
onClose: () => void;
onThemeImported: () => void;
themeName: string;
authorId: string;
authorName: string;
}
export const ImportThemeModal = ({
visible,
onClose,
onThemeImported,
themeName,
authorId,
authorName,
}: ImportThemeModalProps) => {
const { t } = useTranslation("settings");
const { showSuccessToast, showErrorToast } = useToast();
const handleImportTheme = async () => {
const theme: Theme = {
id: crypto.randomUUID(),
name: themeName,
isActive: false,
author: authorId,
authorName: authorName,
code: `${THEME_WEB_STORE_URL}/themes/${themeName.toLowerCase()}/theme.css`,
createdAt: new Date(),
updatedAt: new Date(),
};
try {
await window.electron.addCustomTheme(theme);
const currentTheme = await window.electron.getCustomThemeById(theme.id);
if (!currentTheme) return;
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme) {
removeCustomCss();
await window.electron.toggleCustomTheme(activeTheme.id, false);
}
if (currentTheme.code) {
injectCustomCss(currentTheme.code);
}
await window.electron.toggleCustomTheme(currentTheme.id, true);
onThemeImported();
showSuccessToast(t("theme_imported"));
onClose();
} catch (error) {
logger.error(error);
showErrorToast(t("error_importing_theme"));
onClose();
}
};
return (
<Modal
visible={visible}
title={t("import_theme")}
description={t("import_theme_description", { theme: themeName })}
onClose={onClose}
>
<div className="delete-all-themes-modal__container">
<Button theme="outline" onClick={handleImportTheme}>
{t("import_theme")}
</Button>
<Button theme="primary" onClick={onClose}>
{t("cancel")}
</Button>
</div>
</Modal>
);
};

View file

@ -0,0 +1,15 @@
.add-theme-modal {
&__container {
display: flex;
flex-direction: column;
gap: 16px;
}
}
.delete-all-themes-modal__container {
display: flex;
flex-direction: row;
gap: 8px;
width: 100%;
justify-content: flex-end;
}

View file

@ -0,0 +1,154 @@
@use "../../../scss/globals.scss";
.settings-appearance {
display: flex;
flex-direction: column;
gap: 16px;
&__actions {
display: flex;
justify-content: space-between;
align-items: center;
&-left {
display: flex;
gap: 8px;
}
}
&__themes {
display: flex;
flex-direction: column;
gap: 16px;
&__theme {
width: 100%;
min-height: 160px;
display: flex;
flex-direction: column;
background-color: rgba(globals.$border-color, 0.01);
border: 1px solid globals.$border-color;
border-radius: 12px;
gap: 4px;
transition: background-color 0.2s ease;
padding: 16px;
position: relative;
&--active {
background-color: rgba(globals.$border-color, 0.04);
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: row;
gap: 16px;
&__title {
font-size: 18px;
font-weight: 600;
color: globals.$muted-color;
text-transform: capitalize;
}
&__colors {
display: flex;
flex-direction: row;
gap: 8px;
&__color {
width: 16px;
height: 16px;
border-radius: 4px;
border: 1px solid globals.$border-color;
}
}
}
&__author {
font-size: 12px;
color: globals.$body-color;
font-weight: 400;
&__name {
font-weight: 600;
color: rgba(globals.$muted-color, 0.8);
margin-left: 4px;
&:hover {
color: globals.$muted-color;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
}
}
&__actions {
display: flex;
flex-direction: row;
position: absolute;
bottom: 16px;
left: 16px;
right: 16px;
gap: 8px;
justify-content: space-between;
&__left {
display: flex;
flex-direction: row;
gap: 8px;
}
&__right {
display: flex;
flex-direction: row;
gap: 8px;
Button {
padding: 8px 11px;
}
}
}
}
}
&__no-themes {
width: 100%;
min-height: 160px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 40px 24px;
background-color: rgba(globals.$border-color, 0.01);
cursor: pointer;
border: 1px dashed globals.$border-color;
border-radius: 12px;
gap: 12px;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(globals.$border-color, 0.03);
}
&__icon {
svg {
width: 32px;
height: 32px;
color: globals.$body-color;
opacity: 0.7;
}
}
&__text {
text-align: center;
max-width: 400px;
font-size: 14.5px;
line-height: 1.6;
font-weight: 400;
color: rgba(globals.$body-color, 0.85);
}
}
}

View file

@ -0,0 +1,123 @@
import { useCallback, useContext, useEffect, useState } from "react";
import "./settings-appearance.scss";
import { ThemeActions, ThemeCard, ThemePlaceholder } from "./index";
import type { Theme } from "@types";
import { ImportThemeModal } from "./modals/import-theme-modal";
import { settingsContext } from "@renderer/context";
import { useNavigate } from "react-router-dom";
interface SettingsAppearanceProps {
appearance: {
theme: string | null;
authorId: string | null;
authorName: string | null;
};
}
export function SettingsAppearance({
appearance,
}: Readonly<SettingsAppearanceProps>) {
const [themes, setThemes] = useState<Theme[]>([]);
const [isImportThemeModalVisible, setIsImportThemeModalVisible] =
useState(false);
const [importTheme, setImportTheme] = useState<{
theme: string;
authorId: string;
authorName: string;
} | null>(null);
const [hasShownModal, setHasShownModal] = useState(false);
const { clearTheme } = useContext(settingsContext);
const navigate = useNavigate();
const loadThemes = useCallback(async () => {
const themesList = await window.electron.getAllCustomThemes();
setThemes(themesList);
}, []);
useEffect(() => {
loadThemes();
}, [loadThemes]);
useEffect(() => {
const unsubscribe = window.electron.onCssInjected(() => {
loadThemes();
});
return () => unsubscribe();
}, [loadThemes]);
useEffect(() => {
if (
appearance.theme &&
appearance.authorId &&
appearance.authorName &&
!hasShownModal
) {
setIsImportThemeModalVisible(true);
setImportTheme({
theme: appearance.theme,
authorId: appearance.authorId,
authorName: appearance.authorName,
});
setHasShownModal(true);
navigate("/settings", { replace: true });
clearTheme();
}
}, [
appearance.theme,
appearance.authorId,
appearance.authorName,
navigate,
hasShownModal,
clearTheme,
]);
const onThemeImported = useCallback(() => {
setIsImportThemeModalVisible(false);
setImportTheme(null);
loadThemes();
}, [loadThemes]);
return (
<div className="settings-appearance">
<ThemeActions onListUpdated={loadThemes} themesCount={themes.length} />
<div className="settings-appearance__themes">
{!themes.length ? (
<ThemePlaceholder onListUpdated={loadThemes} />
) : (
[...themes]
.sort(
(a, b) =>
new Date(b.updatedAt).getTime() -
new Date(a.updatedAt).getTime()
)
.map((theme) => (
<ThemeCard
key={theme.id}
theme={theme}
onListUpdated={loadThemes}
/>
))
)}
</div>
{importTheme && (
<ImportThemeModal
visible={isImportThemeModalVisible}
onClose={() => {
setIsImportThemeModalVisible(false);
clearTheme();
setHasShownModal(false);
}}
onThemeImported={onThemeImported}
themeName={importTheme.theme}
authorId={importTheme.authorId}
authorName={importTheme.authorName}
/>
)}
</div>
);
}

View file

@ -63,7 +63,7 @@ export function SettingsAccount() {
return () => {
unsubscribe();
};
}, [fetchUserDetails, updateUserDetails, showSuccessToast]);
}, [fetchUserDetails, updateUserDetails, t, showSuccessToast]);
const visibilityOptions = [
{ value: "PUBLIC", label: t("public") },

View file

@ -86,12 +86,12 @@ export function SettingsRealDebrid() {
<CheckboxField
label={t("enable_real_debrid")}
checked={form.useRealDebrid}
onChange={() =>
onChange={() => {
setForm((prev) => ({
...prev,
useRealDebrid: !form.useRealDebrid,
}))
}
}));
}}
/>
{form.useRealDebrid && (

View file

@ -10,9 +10,10 @@ import {
SettingsContextProvider,
} from "@renderer/context";
import { SettingsAccount } from "./settings-account";
import { useUserDetails } from "@renderer/hooks";
import { useFeature, useUserDetails } from "@renderer/hooks";
import { useMemo } from "react";
import "./settings.scss";
import { SettingsAppearance } from "./aparence/settings-appearance";
import { SettingsTorbox } from "./settings-torbox";
export default function Settings() {
@ -20,20 +21,36 @@ export default function Settings() {
const { userDetails } = useUserDetails();
const { isFeatureEnabled, Feature } = useFeature();
const isTorboxEnabled = isFeatureEnabled(Feature.Torbox);
const categories = useMemo(() => {
const categories = [
{ tabLabel: t("general"), contentTitle: t("general") },
{ tabLabel: t("behavior"), contentTitle: t("behavior") },
{ tabLabel: t("download_sources"), contentTitle: t("download_sources") },
{
tabLabel: (
<>
<img src={torBoxLogo} alt="TorBox" style={{ width: 13 }} />
Torbox
</>
),
contentTitle: "TorBox",
tabLabel: t("appearance"),
contentTitle: t("appearance"),
},
...(isTorboxEnabled
? [
{
tabLabel: (
<>
<img
src={torBoxLogo}
alt="TorBox"
style={{ width: 13, height: 13 }}
/>{" "}
Torbox
</>
),
contentTitle: "TorBox",
},
]
: []),
{ tabLabel: "Real-Debrid", contentTitle: "Real-Debrid" },
];
@ -43,12 +60,12 @@ export default function Settings() {
{ tabLabel: t("account"), contentTitle: t("account") },
];
return categories;
}, [userDetails, t]);
}, [userDetails, t, isTorboxEnabled]);
return (
<SettingsContextProvider>
<SettingsContextConsumer>
{({ currentCategoryIndex, setCurrentCategoryIndex }) => {
{({ currentCategoryIndex, setCurrentCategoryIndex, appearance }) => {
const renderCategory = () => {
if (currentCategoryIndex === 0) {
return <SettingsGeneral />;
@ -63,10 +80,14 @@ export default function Settings() {
}
if (currentCategoryIndex === 3) {
return <SettingsTorbox />;
return <SettingsAppearance appearance={appearance} />;
}
if (currentCategoryIndex === 4) {
return <SettingsTorbox />;
}
if (currentCategoryIndex === 5) {
return <SettingsRealDebrid />;
}
@ -79,7 +100,7 @@ export default function Settings() {
<section className="settings__categories">
{categories.map((category, index) => (
<Button
key={index}
key={category.contentTitle}
theme={
currentCategoryIndex === index ? "primary" : "outline"
}

View file

@ -106,12 +106,10 @@ export const UserFriendItem = (props: UserFriendItemProps) => {
<div className="user-friend-item__container">
<div className="user-friend-item__button">
<Avatar size={35} src={profileImageUrl} alt={displayName} />
<div className="user-friend-item__button__content">
<p className="user-friend-item__display-name">{displayName}</p>
</div>
</div>
<div className="user-friend-item__button__actions">
{getRequestActions()}
</div>
@ -133,7 +131,6 @@ export const UserFriendItem = (props: UserFriendItemProps) => {
{getRequestDescription()}
</div>
</button>
<div className="user-friend-item__button__actions">
{getRequestActions()}
</div>

View file

@ -0,0 +1,77 @@
@use "../../scss/globals.scss";
.theme-editor {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
&__header {
display: flex;
align-items: center;
padding: calc(globals.$spacing-unit + 1px);
background-color: globals.$dark-background-color;
font-size: 8px;
z-index: 50;
-webkit-app-region: drag;
gap: 8px;
&--darwin {
padding-top: calc(globals.$spacing-unit * 6);
}
h1 {
margin: 0;
line-height: 1;
}
&__status {
display: flex;
width: 9px;
height: 9px;
background-color: globals.$muted-color;
border-radius: 50%;
margin-top: 3px;
}
}
&__footer {
background-color: globals.$dark-background-color;
padding: globals.$spacing-unit globals.$spacing-unit * 2;
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
&-actions {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
&__tabs {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 8px;
.active {
background-color: darken(globals.$dark-background-color, 2%);
}
}
}
}
&__info {
padding: 16px;
p {
font-size: 16px;
font-weight: 600;
color: globals.$muted-color;
margin-bottom: 8px;
}
}
}

View file

@ -0,0 +1,98 @@
import { useCallback, useEffect, useState } from "react";
import "./theme-editor.scss";
import Editor from "@monaco-editor/react";
import { Theme } from "@types";
import { useSearchParams } from "react-router-dom";
import { Button } from "@renderer/components";
import { CheckIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import cn from "classnames";
export default function ThemeEditor() {
const [searchParams] = useSearchParams();
const [theme, setTheme] = useState<Theme | null>(null);
const [code, setCode] = useState("");
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const themeId = searchParams.get("themeId");
const { t } = useTranslation("settings");
useEffect(() => {
if (themeId) {
window.electron.getCustomThemeById(themeId).then((loadedTheme) => {
if (loadedTheme) {
setTheme(loadedTheme);
setCode(loadedTheme.code);
}
});
}
}, [themeId]);
const handleSave = useCallback(async () => {
if (theme) {
await window.electron.updateCustomTheme(theme.id, code);
setHasUnsavedChanges(false);
}
}, [code, theme]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
event.preventDefault();
handleSave();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [code, handleSave, theme]);
const handleEditorChange = (value: string | undefined) => {
if (value !== undefined) {
setCode(value);
setHasUnsavedChanges(true);
}
};
return (
<div className="theme-editor">
<div
className={cn("theme-editor__header", {
"theme-editor__header--darwin": window.electron.platform === "darwin",
})}
>
<h1>{theme?.name}</h1>
{hasUnsavedChanges && (
<div className="theme-editor__header__status"></div>
)}
</div>
<Editor
theme="vs-dark"
defaultLanguage="css"
value={code}
onChange={handleEditorChange}
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: "on",
wordWrap: "on",
automaticLayout: true,
}}
/>
<div className="theme-editor__footer">
<div className="theme-editor__footer-actions">
<Button onClick={handleSave}>
<CheckIcon />
{t("editor_tab_save")}
</Button>
</div>
</div>
</div>
);
}

View file

@ -130,6 +130,8 @@ export interface UserProfileCurrentGame extends Omit<GameRunning, "objectId"> {
export type ProfileVisibility = "PUBLIC" | "PRIVATE" | "FRIENDS";
export type UserBadge = "THEME_CREATOR";
export interface UserDetails {
id: string;
username: string;
@ -164,6 +166,7 @@ export interface UserProfile {
quirks: {
backupsPerGameLimit: number;
};
badges: UserBadge[];
}
export interface UpdateProfileRequest {
@ -296,3 +299,4 @@ export * from "./download.types";
export * from "./ludusavi.types";
export * from "./how-long-to-beat.types";
export * from "./level.types";
export * from "./theme.types";

10
src/types/theme.types.ts Normal file
View file

@ -0,0 +1,10 @@
export interface Theme {
id: string;
name: string;
author?: string;
authorName?: string;
isActive: boolean;
code: string;
createdAt: Date;
updatedAt: Date;
}

View file

@ -1790,6 +1790,20 @@
lodash "^4.17.15"
tmp-promise "^3.0.2"
"@monaco-editor/loader@^1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.4.0.tgz#f08227057331ec890fa1e903912a5b711a2ad558"
integrity sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==
dependencies:
state-local "^1.0.6"
"@monaco-editor/react@^4.6.0":
version "4.6.0"
resolved "https://registry.yarnpkg.com/@monaco-editor/react/-/react-4.6.0.tgz#bcc68671e358a21c3814566b865a54b191e24119"
integrity sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==
dependencies:
"@monaco-editor/loader" "^1.4.0"
"@napi-rs/nice-android-arm-eabi@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.1.tgz#9a0cba12706ff56500df127d6f4caf28ddb94936"
@ -5851,6 +5865,11 @@ get-symbol-description@^1.1.0:
es-errors "^1.3.0"
get-intrinsic "^1.2.6"
get-them-args@1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/get-them-args/-/get-them-args-1.3.2.tgz#74a20ba8a4abece5ae199ad03f2bcc68fdfc9ba5"
integrity sha512-LRn8Jlk+DwZE4GTlDbT3Hikd1wSHgLMme/+7ddlqKd7ldwR6LjJgTVWzBnR01wnYGe4KgrXjg287RaI22UHmAw==
getopts@2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/getopts/-/getopts-2.3.0.tgz#71e5593284807e03e2427449d4f6712a268666f4"
@ -6876,6 +6895,14 @@ keyv@^4.0.0, keyv@^4.5.3:
dependencies:
json-buffer "3.0.1"
kill-port@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/kill-port/-/kill-port-2.0.1.tgz#e5e18e2706b13d54320938be42cb7d40609b15cf"
integrity sha512-e0SVOV5jFo0mx8r7bS29maVWp17qGqLBZ5ricNSajON6//kmb7qqqNnml4twNE8Dtj97UQD+gNFOaipS/q1zzQ==
dependencies:
get-them-args "1.3.2"
shell-exec "1.0.2"
knex@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/knex/-/knex-3.1.0.tgz#b6ddd5b5ad26a6315234a5b09ec38dc4a370bd8c"
@ -8599,6 +8626,11 @@ shebang-regex@^3.0.0:
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
shell-exec@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/shell-exec/-/shell-exec-1.0.2.tgz#2e9361b0fde1d73f476c4b6671fa17785f696756"
integrity sha512-jyVd+kU2X+mWKMmGhx4fpWbPsjvD53k9ivqetutVW/BQ+WIZoDoP4d8vUMGezV6saZsiNoW2f9GIhg9Dondohg==
side-channel-list@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad"
@ -8776,6 +8808,11 @@ stat-mode@^1.0.0:
resolved "https://registry.yarnpkg.com/stat-mode/-/stat-mode-1.0.0.tgz#68b55cb61ea639ff57136f36b216a291800d1465"
integrity sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==
state-local@^1.0.6:
version "1.0.7"
resolved "https://registry.yarnpkg.com/state-local/-/state-local-1.0.7.tgz#da50211d07f05748d53009bee46307a37db386d5"
integrity sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"