diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 161708bb..a11ae546 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,9 @@ name: Build +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + on: pull_request jobs: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4e2a8c06..0a17b329 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,5 +1,9 @@ name: Lint +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + on: pull_request jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index afa5502c..99a89245 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,9 @@ name: Release +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + on: push: branches: main diff --git a/package.json b/package.json index 75f541d3..136f3e2f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/python_rpc/main.py b/python_rpc/main.py index 2deb2029..94c34e17 100644 --- a/python_rpc/main.py +++ b/python_rpc/main.py @@ -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) diff --git a/scripts/postinstall.cjs b/scripts/postinstall.cjs index 70768a7d..fc3f69dd 100644 --- a/scripts/postinstall.cjs +++ b/scripts/postinstall.cjs @@ -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; } diff --git a/src/locales/ar/translation.json b/src/locales/ar/translation.json index a12b3559..13445e9d 100644 --- a/src/locales/ar/translation.json +++ b/src/locales/ar/translation.json @@ -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>الإعدادات", "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>هنا", - "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>الإعدادات", "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": "معرفة المزيد" diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 94b52c75..bf91cc40 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index a5c7a66e..79b9a4b2 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -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", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index bbd7047a..494b8d06 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -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}}", diff --git a/src/main/events/auth/get-session-hash.ts b/src/main/events/auth/get-session-hash.ts index c81e0965..02edebdc 100644 --- a/src/main/events/auth/get-session-hash.ts +++ b/src/main/events/auth/get-session-hash.ts @@ -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(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; diff --git a/src/main/events/index.ts b/src/main/events/index.ts index dc64b40e..572cba0f 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -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"); diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index 62993348..c59f6ac8 100644 --- a/src/main/events/library/add-game-to-library.ts +++ b/src/main/events/library/add-game-to-library.ts @@ -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); } }; diff --git a/src/main/events/misc/open-checkout.ts b/src/main/events/misc/open-checkout.ts index 76316a6e..75b93f9d 100644 --- a/src/main/events/misc/open-checkout.ts +++ b/src/main/events/misc/open-checkout.ts @@ -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({ diff --git a/src/main/events/themes/add-custom-theme.ts b/src/main/events/themes/add-custom-theme.ts new file mode 100644 index 00000000..95f526d9 --- /dev/null +++ b/src/main/events/themes/add-custom-theme.ts @@ -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); diff --git a/src/main/events/themes/close-editor-window.ts b/src/main/events/themes/close-editor-window.ts new file mode 100644 index 00000000..6ebc012c --- /dev/null +++ b/src/main/events/themes/close-editor-window.ts @@ -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); diff --git a/src/main/events/themes/delete-all-custom-themes.ts b/src/main/events/themes/delete-all-custom-themes.ts new file mode 100644 index 00000000..d7a42d39 --- /dev/null +++ b/src/main/events/themes/delete-all-custom-themes.ts @@ -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); diff --git a/src/main/events/themes/delete-custom-theme.ts b/src/main/events/themes/delete-custom-theme.ts new file mode 100644 index 00000000..d47c43fb --- /dev/null +++ b/src/main/events/themes/delete-custom-theme.ts @@ -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); diff --git a/src/main/events/themes/get-active-custom-theme.ts b/src/main/events/themes/get-active-custom-theme.ts new file mode 100644 index 00000000..b117f758 --- /dev/null +++ b/src/main/events/themes/get-active-custom-theme.ts @@ -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); diff --git a/src/main/events/themes/get-all-custom-themes.ts b/src/main/events/themes/get-all-custom-themes.ts new file mode 100644 index 00000000..f59a87cd --- /dev/null +++ b/src/main/events/themes/get-all-custom-themes.ts @@ -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); diff --git a/src/main/events/themes/get-custom-theme-by-id.ts b/src/main/events/themes/get-custom-theme-by-id.ts new file mode 100644 index 00000000..4ec5dc03 --- /dev/null +++ b/src/main/events/themes/get-custom-theme-by-id.ts @@ -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); diff --git a/src/main/events/themes/open-editor-window.ts b/src/main/events/themes/open-editor-window.ts new file mode 100644 index 00000000..59838ed4 --- /dev/null +++ b/src/main/events/themes/open-editor-window.ts @@ -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); diff --git a/src/main/events/themes/toggle-custom-theme.ts b/src/main/events/themes/toggle-custom-theme.ts new file mode 100644 index 00000000..50440551 --- /dev/null +++ b/src/main/events/themes/toggle-custom-theme.ts @@ -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); diff --git a/src/main/events/themes/update-custom-theme.ts b/src/main/events/themes/update-custom-theme.ts new file mode 100644 index 00000000..b9a8e048 --- /dev/null +++ b/src/main/events/themes/update-custom-theme.ts @@ -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); diff --git a/src/main/events/torrenting/cancel-game-download.ts b/src/main/events/torrenting/cancel-game-download.ts index 5d80337f..860d33bb 100644 --- a/src/main/events/torrenting/cancel-game-download.ts +++ b/src/main/events/torrenting/cancel-game-download.ts @@ -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); diff --git a/src/main/events/torrenting/pause-game-seed.ts b/src/main/events/torrenting/pause-game-seed.ts index b19da525..fb628ee1 100644 --- a/src/main/events/torrenting/pause-game-seed.ts +++ b/src/main/events/torrenting/pause-game-seed.ts @@ -15,6 +15,7 @@ const pauseGameSeed = async ( await downloadsSublevel.put(downloadKey, { ...download, + status: "complete", shouldSeed: false, }); diff --git a/src/main/events/torrenting/resume-game-seed.ts b/src/main/events/torrenting/resume-game-seed.ts index 63bab952..ae098450 100644 --- a/src/main/events/torrenting/resume-game-seed.ts +++ b/src/main/events/torrenting/resume-game-seed.ts @@ -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, }); diff --git a/src/main/events/user-preferences/get-user-preferences.ts b/src/main/events/user-preferences/get-user-preferences.ts index c67f72b9..ba01d077 100644 --- a/src/main/events/user-preferences/get-user-preferences.ts +++ b/src/main/events/user-preferences/get-user-preferences.ts @@ -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(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(levelKeys.userPreferences, { + valueEncoding: "json", + }); registerEvent("getUserPreferences", getUserPreferences); diff --git a/src/main/events/user-preferences/update-user-preferences.ts b/src/main/events/user-preferences/update-user-preferences.ts index 275a6f27..09f39d2d 100644 --- a/src/main/events/user-preferences/update-user-preferences.ts +++ b/src/main/events/user-preferences/update-user-preferences.ts @@ -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; } diff --git a/src/main/events/user/get-unlocked-achievements.ts b/src/main/events/user/get-unlocked-achievements.ts index 6deecbad..21aad7a0 100644 --- a/src/main/events/user/get-unlocked-achievements.ts +++ b/src/main/events/user/get-unlocked-achievements.ts @@ -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, }; } diff --git a/src/main/index.ts b/src/main/index.ts index 2a18fa31..01818b3d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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(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); diff --git a/src/main/level/sublevels/index.ts b/src/main/level/sublevels/index.ts index 3f0e840e..e63f0a3b 100644 --- a/src/main/level/sublevels/index.ts +++ b/src/main/level/sublevels/index.ts @@ -3,3 +3,4 @@ export * from "./games"; export * from "./game-shop-cache"; export * from "./game-achievements"; export * from "./keys"; +export * from "./themes"; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts index 53eae44b..3f0a09ea 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -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}`, diff --git a/src/main/level/sublevels/themes.ts b/src/main/level/sublevels/themes.ts new file mode 100644 index 00000000..5e23468f --- /dev/null +++ b/src/main/level/sublevels/themes.ts @@ -0,0 +1,7 @@ +import type { Theme } from "@types"; +import { db } from "../level"; +import { levelKeys } from "./keys"; + +export const themesSublevel = db.sublevel(levelKeys.themes, { + valueEncoding: "json", +}); diff --git a/src/main/main.ts b/src/main/main.ts index 4824a1a5..6bcd0ff6 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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( 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, }, { diff --git a/src/main/services/achievements/merge-achievements.ts b/src/main/services/achievements/merge-achievements.ts index 7e6ebf0a..e173af27 100644 --- a/src/main/services/achievements/merge-achievements.ts +++ b/src/main/services/achievements/merge-achievements.ts @@ -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, diff --git a/src/main/services/achievements/update-local-unlocked-achivements.ts b/src/main/services/achievements/update-local-unlocked-achivements.ts index 8832a475..44f2693a 100644 --- a/src/main/services/achievements/update-local-unlocked-achivements.ts +++ b/src/main/services/achievements/update-local-unlocked-achivements.ts @@ -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 = diff --git a/src/main/services/crypto.ts b/src/main/services/crypto.ts deleted file mode 100644 index 63a50668..00000000 --- a/src/main/services/crypto.ts +++ /dev/null @@ -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; - } - } -} diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 247d5c75..d3cab967 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -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; } diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index ba972b44..9c71d57d 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -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( 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" } diff --git a/src/main/services/index.ts b/src/main/services/index.ts index d2034f15..5aaf5322 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -1,4 +1,3 @@ -export * from "./crypto"; export * from "./logger"; export * from "./steam"; export * from "./steam-250"; diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index c3f57c53..f98fb0d3 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -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, diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 2d0bf24d..dde51b5f 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -24,6 +24,8 @@ import { isStaging } from "@main/constants"; export class WindowManager { public static mainWindow: Electron.BrowserWindow | null = null; + private static readonly editorWindows: Map = 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); diff --git a/src/preload/index.ts b/src/preload/index.ts index ef61cbb9..5b3498ac 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), }); diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 650d4ca0..0b738c6f 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -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]); diff --git a/src/renderer/src/assets/icons/badge-theme-creator.svg b/src/renderer/src/assets/icons/badge-theme-creator.svg new file mode 100644 index 00000000..0793a7e9 --- /dev/null +++ b/src/renderer/src/assets/icons/badge-theme-creator.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/renderer/src/components/backdrop/backdrop.tsx b/src/renderer/src/components/backdrop/backdrop.tsx index e62d42ee..5caedb0e 100644 --- a/src/renderer/src/components/backdrop/backdrop.tsx +++ b/src/renderer/src/components/backdrop/backdrop.tsx @@ -6,7 +6,10 @@ export interface BackdropProps { children: React.ReactNode; } -export function Backdrop({ isClosing = false, children }: BackdropProps) { +export function Backdrop({ + isClosing = false, + children, +}: Readonly) { return (
) { return ( - - ))} + {downloaders.map((downloader) => { + const shouldDisableButton = + (downloader === Downloader.RealDebrid && + !userPreferences?.realDebridApiToken) || + (downloader === Downloader.TorBox && + !userPreferences?.torBoxApiToken); + + return ( + + ); + })}
diff --git a/src/renderer/src/pages/profile/profile-hero/profile-hero.scss b/src/renderer/src/pages/profile/profile-hero/profile-hero.scss index 8be99794..fd21cf1d 100644 --- a/src/renderer/src/pages/profile/profile-hero/profile-hero.scss +++ b/src/renderer/src/pages/profile/profile-hero/profile-hero.scss @@ -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; diff --git a/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx b/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx index e198fbf2..66799c47 100644 --- a/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx +++ b/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx @@ -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() {
{userProfile ? ( -

- {userProfile?.displayName} -

+
+

+ {userProfile?.displayName} +

+ +
) : ( )} diff --git a/src/renderer/src/pages/profile/profile-hero/user-badges.tsx b/src/renderer/src/pages/profile/profile-hero/user-badges.tsx new file mode 100644 index 00000000..ed5d0571 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-hero/user-badges.tsx @@ -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 ; + } + + return null; + }; + + return ( +
+ {userProfile.badges.map((badge) => { + const badgeIcon = getBadgeIcon(badge); + + if (!badgeIcon) return null; + return ( +
+ {badgeIcon} +
+ ); + })} +
+ ); +} diff --git a/src/renderer/src/pages/profile/report-profile/report-profile.tsx b/src/renderer/src/pages/profile/report-profile/report-profile.tsx index 04afea51..40084aba 100644 --- a/src/renderer/src/pages/profile/report-profile/report-profile.tsx +++ b/src/renderer/src/pages/profile/report-profile/report-profile.tsx @@ -74,7 +74,10 @@ export function ReportProfile() { title={t("report_profile")} clickOutsideToClose={false} > -
+ - + diff --git a/src/renderer/src/pages/settings/add-download-source-modal.tsx b/src/renderer/src/pages/settings/add-download-source-modal.tsx index 2a9b5dd6..948dc721 100644 --- a/src/renderer/src/pages/settings/add-download-source-modal.tsx +++ b/src/renderer/src/pages/settings/add-download-source-modal.tsx @@ -26,7 +26,7 @@ export function AddDownloadSourceModal({ visible, onClose, onAddDownloadSource, -}: AddDownloadSourceModalProps) { +}: Readonly) { const [url, setUrl] = useState(""); const [isLoading, setIsLoading] = useState(false); diff --git a/src/renderer/src/pages/settings/aparence/components/theme-actions.scss b/src/renderer/src/pages/settings/aparence/components/theme-actions.scss new file mode 100644 index 00000000..0b038a28 --- /dev/null +++ b/src/renderer/src/pages/settings/aparence/components/theme-actions.scss @@ -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; + } +} diff --git a/src/renderer/src/pages/settings/aparence/components/theme-actions.tsx b/src/renderer/src/pages/settings/aparence/components/theme-actions.tsx new file mode 100644 index 00000000..fc8fc53c --- /dev/null +++ b/src/renderer/src/pages/settings/aparence/components/theme-actions.tsx @@ -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 ( + <> + setAddThemeModalVisible(false)} + onThemeAdded={onListUpdated} + /> + + setDeleteAllThemesModalVisible(false)} + onThemesDeleted={onListUpdated} + /> + +
+
+ + + +
+ +
+ +
+
+ + ); +}; diff --git a/src/renderer/src/pages/settings/aparence/components/theme-card.scss b/src/renderer/src/pages/settings/aparence/components/theme-card.scss new file mode 100644 index 00000000..8499078b --- /dev/null +++ b/src/renderer/src/pages/settings/aparence/components/theme-card.scss @@ -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; + } + } + } +} diff --git a/src/renderer/src/pages/settings/aparence/components/theme-card.tsx b/src/renderer/src/pages/settings/aparence/components/theme-card.tsx new file mode 100644 index 00000000..68eed4c3 --- /dev/null +++ b/src/renderer/src/pages/settings/aparence/components/theme-card.tsx @@ -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 ( + <> + setDeleteThemeModalVisible(false)} + onThemeDeleted={onListUpdated} + themeId={theme.id} + themeName={theme.name} + isActive={theme.isActive} + /> + +
+
+
{theme.name}
+
+ + {theme.authorName && ( +

+ {t("by")} + + +

+ )} + +
+
+ {theme.isActive ? ( + + ) : ( + + )} +
+ +
+ + + +
+
+
+ + ); +}; diff --git a/src/renderer/src/pages/settings/aparence/components/theme-placeholder.scss b/src/renderer/src/pages/settings/aparence/components/theme-placeholder.scss new file mode 100644 index 00000000..737ad5e5 --- /dev/null +++ b/src/renderer/src/pages/settings/aparence/components/theme-placeholder.scss @@ -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); + } +} diff --git a/src/renderer/src/pages/settings/aparence/components/theme-placeholder.tsx b/src/renderer/src/pages/settings/aparence/components/theme-placeholder.tsx new file mode 100644 index 00000000..7904bfe4 --- /dev/null +++ b/src/renderer/src/pages/settings/aparence/components/theme-placeholder.tsx @@ -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 ( + <> + setAddThemeModalVisible(false)} + onThemeAdded={onListUpdated} + /> + + + + ); +}; diff --git a/src/renderer/src/pages/settings/aparence/index.ts b/src/renderer/src/pages/settings/aparence/index.ts new file mode 100644 index 00000000..3e7132ec --- /dev/null +++ b/src/renderer/src/pages/settings/aparence/index.ts @@ -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"; diff --git a/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx new file mode 100644 index 00000000..bece2bac --- /dev/null +++ b/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx @@ -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) { + 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({ + 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 ( + +
+ + + + +
+ ); +} diff --git a/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx new file mode 100644 index 00000000..be46e56a --- /dev/null +++ b/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx @@ -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 ( + +
+ + + +
+
+ ); +}; diff --git a/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx new file mode 100644 index 00000000..5783ce01 --- /dev/null +++ b/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx @@ -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 ( + +
+ + + +
+
+ ); +}; diff --git a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx new file mode 100644 index 00000000..db4abe8c --- /dev/null +++ b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx @@ -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 ( + +
+ + + +
+
+ ); +}; diff --git a/src/renderer/src/pages/settings/aparence/modals/modals.scss b/src/renderer/src/pages/settings/aparence/modals/modals.scss new file mode 100644 index 00000000..ff8d0bbc --- /dev/null +++ b/src/renderer/src/pages/settings/aparence/modals/modals.scss @@ -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; +} diff --git a/src/renderer/src/pages/settings/aparence/settings-appearance.scss b/src/renderer/src/pages/settings/aparence/settings-appearance.scss new file mode 100644 index 00000000..af4f4dc8 --- /dev/null +++ b/src/renderer/src/pages/settings/aparence/settings-appearance.scss @@ -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); + } + } +} diff --git a/src/renderer/src/pages/settings/aparence/settings-appearance.tsx b/src/renderer/src/pages/settings/aparence/settings-appearance.tsx new file mode 100644 index 00000000..b8ab0b84 --- /dev/null +++ b/src/renderer/src/pages/settings/aparence/settings-appearance.tsx @@ -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) { + const [themes, setThemes] = useState([]); + 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 ( +
+ + +
+ {!themes.length ? ( + + ) : ( + [...themes] + .sort( + (a, b) => + new Date(b.updatedAt).getTime() - + new Date(a.updatedAt).getTime() + ) + .map((theme) => ( + + )) + )} +
+ + {importTheme && ( + { + setIsImportThemeModalVisible(false); + clearTheme(); + setHasShownModal(false); + }} + onThemeImported={onThemeImported} + themeName={importTheme.theme} + authorId={importTheme.authorId} + authorName={importTheme.authorName} + /> + )} +
+ ); +} diff --git a/src/renderer/src/pages/settings/settings-account.tsx b/src/renderer/src/pages/settings/settings-account.tsx index 06aabc06..9cf35541 100644 --- a/src/renderer/src/pages/settings/settings-account.tsx +++ b/src/renderer/src/pages/settings/settings-account.tsx @@ -63,7 +63,7 @@ export function SettingsAccount() { return () => { unsubscribe(); }; - }, [fetchUserDetails, updateUserDetails, showSuccessToast]); + }, [fetchUserDetails, updateUserDetails, t, showSuccessToast]); const visibilityOptions = [ { value: "PUBLIC", label: t("public") }, diff --git a/src/renderer/src/pages/settings/settings-real-debrid.tsx b/src/renderer/src/pages/settings/settings-real-debrid.tsx index 6fd1a6e2..d1c6bbb7 100644 --- a/src/renderer/src/pages/settings/settings-real-debrid.tsx +++ b/src/renderer/src/pages/settings/settings-real-debrid.tsx @@ -86,12 +86,12 @@ export function SettingsRealDebrid() { + onChange={() => { setForm((prev) => ({ ...prev, useRealDebrid: !form.useRealDebrid, - })) - } + })); + }} /> {form.useRealDebrid && ( diff --git a/src/renderer/src/pages/settings/settings.tsx b/src/renderer/src/pages/settings/settings.tsx index 4c94343c..391742b8 100644 --- a/src/renderer/src/pages/settings/settings.tsx +++ b/src/renderer/src/pages/settings/settings.tsx @@ -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: ( - <> - TorBox - Torbox - - ), - contentTitle: "TorBox", + tabLabel: t("appearance"), + contentTitle: t("appearance"), }, + ...(isTorboxEnabled + ? [ + { + tabLabel: ( + <> + TorBox{" "} + 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 ( - {({ currentCategoryIndex, setCurrentCategoryIndex }) => { + {({ currentCategoryIndex, setCurrentCategoryIndex, appearance }) => { const renderCategory = () => { if (currentCategoryIndex === 0) { return ; @@ -63,10 +80,14 @@ export default function Settings() { } if (currentCategoryIndex === 3) { - return ; + return ; } if (currentCategoryIndex === 4) { + return ; + } + + if (currentCategoryIndex === 5) { return ; } @@ -79,7 +100,7 @@ export default function Settings() {
{categories.map((category, index) => ( -
{getRequestActions()}
diff --git a/src/renderer/src/pages/theme-editor/theme-editor.scss b/src/renderer/src/pages/theme-editor/theme-editor.scss new file mode 100644 index 00000000..8ba09958 --- /dev/null +++ b/src/renderer/src/pages/theme-editor/theme-editor.scss @@ -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; + } + } +} diff --git a/src/renderer/src/pages/theme-editor/theme-editor.tsx b/src/renderer/src/pages/theme-editor/theme-editor.tsx new file mode 100644 index 00000000..0059edc9 --- /dev/null +++ b/src/renderer/src/pages/theme-editor/theme-editor.tsx @@ -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(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 ( +
+
+

{theme?.name}

+ {hasUnsavedChanges && ( +
+ )} +
+ + + +
+
+ +
+
+
+ ); +} diff --git a/src/types/index.ts b/src/types/index.ts index 1e089d08..e17a694a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -130,6 +130,8 @@ export interface UserProfileCurrentGame extends Omit { 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"; diff --git a/src/types/theme.types.ts b/src/types/theme.types.ts new file mode 100644 index 00000000..abba8fc1 --- /dev/null +++ b/src/types/theme.types.ts @@ -0,0 +1,10 @@ +export interface Theme { + id: string; + name: string; + author?: string; + authorName?: string; + isActive: boolean; + code: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/yarn.lock b/yarn.lock index 803d1cc0..58d0cc9a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"