mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
resolved conflicts
This commit is contained in:
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -1,5 +1,9 @@
|
||||
name: Build
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
|
||||
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@@ -1,5 +1,9 @@
|
||||
name: Lint
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -1,5 +1,9 @@
|
||||
name: Release
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: main
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydralauncher",
|
||||
"version": "3.1.5",
|
||||
"version": "3.3.0",
|
||||
"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",
|
||||
@@ -43,6 +44,7 @@
|
||||
"@sentry/vite-plugin": "^2.22.7",
|
||||
"auto-launch": "^5.0.6",
|
||||
"axios": "^1.7.9",
|
||||
"axios-cookiejar-support": "^5.0.5",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"classic-level": "^2.0.0",
|
||||
"classnames": "^2.5.1",
|
||||
@@ -59,6 +61,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",
|
||||
@@ -69,9 +72,11 @@
|
||||
"react-loading-skeleton": "^3.4.0",
|
||||
"react-redux": "^9.1.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-tooltip": "^5.28.0",
|
||||
"sound-play": "^1.1.0",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"tar": "^7.4.3",
|
||||
"tough-cookie": "^5.1.1",
|
||||
"user-agents": "^1.1.387",
|
||||
"yaml": "^2.6.1",
|
||||
"yup": "^1.5.0",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7,18 +7,18 @@
|
||||
"featured": "مميز",
|
||||
"surprise_me": "مفاجئني",
|
||||
"no_results": "لم يتم العثور على نتائج",
|
||||
"start_typing": "ابدأ الكتابة للبحث...",
|
||||
"hot": "الأكثر شيوعًا الآن",
|
||||
"start_typing": "ابدأ بالكتابة للبحث...",
|
||||
"hot": "الأكثر شهرة الآن",
|
||||
"weekly": "📅 أفضل ألعاب الأسبوع",
|
||||
"achievements": "🏆 ألعاب للتغلب عليها"
|
||||
"achievements": "🏆 ألعاب يجب إكمالها"
|
||||
},
|
||||
"sidebar": {
|
||||
"catalogue": "الكـتالوج",
|
||||
"catalogue": "الفهرس",
|
||||
"downloads": "التنزيلات",
|
||||
"settings": "الإعدادات",
|
||||
"my_library": "مكتبتي",
|
||||
"downloading_metadata": "{{title}} (جارٍ تنزيل البيانات الوصفية...)",
|
||||
"paused": "{{title}} (معلّق)",
|
||||
"downloading_metadata": "{{title}} (جاري تنزيل البيانات الوصفية...)",
|
||||
"paused": "{{title}} (معلق)",
|
||||
"downloading": "{{title}} ({{percentage}} - جاري التنزيل...)",
|
||||
"filter": "تصفية المكتبة",
|
||||
"home": "الرئيسية",
|
||||
@@ -26,12 +26,13 @@
|
||||
"game_has_no_executable": "اللعبة لا تحتوي على ملف تشغيل",
|
||||
"sign_in": "تسجيل الدخول",
|
||||
"friends": "الأصدقاء",
|
||||
"need_help": "تحتاج مساعدة؟"
|
||||
"need_help": "تحتاج مساعدة؟",
|
||||
"favorites": "المفضلة"
|
||||
},
|
||||
"header": {
|
||||
"search": "ابحث عن الألعاب",
|
||||
"search": "بحث الألعاب",
|
||||
"home": "الرئيسية",
|
||||
"catalogue": "الكـتالوج",
|
||||
"catalogue": "الفهرس",
|
||||
"downloads": "التنزيلات",
|
||||
"search_results": "نتائج البحث",
|
||||
"settings": "الإعدادات",
|
||||
@@ -40,16 +41,16 @@
|
||||
},
|
||||
"bottom_panel": {
|
||||
"no_downloads_in_progress": "لا توجد تنزيلات قيد التقدم",
|
||||
"downloading_metadata": "جارٍ تنزيل البيانات الوصفية لـ {{title}}...",
|
||||
"downloading": "جارٍ تنزيل {{title}}... ({{percentage}} اكتمال) - الوقت المتبقي {{eta}} - السرعة {{speed}}",
|
||||
"calculating_eta": "جارٍ تنزيل {{title}}... ({{percentage}} اكتمال) - جاري حساب الوقت المتبقي...",
|
||||
"checking_files": "جارٍ فحص ملفات {{title}}... ({{percentage}} اكتمال)"
|
||||
"downloading_metadata": "جاري تنزيل بيانات {{title}} الوصفية...",
|
||||
"downloading": "جاري تنزيل {{title}}... ({{percentage}} مكتمل) - الوقت المتبقي {{eta}} - السرعة {{speed}}",
|
||||
"calculating_eta": "جاري تنزيل {{title}}... ({{percentage}} مكتمل) - جاري حساب الوقت المتبقي...",
|
||||
"checking_files": "جاري فحص ملفات {{title}}... ({{percentage}} مكتمل)"
|
||||
},
|
||||
"catalogue": {
|
||||
"search": "تصفية...",
|
||||
"developers": "المطورون",
|
||||
"genres": "الأنواع",
|
||||
"tags": "العلامات",
|
||||
"tags": "الوسوم",
|
||||
"publishers": "الناشرون",
|
||||
"download_sources": "مصادر التنزيل",
|
||||
"result_count": "{{resultCount}} نتيجة",
|
||||
@@ -68,34 +69,34 @@
|
||||
"cancel": "إلغاء",
|
||||
"remove": "إزالة",
|
||||
"space_left_on_disk": "{{space}} متبقي على القرص",
|
||||
"eta": "الانتهاء {{eta}}",
|
||||
"calculating_eta": "جارٍ حساب الوقت المتبقي...",
|
||||
"downloading_metadata": "جارٍ تنزيل البيانات الوصفية...",
|
||||
"filter": "تصفية الحزم المعاد تعبئتها",
|
||||
"eta": "الانتهاء المتوقع {{eta}}",
|
||||
"calculating_eta": "جاري حساب الوقت المتبقي...",
|
||||
"downloading_metadata": "جاري تنزيل البيانات الوصفية...",
|
||||
"filter": "تصفية الإصدارات المعادة",
|
||||
"requirements": "متطلبات النظام",
|
||||
"minimum": "الحد الأدنى",
|
||||
"recommended": "مُوصى به",
|
||||
"paused": "معلّق",
|
||||
"recommended": "مستحسن",
|
||||
"paused": "معلق",
|
||||
"release_date": "تاريخ الإصدار {{date}}",
|
||||
"publisher": "نشر بواسطة {{publisher}}",
|
||||
"hours": "ساعات",
|
||||
"minutes": "دقائق",
|
||||
"amount_hours": "{{amount}} ساعات",
|
||||
"amount_minutes": "{{amount}} دقائق",
|
||||
"amount_hours": "{{amount}} ساعة",
|
||||
"amount_minutes": "{{amount}} دقيقة",
|
||||
"accuracy": "دقة {{accuracy}}%",
|
||||
"add_to_library": "إضافة إلى المكتبة",
|
||||
"remove_from_library": "إزالة من المكتبة",
|
||||
"no_downloads": "لا توجد تنزيلات متاحة",
|
||||
"play_time": "لعب لمدة {{amount}}",
|
||||
"last_time_played": "آخر تشغيل {{period}}",
|
||||
"play_time": "وقت اللعب {{amount}}",
|
||||
"last_time_played": "آخر مرة لعب {{period}}",
|
||||
"not_played_yet": "لم تلعب {{title}} بعد",
|
||||
"next_suggestion": "الاقتراح التالي",
|
||||
"play": "تشغيل",
|
||||
"deleting": "جارٍ حذف المثبت...",
|
||||
"deleting": "جاري حذف المثبت...",
|
||||
"close": "إغلاق",
|
||||
"playing_now": "يتم التشغيل الآن",
|
||||
"playing_now": "جاري التشغيل الآن",
|
||||
"change": "تغيير",
|
||||
"repacks_modal_description": "اختر الحزمة المعاد تعبئتها التي تريد تنزيلها",
|
||||
"repacks_modal_description": "اختر الإصدار المعاد الذي تريد تنزيله",
|
||||
"select_folder_hint": "لتغيير المجلد الافتراضي، انتقل إلى <0>الإعدادات</0>",
|
||||
"download_now": "تنزيل الآن",
|
||||
"no_shop_details": "تعذر الحصول على تفاصيل المتجر.",
|
||||
@@ -110,12 +111,12 @@
|
||||
"select_executable": "تحديد",
|
||||
"no_executable_selected": "لم يتم تحديد ملف تشغيل",
|
||||
"open_folder": "فتح المجلد",
|
||||
"open_download_location": "عرض الملفات المحملة",
|
||||
"open_download_location": "عرض الملفات المنزلة",
|
||||
"create_shortcut": "إنشاء اختصار على سطح المكتب",
|
||||
"clear": "مسح",
|
||||
"remove_files": "إزالة الملفات",
|
||||
"remove_from_library_title": "هل أنت متأكد؟",
|
||||
"remove_from_library_description": "سيؤدي هذا إلى إزالة {{game}} من مكتبتك",
|
||||
"remove_from_library_description": "سيتم إزالة {{game}} من مكتبتك",
|
||||
"options": "خيارات",
|
||||
"executable_section_title": "ملف التشغيل",
|
||||
"executable_section_description": "مسار الملف الذي سيتم تشغيله عند النقر على \"تشغيل\"",
|
||||
@@ -123,35 +124,35 @@
|
||||
"downloads_section_description": "تحقق من التحديثات أو الإصدارات الأخرى لهذه اللعبة",
|
||||
"danger_zone_section_title": "منطقة الخطر",
|
||||
"danger_zone_section_description": "إزالة هذه اللعبة من مكتبتك أو الملفات التي تم تنزيلها بواسطة Hydra",
|
||||
"download_in_progress": "تنزيل قيد التقدم",
|
||||
"download_in_progress": "جاري التنزيل",
|
||||
"download_paused": "التنزيل معلق",
|
||||
"last_downloaded_option": "خيار التنزيل الأخير",
|
||||
"create_shortcut_success": "تم إنشاء الاختصار بنجاح",
|
||||
"create_shortcut_error": "خطأ في إنشاء الاختصار",
|
||||
"nsfw_content_title": "هذه اللعبة تحتوي على محتوى غير لائق",
|
||||
"nsfw_content_description": "{{title}} يحتوي على محتوى قد لا يناسب جميع الأعمار. هل تريد المتابعة؟",
|
||||
"nsfw_content_description": "{{title}} يحتوي على محتوى قد لا يكون مناسبًا لجميع الأعمار. هل تريد المتابعة؟",
|
||||
"allow_nsfw_content": "متابعة",
|
||||
"refuse_nsfw_content": "رجوع",
|
||||
"stats": "الإحصائيات",
|
||||
"download_count": "مرات التنزيل",
|
||||
"download_count": "التنزيلات",
|
||||
"player_count": "اللاعبون النشطون",
|
||||
"download_error": "خيار التنزيل هذا غير متاح",
|
||||
"download": "تنزيل",
|
||||
"executable_path_in_use": "مسار التشغيل مستخدم بالفعل بواسطة \"{{game}}\"",
|
||||
"warning": "تحذير:",
|
||||
"hydra_needs_to_remain_open": "لهذا التنزيل، يجب أن يبقى Hydra مفتوحًا حتى اكتماله. إذا أغلق Hydra قبل الاكتمال، ستفقد تقدمك.",
|
||||
"hydra_needs_to_remain_open": "لهذا التنزيل، يجب أن يظل Hydra مفتوحًا حتى اكتماله. إذا تم إغلاق Hydra قبل الاكتمال، ستفقد تقدمك.",
|
||||
"achievements": "الإنجازات",
|
||||
"achievements_count": "الإنجازات {{unlockedCount}}/{{achievementsCount}}",
|
||||
"cloud_save": "حفظ سحابي",
|
||||
"cloud_save_description": "احفظ تقدمك على السحابة واستمر في اللعب من أي جهاز",
|
||||
"cloud_save_description": "احفظ تقدمك في السحابة واستمر في اللعب من أي جهاز",
|
||||
"backups": "النسخ الاحتياطية",
|
||||
"install_backup": "تثبيت",
|
||||
"delete_backup": "حذف",
|
||||
"create_backup": "نسخة احتياطية جديدة",
|
||||
"last_backup_date": "آخر نسخة احتياطية في {{date}}",
|
||||
"no_backup_preview": "لم يتم العثور على حفظات لهذا العنوان",
|
||||
"restoring_backup": "جارٍ استعادة النسخة الاحتياطية ({{progress}} اكتمال)...",
|
||||
"uploading_backup": "جارٍ رفع النسخة الاحتياطية...",
|
||||
"restoring_backup": "جاري استعادة النسخة الاحتياطية ({{progress}} مكتمل)...",
|
||||
"uploading_backup": "جاري رفع النسخة الاحتياطية...",
|
||||
"no_backups": "لم تقم بإنشاء أي نسخ احتياطية لهذه اللعبة بعد",
|
||||
"backup_uploaded": "تم رفع النسخة الاحتياطية",
|
||||
"backup_deleted": "تم حذف النسخة الاحتياطية",
|
||||
@@ -164,61 +165,67 @@
|
||||
"files_automatically_mapped": "تم تعيين الملفات تلقائيًا",
|
||||
"no_backups_created": "لم يتم إنشاء نسخ احتياطية لهذه اللعبة",
|
||||
"manage_files": "إدارة الملفات",
|
||||
"loading_save_preview": "جارٍ البحث عن حفظات الألعاب...",
|
||||
"loading_save_preview": "جاري البحث عن حفظات اللعبة...",
|
||||
"wine_prefix": "بادئة Wine",
|
||||
"wine_prefix_description": "بادئة Wine المستخدمة لتشغيل هذه اللعبة",
|
||||
"launch_options": "خيارات التشغيل",
|
||||
"launch_options_description": "يمكن للمستخدمين المتقدمين إدخال تعديلات على خيارات التشغيل (ميزة تجريبية)",
|
||||
"launch_options_placeholder": "لم يتم تحديد أي معاملات",
|
||||
"launch_options_placeholder": "لا توجد معلمات محددة",
|
||||
"no_download_option_info": "لا توجد معلومات متاحة",
|
||||
"backup_deletion_failed": "فشل حذف النسخة الاحتياطية",
|
||||
"max_number_of_artifacts_reached": "تم الوصول إلى الحد الأقصى لعدد النسخ الاحتياطية لهذه اللعبة",
|
||||
"achievements_not_sync": "تعرف على كيفية مزامنة إنجازاتك",
|
||||
"backup_deletion_failed": "فشل في حذف النسخة الاحتياطية",
|
||||
"max_number_of_artifacts_reached": "تم الوصول إلى الحد الأقصى من النسخ الاحتياطية لهذه اللعبة",
|
||||
"achievements_not_sync": "شاهد كيفية مزامنة إنجازاتك",
|
||||
"manage_files_description": "إدارة الملفات التي سيتم نسخها احتياطيًا واستعادتها",
|
||||
"select_folder": "حدد المجلد",
|
||||
"backup_from": "نسخة احتياطية من {{date}}",
|
||||
"custom_backup_location_set": "تم تعيين موقع نسخ احتياطي مخصص",
|
||||
"no_directory_selected": "لم يتم تحديد مجلد",
|
||||
"no_write_permission": "لا يمكن التنزيل إلى هذا المجلد. انقر هنا لمعرفة المزيد.",
|
||||
"no_write_permission": "لا يمكن التنزيل إلى هذا المجلد. انقر هنا للمزيد من المعلومات.",
|
||||
"reset_achievements": "إعادة تعيين الإنجازات",
|
||||
"reset_achievements_description": "سيؤدي هذا إلى إعادة تعيين جميع إنجازات {{game}}",
|
||||
"reset_achievements_title": "هل أنت متأكد؟",
|
||||
"reset_achievements_success": "تم إعادة تعيين الإنجازات بنجاح",
|
||||
"reset_achievements_error": "فشل إعادة تعيين الإنجازات"
|
||||
"reset_achievements_error": "فشل في إعادة تعيين الإنجازات",
|
||||
"download_error_gofile_quota_exceeded": "لقد تجاوزت الحصة الشهرية لـ Gofile. يرجى الانتظار حتى إعادة تعيين الحصة.",
|
||||
"download_error_real_debrid_account_not_authorized": "حساب Real-Debrid الخاص بك غير مصرح له بإجراء تنزيلات جديدة. يرجى مراجعة إعدادات الحساب والمحاولة مرة أخرى.",
|
||||
"download_error_not_cached_in_real_debrid": "هذا التنزيل غير متوفر على Real-Debrid وجلب حالة التنزيل من Real-Debrid غير متاح حاليًا.",
|
||||
"download_error_not_cached_in_torbox": "هذا التنزيل غير متوفر على Torbox وجلب حالة التنزيل من Torbox غير متاح حاليًا.",
|
||||
"game_removed_from_favorites": "تمت إزالة اللعبة من المفضلة",
|
||||
"game_added_to_favorites": "تمت إضافة اللعبة إلى المفضلة"
|
||||
},
|
||||
"activation": {
|
||||
"title": "تفعيل Hydra",
|
||||
"installation_id": "معرف التثبيت:",
|
||||
"enter_activation_code": "أدخل رمز التفعيل الخاص بك",
|
||||
"message": "إذا كنت لا تعرف أين تطلب هذا، فلا يجب أن يكون لديك هذا.",
|
||||
"message": "إذا كنت لا تعرف أين تطلب هذا، فأنت لا يجب أن يكون لديك هذا.",
|
||||
"activate": "تفعيل",
|
||||
"loading": "جارٍ التحميل..."
|
||||
"loading": "جاري التحميل..."
|
||||
},
|
||||
"downloads": {
|
||||
"resume": "استئناف",
|
||||
"pause": "إيقاف مؤقت",
|
||||
"eta": "الانتهاء {{eta}}",
|
||||
"paused": "معلّق",
|
||||
"verifying": "جارٍ التحقق...",
|
||||
"eta": "الانتهاء المتوقع {{eta}}",
|
||||
"paused": "معلق",
|
||||
"verifying": "جاري التحقق...",
|
||||
"completed": "مكتمل",
|
||||
"removed": "غير محمل",
|
||||
"removed": "غير منزّل",
|
||||
"cancel": "إلغاء",
|
||||
"filter": "تصفية الألعاب المحملة",
|
||||
"filter": "تصفية الألعاب المنزلة",
|
||||
"remove": "إزالة",
|
||||
"downloading_metadata": "جارٍ تنزيل البيانات الوصفية...",
|
||||
"deleting": "جارٍ حذف المثبت...",
|
||||
"delete": "إزالة المثبت",
|
||||
"downloading_metadata": "جاري تنزيل البيانات الوصفية...",
|
||||
"deleting": "جاري حذف المثبت...",
|
||||
"delete": "حذف المثبت",
|
||||
"delete_modal_title": "هل أنت متأكد؟",
|
||||
"delete_modal_description": "سيؤدي هذا إلى إزالة جميع ملفات التثبيت من جهازك",
|
||||
"install": "تثبيت",
|
||||
"download_in_progress": "قيد التقدم",
|
||||
"queued_downloads": "التنزيلات في قائمة الانتظار",
|
||||
"downloads_completed": "مكتمل",
|
||||
"downloads_completed": "مكتملة",
|
||||
"queued": "في قائمة الانتظار",
|
||||
"no_downloads_title": "فارغ جدًا",
|
||||
"no_downloads_title": "لا شيء هنا",
|
||||
"no_downloads_description": "لم تقم بتنزيل أي شيء باستخدام Hydra بعد، ولكن لم يفت الأوان للبدء.",
|
||||
"checking_files": "جارٍ فحص الملفات...",
|
||||
"seeding": "التوزيع",
|
||||
"checking_files": "جاري فحص الملفات...",
|
||||
"seeding": "جاري التوزيع",
|
||||
"stop_seeding": "إيقاف التوزيع",
|
||||
"resume_seeding": "استئناف التوزيع",
|
||||
"options": "إدارة"
|
||||
@@ -228,8 +235,8 @@
|
||||
"change": "تحديث",
|
||||
"notifications": "الإشعارات",
|
||||
"enable_download_notifications": "عند اكتمال التنزيل",
|
||||
"enable_repack_list_notifications": "عند إضافة حزمة معاد تعبئتها جديدة",
|
||||
"real_debrid_api_token_label": "رمز واجهة برمجة تطبيقات Real-Debrid",
|
||||
"enable_repack_list_notifications": "عند إضافة إصدار معاد جديد",
|
||||
"real_debrid_api_token_label": "رمز Real-Debrid API",
|
||||
"quit_app_instead_hiding": "لا تخفي Hydra عند الإغلاق",
|
||||
"launch_with_system": "تشغيل Hydra مع بدء النظام",
|
||||
"general": "عام",
|
||||
@@ -238,21 +245,21 @@
|
||||
"language": "اللغة",
|
||||
"api_token": "رمز API",
|
||||
"enable_real_debrid": "تفعيل Real-Debrid",
|
||||
"real_debrid_description": "Real-Debrid هو أداة تنزيل غير مقيدة تتيح لك تنزيل الملفات بسرعة، مقيدة فقط بسرعة الإنترنت لديك.",
|
||||
"real_debrid_description": "Real-Debrid هو أداة تنزيل غير مقيدة تتيح لك تنزيل الملفات بسرعة، محدودة فقط بسرعة اتصالك بالإنترنت.",
|
||||
"debrid_invalid_token": "رمز API غير صالح",
|
||||
"debrid_api_token_hint": "يمكنك الحصول على رمز API الخاص بك <0>هنا</0>",
|
||||
"real_debrid_free_account_error": "الحساب \"{{username}}\" هو حساب مجاني. يرجى الاشتراك في Real-Debrid",
|
||||
"real_debrid_free_account_error": "الحساب \"{{username}}\" حساب مجاني. يرجى الاشتراك في Real-Debrid",
|
||||
"debrid_linked_message": "تم ربط الحساب \"{{username}}\"",
|
||||
"save_changes": "حفظ التغييرات",
|
||||
"changes_saved": "تم حفظ التغييرات بنجاح",
|
||||
"download_sources_description": "سيقوم Hydra بجلب روابط التنزيل من هذه المصادر. يجب أن يكون عنوان URL المصدر رابطًا مباشرًا لملف .json يحتوي على روابط التنزيل.",
|
||||
"download_sources_description": "سيقوم Hydra بجلب روابط التنزيل من هذه المصادر. يجب أن يكون عنوان URL للمصدر رابطًا مباشرًا لملف .json يحتوي على روابط التنزيل.",
|
||||
"validate_download_source": "تحقق",
|
||||
"remove_download_source": "إزالة",
|
||||
"add_download_source": "إضافة مصدر",
|
||||
"download_count_zero": "لا توجد خيارات تنزيل",
|
||||
"download_count_one": "{{countFormatted}} خيار تنزيل",
|
||||
"download_count_other": "{{countFormatted}} خيارات تنزيل",
|
||||
"download_source_url": "عنوان URL لمصدر التنزيل",
|
||||
"download_source_url": "عنوان مصدر التنزيل",
|
||||
"add_download_source_description": "أدخل عنوان URL لملف .json",
|
||||
"download_source_up_to_date": "محدث",
|
||||
"download_source_errored": "خطأ",
|
||||
@@ -272,13 +279,13 @@
|
||||
"profile_visibility": "رؤية الملف الشخصي",
|
||||
"profile_visibility_description": "اختر من يمكنه رؤية ملفك الشخصي ومكتبتك",
|
||||
"required_field": "هذا الحقل مطلوب",
|
||||
"source_already_exists": "تمت إضافة هذا المصدر مسبقًا",
|
||||
"must_be_valid_url": "يجب أن يكون المصدر عنوان URL صالحًا",
|
||||
"source_already_exists": "هذا المصدر مضاف مسبقًا",
|
||||
"must_be_valid_url": "يجب أن يكون المصدر عنوان URL صالح",
|
||||
"blocked_users": "المستخدمون المحظورون",
|
||||
"user_unblocked": "تم إلغاء حظر المستخدم",
|
||||
"enable_achievement_notifications": "عند فتح إنجاز",
|
||||
"launch_minimized": "تشغيل Hydra مصغرًا",
|
||||
"disable_nsfw_alert": "تعطيل تنبيه المحتوى غير اللائق",
|
||||
"disable_nsfw_alert": "تعطيل تنبيهات المحتوى غير اللائق",
|
||||
"seed_after_download_complete": "التوزيع بعد اكتمال التنزيل",
|
||||
"show_hidden_achievement_description": "عرض وصف الإنجازات المخفية قبل فتحها",
|
||||
"account": "الحساب",
|
||||
@@ -296,18 +303,47 @@
|
||||
"become_subscriber": "كن مشتركًا في Hydra Cloud",
|
||||
"subscription_renew_cancelled": "تم تعطيل التجديد التلقائي",
|
||||
"subscription_renews_on": "سيتم تجديد اشتراكك في {{date}}",
|
||||
"bill_sent_until": "سيتم إرسال فاتورتك التالية حتى هذا اليوم"
|
||||
"bill_sent_until": "سيتم إرسال فاتورتك القادمة حتى هذا اليوم",
|
||||
"no_themes": "يبدو أنه ليس لديك أي سمات بعد، لكن لا تقلق، انقر هنا لإنشاء أول تحفة فنية لك.",
|
||||
"editor_tab_code": "الكود",
|
||||
"editor_tab_info": "معلومات",
|
||||
"editor_tab_save": "حفظ",
|
||||
"web_store": "المتجر الإلكتروني",
|
||||
"clear_themes": "مسح",
|
||||
"create_theme": "إنشاء",
|
||||
"create_theme_modal_title": "إنشاء سمة مخصصة",
|
||||
"create_theme_modal_description": "إنشاء سمة جديدة لتخصيص مظهر Hydra",
|
||||
"theme_name": "الاسم",
|
||||
"insert_theme_name": "أدخل اسم السمة",
|
||||
"set_theme": "تعيين السمة",
|
||||
"unset_theme": "إلغاء تعيين السمة",
|
||||
"delete_theme": "حذف السمة",
|
||||
"edit_theme": "تعديل السمة",
|
||||
"delete_all_themes": "حذف جميع السمات",
|
||||
"delete_all_themes_description": "سيؤدي هذا إلى حذف جميع السمات المخصصة الخاصة بك",
|
||||
"delete_theme_description": "سيؤدي هذا إلى حذف السمة {{theme}}",
|
||||
"cancel": "إلغاء",
|
||||
"appearance": "المظهر",
|
||||
"enable_torbox": "تفعيل Torbox",
|
||||
"torbox_description": "TorBox هي خدمة seedbox متميزة تنافس أفضل الخوادم في السوق.",
|
||||
"torbox_account_linked": "تم ربط حساب TorBox",
|
||||
"real_debrid_account_linked": "تم ربط حساب Real-Debrid",
|
||||
"name_min_length": "يجب أن يكون اسم السمة على الأقل 3 أحرف",
|
||||
"import_theme": "استيراد سمة",
|
||||
"import_theme_description": "ستقوم باستيراد {{theme}} من متجر السمات",
|
||||
"error_importing_theme": "خطأ في استيراد السمة",
|
||||
"theme_imported": "تم استيراد السمة بنجاح"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "اكتمل التنزيل",
|
||||
"game_ready_to_install": "{{title}} جاهز للتثبيت",
|
||||
"repack_list_updated": "تم تحديث قائمة الحزم المعاد تعبئتها",
|
||||
"repack_count_one": "تمت إضافة {{count}} حزمة معاد تعبئتها",
|
||||
"repack_count_other": "تمت إضافة {{count}} حزم معاد تعبئتها",
|
||||
"repack_list_updated": "تم تحديث قائمة الإصدارات المعادة",
|
||||
"repack_count_one": "تمت إضافة {{count}} إصدار معاد",
|
||||
"repack_count_other": "تمت إضافة {{count}} إصدارات معادة",
|
||||
"new_update_available": "الإصدار {{version}} متوفر",
|
||||
"restart_to_install_update": "أعد تشغيل Hydra لتثبيت التحديث",
|
||||
"notification_achievement_unlocked_title": "تم فتح إنجاز لـ {{game}}",
|
||||
"notification_achievement_unlocked_body": "{{achievement}} و {{count}} آخرين تم فتحهم"
|
||||
"notification_achievement_unlocked_body": "{{achievement}} و {{count}} أخرى تم فتحها"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "فتح Hydra",
|
||||
@@ -319,7 +355,7 @@
|
||||
"binary_not_found_modal": {
|
||||
"title": "البرامج غير مثبتة",
|
||||
"description": "لم يتم العثور على ملفات تشغيل Wine أو Lutris على نظامك",
|
||||
"instructions": "تحقق من الطريقة الصحيحة لتثبيت أي منها على توزيعة لينكس الخاصة بك حتى تعمل اللعبة بشكل طبيعي"
|
||||
"instructions": "تحقق من الطريقة الصحيحة لتثبيت أي منها على توزيعة Linux الخاصة بك حتى تعمل اللعبة بشكل طبيعي"
|
||||
},
|
||||
"modal": {
|
||||
"close": "زر الإغلاق"
|
||||
@@ -328,16 +364,16 @@
|
||||
"toggle_password_visibility": "تبديل رؤية كلمة المرور"
|
||||
},
|
||||
"user_profile": {
|
||||
"amount_hours": "{{amount}} ساعات",
|
||||
"amount_minutes": "{{amount}} دقائق",
|
||||
"last_time_played": "آخر تشغيل {{period}}",
|
||||
"amount_hours": "{{amount}} ساعة",
|
||||
"amount_minutes": "{{amount}} دقيقة",
|
||||
"last_time_played": "آخر مرة لعب {{period}}",
|
||||
"activity": "النشاط الأخير",
|
||||
"library": "المكتبة",
|
||||
"total_play_time": "إجمالي وقت اللعب",
|
||||
"no_recent_activity_title": "همم... لا شيء هنا",
|
||||
"no_recent_activity_title": "لا شيء هنا...",
|
||||
"no_recent_activity_description": "لم تلعب أي ألعاب مؤخرًا. حان الوقت لتغيير ذلك!",
|
||||
"display_name": "اسم العرض",
|
||||
"saving": "جارٍ الحفظ",
|
||||
"saving": "جاري الحفظ",
|
||||
"save": "حفظ",
|
||||
"edit_profile": "تعديل الملف الشخصي",
|
||||
"saved_successfully": "تم الحفظ بنجاح",
|
||||
@@ -346,13 +382,13 @@
|
||||
"cancel": "إلغاء",
|
||||
"successfully_signed_out": "تم تسجيل الخروج بنجاح",
|
||||
"sign_out": "تسجيل الخروج",
|
||||
"playing_for": "يلعب لمدة {{amount}}",
|
||||
"sign_out_modal_text": "مكتبتك مرتبطة بحسابك الحالي. عند تسجيل الخروج، لن تكون مكتبتك مرئية بعد الآن، ولن يتم حفظ أي تقدم. هل تتابع تسجيل الخروج؟",
|
||||
"playing_for": "جاري اللعب لمدة {{amount}}",
|
||||
"sign_out_modal_text": "مكتبتك مرتبطة بحسابك الحالي. عند تسجيل الخروج، لن تكون مكتبتك مرئية، ولن يتم حفظ أي تقدم. هل تتابع تسجيل الخروج؟",
|
||||
"add_friends": "إضافة أصدقاء",
|
||||
"add": "إضافة",
|
||||
"friend_code": "رمز الصديق",
|
||||
"see_profile": "عرض الملف الشخصي",
|
||||
"sending": "جارٍ الإرسال",
|
||||
"sending": "جاري الإرسال",
|
||||
"friend_request_sent": "تم إرسال طلب الصداقة",
|
||||
"friends": "الأصدقاء",
|
||||
"friends_list": "قائمة الأصدقاء",
|
||||
@@ -371,19 +407,19 @@
|
||||
"blocked_users": "المستخدمون المحظورون",
|
||||
"unblock": "إلغاء الحظر",
|
||||
"no_friends_added": "ليس لديك أصدقاء مضافون",
|
||||
"pending": "قيد الانتظار",
|
||||
"pending": "معلق",
|
||||
"no_pending_invites": "ليس لديك دعوات معلقة",
|
||||
"no_blocked_users": "ليس لديك مستخدمون محظورون",
|
||||
"friend_code_copied": "تم نسخ رمز الصديق",
|
||||
"undo_friendship_modal_text": "سيؤدي هذا إلى إلغاء صداقتك مع {{displayName}}",
|
||||
"privacy_hint": "لضبط من يمكنه رؤية هذا، انتقل إلى <0>الإعدادات</0>",
|
||||
"locked_profile": "هذا الملف الشخصي خاص",
|
||||
"image_process_failure": "فشل معالجة الصورة",
|
||||
"image_process_failure": "فشل في معالجة الصورة",
|
||||
"required_field": "هذا الحقل مطلوب",
|
||||
"displayname_min_length": "يجب أن يكون اسم العرض على الأقل 3 أحرف",
|
||||
"displayname_max_length": "يجب ألا يتجاوز اسم العرض 50 حرفًا",
|
||||
"report_profile": "الإبلاغ عن هذا الملف الشخصي",
|
||||
"report_reason": "لماذا تقوم بالإبلاغ عن هذا الملف الشخصي؟",
|
||||
"displayname_max_length": "يجب أن لا يتجاوز اسم العرض 50 حرفًا",
|
||||
"report_profile": "الإبلاغ عن هذا الملف",
|
||||
"report_reason": "لماذا تقوم بالإبلاغ عن هذا الملف؟",
|
||||
"report_description": "معلومات إضافية",
|
||||
"report_description_placeholder": "معلومات إضافية",
|
||||
"report": "الإبلاغ",
|
||||
@@ -393,32 +429,32 @@
|
||||
"report_reason_spam": "بريد عشوائي",
|
||||
"report_reason_other": "أخرى",
|
||||
"profile_reported": "تم الإبلاغ عن الملف الشخصي",
|
||||
"your_friend_code": "رمز صديقك:",
|
||||
"upload_banner": "تحميل بانر",
|
||||
"uploading_banner": "جارٍ تحميل البانر...",
|
||||
"your_friend_code": "رمز الصديق الخاص بك:",
|
||||
"upload_banner": "رفع بانر",
|
||||
"uploading_banner": "جاري رفع البانر...",
|
||||
"background_image_updated": "تم تحديث صورة الخلفية",
|
||||
"stats": "الإحصائيات",
|
||||
"achievements": "إنجازات",
|
||||
"achievements": "الإنجازات",
|
||||
"games": "الألعاب",
|
||||
"top_percentile": "ال{{percentile}}% الأعلى",
|
||||
"top_percentile": "الأعلى {{percentile}}%",
|
||||
"ranking_updated_weekly": "يتم تحديث التصنيف أسبوعيًا",
|
||||
"playing": "يلعب {{game}}",
|
||||
"playing": "جاري لعب {{game}}",
|
||||
"achievements_unlocked": "الإنجازات المفتوحة",
|
||||
"earned_points": "النقاط المكتسبة",
|
||||
"show_achievements_on_profile": "عرض إنجازاتك على ملفك الشخصي",
|
||||
"show_points_on_profile": "عرض نقاطك المكتسبة على ملفك الشخصي"
|
||||
"show_achievements_on_profile": "عرض إنجازاتك في ملفك الشخصي",
|
||||
"show_points_on_profile": "عرض نقاطك المكتسبة في ملفك الشخصي"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "تم فتح الإنجاز",
|
||||
"user_achievements": "إنجازات {{displayName}}",
|
||||
"your_achievements": "إنجازاتك",
|
||||
"unlocked_at": "تم الفتح في: {{date}}",
|
||||
"subscription_needed": "يحتاج إلى اشتراك Hydra Cloud لرؤية هذا المحتوى",
|
||||
"subscription_needed": "يحتاج إلى اشتراك Hydra Cloud لعرض هذا المحتوى",
|
||||
"new_achievements_unlocked": "تم فتح {{achievementCount}} إنجازات جديدة من {{gameCount}} ألعاب",
|
||||
"achievement_progress": "{{unlockedCount}}/{{totalCount}} إنجازات",
|
||||
"achievements_unlocked_for_game": "تم فتح {{achievementCount}} إنجازات جديدة لـ {{gameTitle}}",
|
||||
"hidden_achievement_tooltip": "هذا إنجاز مخفي",
|
||||
"achievement_earn_points": "اكسب {{points}} نقطة مع هذا الإنجاز",
|
||||
"achievement_earn_points": "احصل على {{points}} نقاط مع هذا الإنجاز",
|
||||
"earned_points": "النقاط المكتسبة:",
|
||||
"available_points": "النقاط المتاحة:",
|
||||
"how_to_earn_achievements_points": "كيفية كسب نقاط الإنجازات؟"
|
||||
@@ -428,10 +464,10 @@
|
||||
"subscribe_now": "اشترك الآن",
|
||||
"cloud_saving": "حفظ سحابي",
|
||||
"cloud_achievements": "احفظ إنجازاتك على السحابة",
|
||||
"animated_profile_picture": "صورة ملف شخصي متحركة",
|
||||
"animated_profile_picture": "صورة ملف متحركة",
|
||||
"premium_support": "دعم ممتاز",
|
||||
"show_and_compare_achievements": "اعرض وقارن إنجازاتك مع المستخدمين الآخرين",
|
||||
"animated_profile_banner": "بانر ملف شخصي متحرك",
|
||||
"animated_profile_banner": "بانر ملف متحرك",
|
||||
"hydra_cloud": "Hydra Cloud",
|
||||
"hydra_cloud_feature_found": "لقد اكتشفت ميزة Hydra Cloud!",
|
||||
"learn_more": "معرفة المزيد"
|
||||
|
||||
@@ -178,6 +178,8 @@
|
||||
"manage_files_description": "Manage which files will be backed up and restored",
|
||||
"select_folder": "Select folder",
|
||||
"backup_from": "Backup from {{date}}",
|
||||
"automatic_backup_from": "Automatic backup from {{date}}",
|
||||
"enable_automatic_cloud_sync": "Enable automatic cloud sync",
|
||||
"custom_backup_location_set": "Custom backup location set",
|
||||
"no_directory_selected": "No directory selected",
|
||||
"no_write_permission": "Cannot download into this directory. Click here to learn more.",
|
||||
@@ -189,9 +191,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 +306,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",
|
||||
|
||||
@@ -174,6 +174,8 @@
|
||||
"manage_files_description": "Gestiona los archivos que serán respaldados y restaurados",
|
||||
"select_folder": "Seleccionar carpeta",
|
||||
"backup_from": "Copia de seguridad de {{date}}",
|
||||
"automatic_backup_from": "Copia de seguridad automática de {{date}}",
|
||||
"enable_automatic_cloud_sync": "Habilitar sincronización automática en la nube",
|
||||
"custom_backup_location_set": "Se configuró la carpeta de copia de seguridad",
|
||||
"clear": "Limpiar",
|
||||
"no_directory_selected": "No se seleccionó un directorio",
|
||||
@@ -185,7 +187,13 @@
|
||||
"reset_achievements_description": "Esto reiniciará todos los logros de {{game}}",
|
||||
"reset_achievements_title": "¿Estás seguro?",
|
||||
"reset_achievements_success": "Logros reiniciados exitosamente",
|
||||
"reset_achievements_error": "Se produjo un error al reiniciar los logros"
|
||||
"reset_achievements_error": "Se produjo un error al reiniciar los logros",
|
||||
"download_error_gofile_quota_exceeded": "Has excedido la cuota mensual de Gofile. Por favor espera a que se reinicie la cuota.",
|
||||
"download_error_real_debrid_account_not_authorized": "Tu cuenta de Real-Debrid no está autorizada para nueva descargas. Por favor, revisa los ajustes de tu cuenta e intenta de nuevo.",
|
||||
"download_error_not_cached_in_real_debrid": "Esta descarga no está disponible en Real-Debrid y el estado de descarga del sondeo de Real-Debrid aún no está disponible.",
|
||||
"download_error_not_cached_in_torbox": "Esta descarga no está disponible en Torbox y el estado de descarga del sondeo aún no está disponible.",
|
||||
"game_added_to_favorites": "Juego añadido a favoritos",
|
||||
"game_removed_from_favorites": "Juego removido de favoritos"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Activar Hydra",
|
||||
@@ -297,7 +305,37 @@
|
||||
"subscription_renew_cancelled": "Está desactivada la renovación automática",
|
||||
"subscription_renews_on": "Tú suscripción se renueva el {{date}}",
|
||||
"update_email": "Actualizar correo",
|
||||
"update_password": "Actualizar contraseña"
|
||||
"update_password": "Actualizar contraseña",
|
||||
"appearance": "Apariencia",
|
||||
"become_subscriber": "Sé Hydra Cloud",
|
||||
"cancel": "Cancelar",
|
||||
"clear_themes": "Limpiar",
|
||||
"create_theme": "Crear",
|
||||
"create_theme_modal_description": "Crea un nuevo tema para personalizar la apariencia de Hydra",
|
||||
"create_theme_modal_title": "Crear tema personalizado",
|
||||
"delete_all_themes": "Eliminar todos los temas",
|
||||
"delete_all_themes_description": "Esto eliminará todos tus temas personalizados",
|
||||
"delete_theme": "Eliminar tema",
|
||||
"delete_theme_description": "Esto eliminará el tema {{theme}}",
|
||||
"edit_theme": "Editar tema",
|
||||
"editor_tab_code": "Código",
|
||||
"editor_tab_info": "Info",
|
||||
"editor_tab_save": "Guardar",
|
||||
"enable_torbox": "Habilitar Torbox",
|
||||
"error_importing_theme": "Error al importar el tema",
|
||||
"import_theme": "Importar tema",
|
||||
"import_theme_description": "Vas a importar el tema {{theme}} desde la tienda de temas",
|
||||
"insert_theme_name": "Introducí el nombre del tema",
|
||||
"name_min_length": "El tema tiene que tener 3 carácteres de largo mínimo",
|
||||
"no_themes": "Parece que no tenés ningún tema aún, pero no te preocupes, presiona acá para crear tu primer tema.",
|
||||
"real_debrid_account_linked": "Cuenta de Real-Debrid vinculada",
|
||||
"set_theme": "Establecer tema",
|
||||
"theme_imported": "Tema importado exitosamente",
|
||||
"theme_name": "Nombre",
|
||||
"torbox_account_linked": "Cuenta de TorBox vinculada",
|
||||
"torbox_description": "TorBox es tu servicio premium de seedbox que rivaliza incluso a los mejores servidores del mercado.",
|
||||
"unset_theme": "Desactivar tema",
|
||||
"web_store": "Tienda Web"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Descarga completada",
|
||||
|
||||
@@ -165,6 +165,8 @@
|
||||
"max_number_of_artifacts_reached": "Número máximo de backups atingido para este jogo",
|
||||
"achievements_not_sync": "Veja como exibir suas conquistas no perfil",
|
||||
"backup_from": "Backup de {{date}}",
|
||||
"automatic_backup_from": "Backup automático de {{date}}",
|
||||
"enable_automatic_cloud_sync": "Habilitar sincronização automática na nuvem",
|
||||
"custom_backup_location_set": "Localização customizada selecionada",
|
||||
"select_folder": "Selecione a pasta",
|
||||
"manage_files_description": "Gerencie quais arquivos serão feitos backup",
|
||||
@@ -179,9 +181,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 +296,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",
|
||||
|
||||
@@ -178,12 +178,20 @@
|
||||
"manage_files_description": "Управляйте файлами, которые будут сохраняться и восстанавливаться",
|
||||
"select_folder": "Выбрать папку",
|
||||
"backup_from": "Резервная копия от {{date}}",
|
||||
"automatic_backup_from": "Автоматическая резервная копия от {{date}}",
|
||||
"enable_automatic_cloud_sync": "Включить автоматическую синхронизацию в облаке",
|
||||
"custom_backup_location_set": "Установлено настраиваемое местоположение резервной копии",
|
||||
"no_directory_selected": "Не выбран каталог",
|
||||
"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 +303,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": "Загрузка завершена",
|
||||
|
||||
@@ -3,7 +3,6 @@ import jwt from "jsonwebtoken";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { db, levelKeys } from "@main/level";
|
||||
import type { Auth } from "@types";
|
||||
import { Crypto } from "@main/services";
|
||||
|
||||
const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
const auth = await db.get<string, Auth>(levelKeys.auth, {
|
||||
@@ -11,9 +10,7 @@ const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
});
|
||||
|
||||
if (!auth) return null;
|
||||
const payload = jwt.decode(
|
||||
Crypto.decrypt(auth.accessToken)
|
||||
) as jwt.JwtPayload;
|
||||
const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload;
|
||||
|
||||
if (!payload) return null;
|
||||
|
||||
|
||||
@@ -1,44 +1,8 @@
|
||||
import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services";
|
||||
import { CloudSync } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import crypto from "node:crypto";
|
||||
import type { GameShop } from "@types";
|
||||
import axios from "axios";
|
||||
import os from "node:os";
|
||||
import { backupsPath } from "@main/constants";
|
||||
import { app } from "electron";
|
||||
import { normalizePath } from "@main/helpers";
|
||||
import { gamesSublevel, levelKeys } from "@main/level";
|
||||
|
||||
const bundleBackup = async (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
winePrefix: string | null
|
||||
) => {
|
||||
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
|
||||
|
||||
// Remove existing backup
|
||||
if (fs.existsSync(backupPath)) {
|
||||
fs.rmSync(backupPath, { recursive: true });
|
||||
}
|
||||
|
||||
await Ludusavi.backupGame(shop, objectId, backupPath, winePrefix);
|
||||
|
||||
const tarLocation = path.join(backupsPath, `${crypto.randomUUID()}.tar`);
|
||||
|
||||
await tar.create(
|
||||
{
|
||||
gzip: false,
|
||||
file: tarLocation,
|
||||
cwd: backupPath,
|
||||
},
|
||||
["."]
|
||||
);
|
||||
|
||||
return tarLocation;
|
||||
};
|
||||
import { t } from "i18next";
|
||||
import { format } from "date-fns";
|
||||
|
||||
const uploadSaveGame = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@@ -46,61 +10,15 @@ const uploadSaveGame = async (
|
||||
shop: GameShop,
|
||||
downloadOptionTitle: string | null
|
||||
) => {
|
||||
const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
|
||||
|
||||
const bundleLocation = await bundleBackup(
|
||||
shop,
|
||||
return CloudSync.uploadSaveGame(
|
||||
objectId,
|
||||
game?.winePrefixPath ?? null
|
||||
shop,
|
||||
downloadOptionTitle,
|
||||
t("backup_from", {
|
||||
ns: "game_details",
|
||||
date: format(new Date(), "dd/MM/yyyy"),
|
||||
})
|
||||
);
|
||||
|
||||
fs.stat(bundleLocation, async (err, stat) => {
|
||||
if (err) {
|
||||
logger.error("Failed to get zip file stats", err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const { uploadUrl } = await HydraApi.post<{
|
||||
id: string;
|
||||
uploadUrl: string;
|
||||
}>("/profile/games/artifacts", {
|
||||
artifactLengthInBytes: stat.size,
|
||||
shop,
|
||||
objectId,
|
||||
hostname: os.hostname(),
|
||||
homeDir: normalizePath(app.getPath("home")),
|
||||
downloadOptionTitle,
|
||||
platform: os.platform(),
|
||||
});
|
||||
|
||||
fs.readFile(bundleLocation, async (err, fileBuffer) => {
|
||||
if (err) {
|
||||
logger.error("Failed to read zip file", err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
await axios.put(uploadUrl, fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "application/tar",
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
logger.log(progressEvent);
|
||||
},
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
`on-upload-complete-${objectId}-${shop}`,
|
||||
true
|
||||
);
|
||||
|
||||
fs.rm(bundleLocation, (err) => {
|
||||
if (err) {
|
||||
logger.error("Failed to remove tar file", err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("uploadSaveGame", uploadSaveGame);
|
||||
|
||||
@@ -31,11 +31,13 @@ import "./library/remove-game";
|
||||
import "./library/remove-game-from-library";
|
||||
import "./library/select-game-wine-prefix";
|
||||
import "./library/reset-game-achievements";
|
||||
import "./library/toggle-automatic-cloud-sync";
|
||||
import "./misc/open-checkout";
|
||||
import "./misc/open-external";
|
||||
import "./misc/show-open-dialog";
|
||||
import "./misc/get-features";
|
||||
import "./misc/show-item-in-folder";
|
||||
import "./misc/get-badges";
|
||||
import "./torrenting/cancel-game-download";
|
||||
import "./torrenting/pause-game-download";
|
||||
import "./torrenting/resume-game-download";
|
||||
@@ -58,6 +60,7 @@ import "./user/get-blocked-users";
|
||||
import "./user/block-user";
|
||||
import "./user/unblock-user";
|
||||
import "./user/get-user-friends";
|
||||
import "./user/get-auth";
|
||||
import "./user/get-user-stats";
|
||||
import "./user/report-user";
|
||||
import "./user/get-unlocked-achievements";
|
||||
@@ -77,6 +80,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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
23
src/main/events/library/toggle-automatic-cloud-sync.ts
Normal file
23
src/main/events/library/toggle-automatic-cloud-sync.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { levelKeys, gamesSublevel } from "@main/level";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
const toggleAutomaticCloudSync = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
automaticCloudSync: boolean
|
||||
) => {
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
|
||||
const game = await gamesSublevel.get(gameKey);
|
||||
|
||||
if (!game) return;
|
||||
|
||||
await gamesSublevel.put(gameKey, {
|
||||
...game,
|
||||
automaticCloudSync,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("toggleAutomaticCloudSync", toggleAutomaticCloudSync);
|
||||
22
src/main/events/misc/get-badges.ts
Normal file
22
src/main/events/misc/get-badges.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Badge } from "@types";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { db, levelKeys } from "@main/level";
|
||||
|
||||
const getBadges = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
const language = await db
|
||||
.get<string, string>(levelKeys.language, {
|
||||
valueEncoding: "utf-8",
|
||||
})
|
||||
.then((language) => language || "en");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
locale: language,
|
||||
});
|
||||
|
||||
return HydraApi.get<Badge[]>(`/badges?${params.toString()}`, null, {
|
||||
needsAuth: false,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("getBadges", getBadges);
|
||||
@@ -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({
|
||||
|
||||
12
src/main/events/themes/add-custom-theme.ts
Normal file
12
src/main/events/themes/add-custom-theme.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Theme } from "@types";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { themesSublevel } from "@main/level";
|
||||
|
||||
const addCustomTheme = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
theme: Theme
|
||||
) => {
|
||||
await themesSublevel.put(theme.id, theme);
|
||||
};
|
||||
|
||||
registerEvent("addCustomTheme", addCustomTheme);
|
||||
11
src/main/events/themes/close-editor-window.ts
Normal file
11
src/main/events/themes/close-editor-window.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { WindowManager } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const closeEditorWindow = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
themeId?: string
|
||||
) => {
|
||||
WindowManager.closeEditorWindow(themeId);
|
||||
};
|
||||
|
||||
registerEvent("closeEditorWindow", closeEditorWindow);
|
||||
8
src/main/events/themes/delete-all-custom-themes.ts
Normal file
8
src/main/events/themes/delete-all-custom-themes.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { themesSublevel } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const deleteAllCustomThemes = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
await themesSublevel.clear();
|
||||
};
|
||||
|
||||
registerEvent("deleteAllCustomThemes", deleteAllCustomThemes);
|
||||
11
src/main/events/themes/delete-custom-theme.ts
Normal file
11
src/main/events/themes/delete-custom-theme.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { themesSublevel } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const deleteCustomTheme = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
themeId: string
|
||||
) => {
|
||||
await themesSublevel.del(themeId);
|
||||
};
|
||||
|
||||
registerEvent("deleteCustomTheme", deleteCustomTheme);
|
||||
9
src/main/events/themes/get-active-custom-theme.ts
Normal file
9
src/main/events/themes/get-active-custom-theme.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { themesSublevel } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getActiveCustomTheme = async () => {
|
||||
const allThemes = await themesSublevel.values().all();
|
||||
return allThemes.find((theme) => theme.isActive);
|
||||
};
|
||||
|
||||
registerEvent("getActiveCustomTheme", getActiveCustomTheme);
|
||||
8
src/main/events/themes/get-all-custom-themes.ts
Normal file
8
src/main/events/themes/get-all-custom-themes.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { themesSublevel } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getAllCustomThemes = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
return themesSublevel.values().all();
|
||||
};
|
||||
|
||||
registerEvent("getAllCustomThemes", getAllCustomThemes);
|
||||
11
src/main/events/themes/get-custom-theme-by-id.ts
Normal file
11
src/main/events/themes/get-custom-theme-by-id.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { themesSublevel } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getCustomThemeById = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
themeId: string
|
||||
) => {
|
||||
return themesSublevel.get(themeId);
|
||||
};
|
||||
|
||||
registerEvent("getCustomThemeById", getCustomThemeById);
|
||||
11
src/main/events/themes/open-editor-window.ts
Normal file
11
src/main/events/themes/open-editor-window.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { WindowManager } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const openEditorWindow = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
themeId: string
|
||||
) => {
|
||||
WindowManager.openEditorWindow(themeId);
|
||||
};
|
||||
|
||||
registerEvent("openEditorWindow", openEditorWindow);
|
||||
22
src/main/events/themes/toggle-custom-theme.ts
Normal file
22
src/main/events/themes/toggle-custom-theme.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { themesSublevel } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const toggleCustomTheme = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
themeId: string,
|
||||
isActive: boolean
|
||||
) => {
|
||||
const theme = await themesSublevel.get(themeId);
|
||||
|
||||
if (!theme) {
|
||||
throw new Error("Theme not found");
|
||||
}
|
||||
|
||||
await themesSublevel.put(themeId, {
|
||||
...theme,
|
||||
isActive,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("toggleCustomTheme", toggleCustomTheme);
|
||||
27
src/main/events/themes/update-custom-theme.ts
Normal file
27
src/main/events/themes/update-custom-theme.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { themesSublevel } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { WindowManager } from "@main/services";
|
||||
|
||||
const updateCustomTheme = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
themeId: string,
|
||||
code: string
|
||||
) => {
|
||||
const theme = await themesSublevel.get(themeId);
|
||||
|
||||
if (!theme) {
|
||||
throw new Error("Theme not found");
|
||||
}
|
||||
|
||||
await themesSublevel.put(themeId, {
|
||||
...theme,
|
||||
code,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
if (theme.isActive) {
|
||||
WindowManager.mainWindow?.webContents.send("css-injected", code);
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("updateCustomTheme", updateCustomTheme);
|
||||
@@ -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);
|
||||
|
||||
@@ -15,6 +15,7 @@ const pauseGameSeed = async (
|
||||
|
||||
await downloadsSublevel.put(downloadKey, {
|
||||
...download,
|
||||
status: "complete",
|
||||
shouldSeed: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,27 +1,10 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { db, levelKeys } from "@main/level";
|
||||
import { Crypto } from "@main/services";
|
||||
import type { UserPreferences } from "@types";
|
||||
|
||||
const getUserPreferences = async () =>
|
||||
db
|
||||
.get<string, UserPreferences | null>(levelKeys.userPreferences, {
|
||||
valueEncoding: "json",
|
||||
})
|
||||
.then((userPreferences) => {
|
||||
if (userPreferences?.realDebridApiToken) {
|
||||
userPreferences.realDebridApiToken = Crypto.decrypt(
|
||||
userPreferences.realDebridApiToken
|
||||
);
|
||||
}
|
||||
|
||||
if (userPreferences?.torBoxApiToken) {
|
||||
userPreferences.torBoxApiToken = Crypto.decrypt(
|
||||
userPreferences.torBoxApiToken
|
||||
);
|
||||
}
|
||||
|
||||
return userPreferences;
|
||||
});
|
||||
db.get<string, UserPreferences | null>(levelKeys.userPreferences, {
|
||||
valueEncoding: "json",
|
||||
});
|
||||
|
||||
registerEvent("getUserPreferences", getUserPreferences);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
11
src/main/events/user/get-auth.ts
Normal file
11
src/main/events/user/get-auth.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { db, levelKeys } from "@main/level";
|
||||
import type { Auth } from "@types";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getAuth = async (_event: Electron.IpcMainInvokeEvent) =>
|
||||
db.get<string, Auth>(levelKeys.auth, {
|
||||
valueEncoding: "json",
|
||||
});
|
||||
|
||||
registerEvent("getAuth", getAuth);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import updater from "electron-updater";
|
||||
import i18n from "i18next";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
import kill from "kill-port";
|
||||
import { electronApp, optimizer } from "@electron-toolkit/utils";
|
||||
import { logger, WindowManager } from "@main/services";
|
||||
import resources from "@locales";
|
||||
@@ -58,7 +59,7 @@ app.whenReady().then(async () => {
|
||||
return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString());
|
||||
});
|
||||
|
||||
await loadState();
|
||||
await kill(PythonRPC.RPC_PORT).finally(() => loadState());
|
||||
|
||||
const language = await db.get<string, string>(levelKeys.language, {
|
||||
valueEncoding: "utf-8",
|
||||
@@ -85,6 +86,29 @@ const handleDeepLinkPath = (uri?: string) => {
|
||||
|
||||
if (url.host === "install-source") {
|
||||
WindowManager.redirect(`settings${url.search}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.host === "profile") {
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (userId) {
|
||||
WindowManager.redirect(`profile/${userId}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.host === "install-theme") {
|
||||
const themeName = url.searchParams.get("theme");
|
||||
const authorId = url.searchParams.get("authorId");
|
||||
const authorName = url.searchParams.get("authorName");
|
||||
|
||||
if (themeName && authorId && authorName) {
|
||||
WindowManager.redirect(
|
||||
`settings?theme=${themeName}&authorId=${authorId}&authorName=${authorName}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error handling deep link", uri, error);
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from "./games";
|
||||
export * from "./game-shop-cache";
|
||||
export * from "./game-achievements";
|
||||
export * from "./keys";
|
||||
export * from "./themes";
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
7
src/main/level/sublevels/themes.ts
Normal file
7
src/main/level/sublevels/themes.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Theme } from "@types";
|
||||
import { db } from "../level";
|
||||
import { levelKeys } from "./keys";
|
||||
|
||||
export const themesSublevel = db.sublevel<string, Theme>(levelKeys.themes, {
|
||||
valueEncoding: "json",
|
||||
});
|
||||
@@ -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,21 +49,17 @@ 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
|
||||
);
|
||||
|
||||
await DownloadManager.startRPC(nextItemOnQueue, downloadsToSeed);
|
||||
@@ -123,9 +111,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,
|
||||
@@ -144,7 +130,9 @@ const migrateFromSqlite = async () => {
|
||||
);
|
||||
|
||||
if (rest.language) {
|
||||
await db.put(levelKeys.language, rest.language);
|
||||
await db.put<string, string>(levelKeys.language, rest.language, {
|
||||
valueEncoding: "utf-8",
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -191,8 +179,8 @@ const migrateFromSqlite = async () => {
|
||||
await db.put<string, Auth>(
|
||||
levelKeys.auth,
|
||||
{
|
||||
accessToken: Crypto.encrypt(users[0].accessToken),
|
||||
refreshToken: Crypto.encrypt(users[0].refreshToken),
|
||||
accessToken: users[0].accessToken,
|
||||
refreshToken: users[0].refreshToken,
|
||||
tokenExpirationTimestamp: users[0].tokenExpirationTimestamp,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
112
src/main/services/cloud-sync.ts
Normal file
112
src/main/services/cloud-sync.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { levelKeys, gamesSublevel, db } from "@main/level";
|
||||
import { app } from "electron";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import type { GameShop, User } from "@types";
|
||||
import { backupsPath } from "@main/constants";
|
||||
import { HydraApi } from "./hydra-api";
|
||||
import { normalizePath } from "@main/helpers";
|
||||
import { logger } from "./logger";
|
||||
import { WindowManager } from "./window-manager";
|
||||
import axios from "axios";
|
||||
import { Ludusavi } from "./ludusavi";
|
||||
import { isFuture, isToday } from "date-fns";
|
||||
import { SubscriptionRequiredError } from "@shared";
|
||||
|
||||
export class CloudSync {
|
||||
private static async bundleBackup(
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
winePrefix: string | null
|
||||
) {
|
||||
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
|
||||
|
||||
// Remove existing backup
|
||||
if (fs.existsSync(backupPath)) {
|
||||
fs.rmSync(backupPath, { recursive: true });
|
||||
}
|
||||
|
||||
await Ludusavi.backupGame(shop, objectId, backupPath, winePrefix);
|
||||
|
||||
const tarLocation = path.join(backupsPath, `${crypto.randomUUID()}.tar`);
|
||||
|
||||
await tar.create(
|
||||
{
|
||||
gzip: false,
|
||||
file: tarLocation,
|
||||
cwd: backupPath,
|
||||
},
|
||||
["."]
|
||||
);
|
||||
|
||||
return tarLocation;
|
||||
}
|
||||
|
||||
public static async uploadSaveGame(
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
downloadOptionTitle: string | null,
|
||||
label?: string
|
||||
) {
|
||||
const hasActiveSubscription = await db
|
||||
.get<string, User>(levelKeys.user, { valueEncoding: "json" })
|
||||
.then((user) => {
|
||||
const expiresAt = user?.subscription?.expiresAt;
|
||||
return expiresAt && (isFuture(expiresAt) || isToday(expiresAt));
|
||||
});
|
||||
|
||||
if (!hasActiveSubscription) {
|
||||
throw new SubscriptionRequiredError();
|
||||
}
|
||||
|
||||
const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
|
||||
|
||||
const bundleLocation = await this.bundleBackup(
|
||||
shop,
|
||||
objectId,
|
||||
game?.winePrefixPath ?? null
|
||||
);
|
||||
|
||||
const stat = await fs.promises.stat(bundleLocation);
|
||||
|
||||
const { uploadUrl } = await HydraApi.post<{
|
||||
id: string;
|
||||
uploadUrl: string;
|
||||
}>("/profile/games/artifacts", {
|
||||
artifactLengthInBytes: stat.size,
|
||||
shop,
|
||||
objectId,
|
||||
hostname: os.hostname(),
|
||||
homeDir: normalizePath(app.getPath("home")),
|
||||
downloadOptionTitle,
|
||||
platform: os.platform(),
|
||||
label,
|
||||
});
|
||||
|
||||
const fileBuffer = await fs.promises.readFile(bundleLocation);
|
||||
|
||||
await axios.put(uploadUrl, fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "application/tar",
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
logger.log(progressEvent);
|
||||
},
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
`on-upload-complete-${objectId}-${shop}`,
|
||||
true
|
||||
);
|
||||
|
||||
fs.rm(bundleLocation, (err) => {
|
||||
if (err) {
|
||||
logger.error("Failed to remove tar file", err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,13 @@ import { Downloader, DownloadError } from "@shared";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import { publishDownloadCompleteNotification } from "../notifications";
|
||||
import type { Download, DownloadProgress, UserPreferences } from "@types";
|
||||
import { GofileApi, QiwiApi, DatanodesApi, MediafireApi } from "../hosters";
|
||||
import {
|
||||
GofileApi,
|
||||
QiwiApi,
|
||||
DatanodesApi,
|
||||
MediafireApi,
|
||||
PixelDrainApi,
|
||||
} from "../hosters";
|
||||
import { PythonRPC } from "../python-rpc";
|
||||
import {
|
||||
LibtorrentPayload,
|
||||
@@ -219,8 +225,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 +236,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;
|
||||
}
|
||||
@@ -278,11 +289,12 @@ export class DownloadManager {
|
||||
}
|
||||
case Downloader.PixelDrain: {
|
||||
const id = download.uri.split("/").pop();
|
||||
const downloadUrl = await PixelDrainApi.getDownloadUrl(id!);
|
||||
|
||||
return {
|
||||
action: "start",
|
||||
game_id: downloadId,
|
||||
url: `https://cdn.pd5-gamedriveorg.workers.dev/api/file/${id}`,
|
||||
url: downloadUrl,
|
||||
save_path: download.downloadPath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
TorBoxAddTorrentRequest,
|
||||
TorBoxRequestLinkRequest,
|
||||
} from "@types";
|
||||
import { appVersion } from "@main/constants";
|
||||
|
||||
export class TorBoxClient {
|
||||
private static instance: AxiosInstance;
|
||||
@@ -18,6 +19,7 @@ export class TorBoxClient {
|
||||
baseURL: this.baseURL,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
"User-Agent": `Hydra/${appVersion}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,47 +1,71 @@
|
||||
import axios, { AxiosResponse } from "axios";
|
||||
import { wrapper } from "axios-cookiejar-support";
|
||||
import { CookieJar } from "tough-cookie";
|
||||
|
||||
export class DatanodesApi {
|
||||
private static readonly session = axios.create({});
|
||||
private static readonly jar = new CookieJar();
|
||||
|
||||
private static readonly session = wrapper(
|
||||
axios.create({
|
||||
jar: DatanodesApi.jar,
|
||||
withCredentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
public static async getDownloadUrl(downloadUrl: string): Promise<string> {
|
||||
const parsedUrl = new URL(downloadUrl);
|
||||
const pathSegments = parsedUrl.pathname.split("/");
|
||||
try {
|
||||
const parsedUrl = new URL(downloadUrl);
|
||||
const pathSegments = parsedUrl.pathname.split("/").filter(Boolean);
|
||||
const fileCode = pathSegments[0];
|
||||
|
||||
const fileCode = decodeURIComponent(pathSegments[1]);
|
||||
const fileName = decodeURIComponent(pathSegments[pathSegments.length - 1]);
|
||||
await this.jar.setCookie("lang=english;", "https://datanodes.to");
|
||||
|
||||
const payload = new URLSearchParams({
|
||||
op: "download2",
|
||||
id: fileCode,
|
||||
rand: "",
|
||||
referer: "https://datanodes.to/download",
|
||||
method_free: "Free Download >>",
|
||||
method_premium: "",
|
||||
adblock_detected: "",
|
||||
});
|
||||
const payload = new URLSearchParams({
|
||||
op: "download2",
|
||||
id: fileCode,
|
||||
method_free: "Free Download >>",
|
||||
dl: "1",
|
||||
});
|
||||
|
||||
const response: AxiosResponse = await this.session.post(
|
||||
"https://datanodes.to/download",
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Cookie: `lang=english; file_name=${fileName}; file_code=${fileCode};`,
|
||||
Host: "datanodes.to",
|
||||
Origin: "https://datanodes.to",
|
||||
Referer: "https://datanodes.to/download",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
||||
},
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status: number) => status === 302 || status < 400,
|
||||
const response: AxiosResponse = await this.session.post(
|
||||
"https://datanodes.to/download",
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0",
|
||||
Referer: "https://datanodes.to/download",
|
||||
Origin: "https://datanodes.to",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status: number) => status === 302 || status < 400,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 302) {
|
||||
return response.headers["location"];
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 302) {
|
||||
return response.headers["location"];
|
||||
if (typeof response.data === "object" && response.data.url) {
|
||||
return decodeURIComponent(response.data.url);
|
||||
}
|
||||
|
||||
const htmlContent = String(response.data);
|
||||
if (!htmlContent) {
|
||||
throw new Error("Empty response received");
|
||||
}
|
||||
|
||||
const downloadLinkRegex = /href=["'](https:\/\/[^"']+)["']/;
|
||||
const downloadLinkMatch = downloadLinkRegex.exec(htmlContent);
|
||||
if (downloadLinkMatch) {
|
||||
return downloadLinkMatch[1];
|
||||
}
|
||||
|
||||
throw new Error("Failed to get the download link");
|
||||
} catch (error) {
|
||||
console.error("Error fetching download URL:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./gofile";
|
||||
export * from "./qiwi";
|
||||
export * from "./datanodes";
|
||||
export * from "./mediafire";
|
||||
export * from "./pixeldrain";
|
||||
|
||||
42
src/main/services/hosters/pixeldrain.ts
Normal file
42
src/main/services/hosters/pixeldrain.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import axios from "axios";
|
||||
|
||||
export class PixelDrainApi {
|
||||
private static readonly browserHeaders = {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
||||
Accept:
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
DNT: "1",
|
||||
Connection: "keep-alive",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
};
|
||||
|
||||
public static async getDownloadUrl(fileId: string): Promise<string> {
|
||||
try {
|
||||
const response = await axios.get(`https://pd.cybar.xyz/${fileId}`, {
|
||||
headers: this.browserHeaders,
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status) =>
|
||||
status === 301 || status === 302 || status === 200,
|
||||
});
|
||||
|
||||
if (
|
||||
response.headers.location ||
|
||||
response.status === 301 ||
|
||||
response.status === 302
|
||||
) {
|
||||
return response.headers.location;
|
||||
}
|
||||
|
||||
throw new Error(`No redirect URL found (status: ${response.status})`);
|
||||
} catch (error) {
|
||||
console.error("Error fetching PixelDrain URL:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import { isFuture, isToday } from "date-fns";
|
||||
import { db } from "@main/level";
|
||||
import { levelKeys } from "@main/level/sublevels";
|
||||
import type { Auth, User } from "@types";
|
||||
import { Crypto } from "./crypto";
|
||||
|
||||
interface HydraApiOptions {
|
||||
needsAuth?: boolean;
|
||||
@@ -32,8 +31,9 @@ export class HydraApi {
|
||||
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
|
||||
private static readonly ADD_LOG_INTERCEPTOR = true;
|
||||
|
||||
private static readonly secondsToMilliseconds = (seconds: number) =>
|
||||
seconds * 1000;
|
||||
private static secondsToMilliseconds(seconds: number) {
|
||||
return seconds * 1000;
|
||||
}
|
||||
|
||||
private static userAuth: HydraApiUserAuth = {
|
||||
authToken: "",
|
||||
@@ -81,8 +81,8 @@ export class HydraApi {
|
||||
db.put<string, Auth>(
|
||||
levelKeys.auth,
|
||||
{
|
||||
accessToken: Crypto.encrypt(accessToken),
|
||||
refreshToken: Crypto.encrypt(refreshToken),
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenExpirationTimestamp,
|
||||
},
|
||||
{ valueEncoding: "json" }
|
||||
@@ -204,12 +204,8 @@ export class HydraApi {
|
||||
const user = result.at(1) as User | undefined;
|
||||
|
||||
this.userAuth = {
|
||||
authToken: userAuth?.accessToken
|
||||
? Crypto.decrypt(userAuth.accessToken)
|
||||
: "",
|
||||
refreshToken: userAuth?.refreshToken
|
||||
? Crypto.decrypt(userAuth.refreshToken)
|
||||
: "",
|
||||
authToken: userAuth?.accessToken ?? "",
|
||||
refreshToken: userAuth?.refreshToken ?? "",
|
||||
expirationTimestamp: userAuth?.tokenExpirationTimestamp ?? 0,
|
||||
subscription: user?.subscription
|
||||
? { expiresAt: user.subscription?.expiresAt }
|
||||
@@ -258,7 +254,7 @@ export class HydraApi {
|
||||
levelKeys.auth,
|
||||
{
|
||||
...auth,
|
||||
accessToken: Crypto.encrypt(accessToken),
|
||||
accessToken,
|
||||
tokenExpirationTimestamp,
|
||||
},
|
||||
{ valueEncoding: "json" }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./crypto";
|
||||
export * from "./logger";
|
||||
export * from "./steam";
|
||||
export * from "./steam-250";
|
||||
@@ -8,3 +7,4 @@ export * from "./process-watcher";
|
||||
export * from "./main-loop";
|
||||
export * from "./hydra-api";
|
||||
export * from "./ludusavi";
|
||||
export * from "./cloud-sync";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,9 @@ import axios from "axios";
|
||||
import { exec } from "child_process";
|
||||
import { ProcessPayload } from "./download/types";
|
||||
import { gamesSublevel, levelKeys } from "@main/level";
|
||||
import { t } from "i18next";
|
||||
import { CloudSync } from "./cloud-sync";
|
||||
import { format } from "date-fns";
|
||||
|
||||
const commands = {
|
||||
findWineDir: `lsof -c wine 2>/dev/null | grep '/drive_c/windows$' | head -n 1 | awk '{for(i=9;i<=NF;i++) printf "%s ", $i; print ""}'`,
|
||||
@@ -225,6 +228,18 @@ function onOpenGame(game: Game) {
|
||||
|
||||
if (game.remoteId) {
|
||||
updateGamePlaytime(game, 0, new Date()).catch(() => {});
|
||||
|
||||
if (game.automaticCloudSync) {
|
||||
CloudSync.uploadSaveGame(
|
||||
game.objectId,
|
||||
game.shop,
|
||||
null,
|
||||
t("automatic_backup_from", {
|
||||
ns: "game_details",
|
||||
date: format(new Date(), "dd/MM/yyyy"),
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
createGame({ ...game, lastTimePlayed: new Date() }).catch(() => {});
|
||||
}
|
||||
@@ -287,6 +302,18 @@ const onCloseGame = (game: Game) => {
|
||||
performance.now() - gamePlaytime.lastSyncTick,
|
||||
game.lastTimePlayed!
|
||||
).catch(() => {});
|
||||
|
||||
if (game.automaticCloudSync) {
|
||||
CloudSync.uploadSaveGame(
|
||||
game.objectId,
|
||||
game.shop,
|
||||
null,
|
||||
t("automatic_backup_from", {
|
||||
ns: "game_details",
|
||||
date: format(new Date(), "dd/MM/yyyy"),
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
createGame(game).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import { isStaging } from "@main/constants";
|
||||
export class WindowManager {
|
||||
public static mainWindow: Electron.BrowserWindow | null = null;
|
||||
|
||||
private static readonly editorWindows: Map<string, BrowserWindow> = new Map();
|
||||
|
||||
private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions =
|
||||
{
|
||||
width: 1200,
|
||||
@@ -36,7 +38,7 @@ export class WindowManager {
|
||||
trafficLightPosition: { x: 16, y: 16 },
|
||||
titleBarOverlay: {
|
||||
symbolColor: "#DADBE1",
|
||||
color: "#151515",
|
||||
color: "#00000000",
|
||||
height: 34,
|
||||
},
|
||||
webPreferences: {
|
||||
@@ -46,6 +48,23 @@ export class WindowManager {
|
||||
show: false,
|
||||
};
|
||||
|
||||
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.
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
this.mainWindow?.loadURL(
|
||||
`${process.env["ELECTRON_RENDERER_URL"]}#/${hash}`
|
||||
);
|
||||
} else {
|
||||
this.mainWindow?.loadFile(
|
||||
path.join(__dirname, "../renderer/index.html"),
|
||||
{
|
||||
hash,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static async saveScreenConfig({
|
||||
...configScreenWhenClosed
|
||||
}: {
|
||||
@@ -66,6 +85,7 @@ export class WindowManager {
|
||||
});
|
||||
return data ?? {};
|
||||
}
|
||||
|
||||
private static updateInitialConfig(
|
||||
newConfig: Partial<Electron.BrowserWindowConstructorOptions>
|
||||
) {
|
||||
@@ -75,23 +95,6 @@ export class WindowManager {
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
this.mainWindow?.loadURL(
|
||||
`${process.env["ELECTRON_RENDERER_URL"]}#/${hash}`
|
||||
);
|
||||
} else {
|
||||
this.mainWindow?.loadFile(
|
||||
path.join(__dirname, "../renderer/index.html"),
|
||||
{
|
||||
hash,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static async createMainWindow() {
|
||||
if (this.mainWindow) return;
|
||||
|
||||
@@ -261,6 +264,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);
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
CatalogueSearchPayload,
|
||||
SeedingStatus,
|
||||
GameAchievement,
|
||||
Theme,
|
||||
} from "@types";
|
||||
import type { AuthPage, CatalogueCategory } from "@shared";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
@@ -100,6 +101,17 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("putDownloadSource", objectIds),
|
||||
|
||||
/* Library */
|
||||
toggleAutomaticCloudSync: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
automaticCloudSync: boolean
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
"toggleAutomaticCloudSync",
|
||||
shop,
|
||||
objectId,
|
||||
automaticCloudSync
|
||||
),
|
||||
addGameToLibrary: (shop: GameShop, objectId: string, title: string) =>
|
||||
ipcRenderer.invoke("addGameToLibrary", shop, objectId, title),
|
||||
createGameShortcut: (shop: GameShop, objectId: string) =>
|
||||
@@ -265,6 +277,7 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
showItemInFolder: (path: string) =>
|
||||
ipcRenderer.invoke("showItemInFolder", path),
|
||||
getFeatures: () => ipcRenderer.invoke("getFeatures"),
|
||||
getBadges: () => ipcRenderer.invoke("getBadges"),
|
||||
platform: process.platform,
|
||||
|
||||
/* Auto update */
|
||||
@@ -324,6 +337,7 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("getUnlockedAchievements", objectId, shop),
|
||||
|
||||
/* Auth */
|
||||
getAuth: () => ipcRenderer.invoke("getAuth"),
|
||||
signOut: () => ipcRenderer.invoke("signOut"),
|
||||
openAuthWindow: (page: AuthPage) =>
|
||||
ipcRenderer.invoke("openAuthWindow", page),
|
||||
@@ -347,4 +361,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),
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
cursor: pointer;
|
||||
color: globals.$muted-color;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&__image {
|
||||
height: 100%;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PersonIcon } from "@primer/octicons-react";
|
||||
import cn from "classnames";
|
||||
|
||||
import "./avatar.scss";
|
||||
|
||||
@@ -14,11 +15,18 @@ export interface AvatarProps
|
||||
src?: string | null;
|
||||
}
|
||||
|
||||
export function Avatar({ size, alt, src, ...props }: AvatarProps) {
|
||||
export function Avatar({ size, alt, src, className, ...props }: AvatarProps) {
|
||||
return (
|
||||
<div className="profile-avatar" style={{ width: size, height: size }}>
|
||||
{src ? (
|
||||
<img className="profile-avatar__image" alt={alt} src={src} {...props} />
|
||||
<img
|
||||
className={cn("profile-avatar__image", className)}
|
||||
alt={alt}
|
||||
src={src}
|
||||
width={size}
|
||||
height={size}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon size={size * 0.7} />
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,10 @@ export interface BackdropProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Backdrop({ isClosing = false, children }: BackdropProps) {
|
||||
export function Backdrop({
|
||||
isClosing = false,
|
||||
children,
|
||||
}: Readonly<BackdropProps>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("backdrop", {
|
||||
|
||||
@@ -15,7 +15,7 @@ export function Button({
|
||||
theme = "primary",
|
||||
className,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
}: Readonly<ButtonProps>) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface CheckboxFieldProps
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
> {
|
||||
label: string;
|
||||
label: string | React.ReactNode;
|
||||
}
|
||||
|
||||
export function CheckboxField({ label, ...props }: CheckboxFieldProps) {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
width: 100%;
|
||||
@@ -54,6 +55,7 @@
|
||||
display: flex;
|
||||
color: globals.$muted-color;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
@@ -104,13 +106,14 @@
|
||||
&__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__section {
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
padding-bottom: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,10 @@ export function Sidebar() {
|
||||
}
|
||||
};
|
||||
|
||||
const favoriteGames = useMemo(() => {
|
||||
return sortedLibrary.filter((game) => game.favorite);
|
||||
}, [sortedLibrary]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
@@ -206,13 +210,12 @@ export function Sidebar() {
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="sidebar__section">
|
||||
<small className="sidebar__section-title">{t("favorites")}</small>
|
||||
{favoriteGames.length > 0 && (
|
||||
<section className="sidebar__section">
|
||||
<small className="sidebar__section-title">{t("favorites")}</small>
|
||||
|
||||
<ul className="sidebar__menu">
|
||||
{sortedLibrary
|
||||
.filter((game) => game.favorite)
|
||||
.map((game) => (
|
||||
<ul className="sidebar__menu">
|
||||
{favoriteGames.map((game) => (
|
||||
<SidebarGameItem
|
||||
key={game.id}
|
||||
game={game}
|
||||
@@ -220,8 +223,9 @@ export function Sidebar() {
|
||||
getGameTitle={getGameTitle}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="sidebar__section">
|
||||
<small className="sidebar__section-title">{t("my_library")}</small>
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
background-color: globals.$dark-background-color;
|
||||
border-radius: 4px;
|
||||
border: solid 1px globals.$border-color;
|
||||
right: 16px;
|
||||
bottom: 26px + globals.$spacing-unit;
|
||||
right: calc(globals.$spacing-unit * 2);
|
||||
// 28px is the height of the bottom panel
|
||||
bottom: calc(28px + globals.$spacing-unit * 2);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Downloader } from "@shared";
|
||||
|
||||
export const VERSION_CODENAME = "Spectre";
|
||||
export const VERSION_CODENAME = "Polychrome";
|
||||
|
||||
export const DOWNLOADER_NAME = {
|
||||
[Downloader.RealDebrid]: "Real-Debrid",
|
||||
@@ -14,3 +14,5 @@ export const DOWNLOADER_NAME = {
|
||||
};
|
||||
|
||||
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||
|
||||
export const THEME_WEB_STORE_URL = "https://hydrathemes.shop";
|
||||
|
||||
@@ -9,20 +9,32 @@ export interface SettingsContext {
|
||||
updateUserPreferences: (values: Partial<UserPreferences>) => Promise<void>;
|
||||
setCurrentCategoryIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
clearSourceUrl: () => void;
|
||||
clearTheme: () => void;
|
||||
sourceUrl: string | null;
|
||||
currentCategoryIndex: number;
|
||||
blockedUsers: UserBlocks["blocks"];
|
||||
fetchBlockedUsers: () => Promise<void>;
|
||||
appearance: {
|
||||
theme: string | null;
|
||||
authorId: string | null;
|
||||
authorName: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const settingsContext = createContext<SettingsContext>({
|
||||
updateUserPreferences: async () => {},
|
||||
setCurrentCategoryIndex: () => {},
|
||||
clearSourceUrl: () => {},
|
||||
clearTheme: () => {},
|
||||
sourceUrl: null,
|
||||
currentCategoryIndex: 0,
|
||||
blockedUsers: [],
|
||||
fetchBlockedUsers: async () => {},
|
||||
appearance: {
|
||||
theme: null,
|
||||
authorId: null,
|
||||
authorName: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { Provider } = settingsContext;
|
||||
@@ -34,15 +46,26 @@ export interface SettingsContextProviderProps {
|
||||
|
||||
export function SettingsContextProvider({
|
||||
children,
|
||||
}: SettingsContextProviderProps) {
|
||||
}: Readonly<SettingsContextProviderProps>) {
|
||||
const dispatch = useAppDispatch();
|
||||
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
|
||||
const [appearance, setAppearance] = useState<{
|
||||
theme: string | null;
|
||||
authorId: string | null;
|
||||
authorName: string | null;
|
||||
}>({
|
||||
theme: null,
|
||||
authorId: null,
|
||||
authorName: null,
|
||||
});
|
||||
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
|
||||
|
||||
const [blockedUsers, setBlockedUsers] = useState<UserBlocks["blocks"]>([]);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const defaultSourceUrl = searchParams.get("urls");
|
||||
const defaultAppearanceTheme = searchParams.get("theme");
|
||||
const defaultAppearanceAuthorId = searchParams.get("authorId");
|
||||
const defaultAppearanceAuthorName = searchParams.get("authorName");
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceUrl) setCurrentCategoryIndex(2);
|
||||
@@ -54,6 +77,36 @@ export function SettingsContextProvider({
|
||||
}
|
||||
}, [defaultSourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (appearance.theme) setCurrentCategoryIndex(3);
|
||||
}, [appearance.theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
defaultAppearanceTheme &&
|
||||
defaultAppearanceAuthorId &&
|
||||
defaultAppearanceAuthorName
|
||||
) {
|
||||
setAppearance({
|
||||
theme: defaultAppearanceTheme,
|
||||
authorId: defaultAppearanceAuthorId,
|
||||
authorName: defaultAppearanceAuthorName,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
defaultAppearanceTheme,
|
||||
defaultAppearanceAuthorId,
|
||||
defaultAppearanceAuthorName,
|
||||
]);
|
||||
|
||||
const clearTheme = useCallback(() => {
|
||||
setAppearance({
|
||||
theme: null,
|
||||
authorId: null,
|
||||
authorName: null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchBlockedUsers = useCallback(async () => {
|
||||
const blockedUsers = await window.electron.getBlockedUsers(12, 0);
|
||||
setBlockedUsers(blockedUsers.blocks);
|
||||
@@ -79,9 +132,11 @@ export function SettingsContextProvider({
|
||||
setCurrentCategoryIndex,
|
||||
clearSourceUrl,
|
||||
fetchBlockedUsers,
|
||||
clearTheme,
|
||||
currentCategoryIndex,
|
||||
sourceUrl,
|
||||
blockedUsers,
|
||||
appearance,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { darkenColor } from "@renderer/helpers";
|
||||
import { useAppSelector, useToast } from "@renderer/hooks";
|
||||
import type { UserProfile, UserStats } from "@types";
|
||||
import type { Badge, UserProfile, UserStats } from "@types";
|
||||
import { average } from "color.js";
|
||||
|
||||
import { createContext, useCallback, useEffect, useState } from "react";
|
||||
@@ -16,6 +16,7 @@ export interface UserProfileContext {
|
||||
getUserProfile: () => Promise<void>;
|
||||
setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
|
||||
backgroundImage: string;
|
||||
badges: Badge[];
|
||||
}
|
||||
|
||||
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
|
||||
@@ -28,6 +29,7 @@ export const userProfileContext = createContext<UserProfileContext>({
|
||||
getUserProfile: async () => {},
|
||||
setSelectedBackgroundImage: () => {},
|
||||
backgroundImage: "",
|
||||
badges: [],
|
||||
});
|
||||
|
||||
const { Provider } = userProfileContext;
|
||||
@@ -41,12 +43,13 @@ export interface UserProfileContextProviderProps {
|
||||
export function UserProfileContextProvider({
|
||||
children,
|
||||
userId,
|
||||
}: UserProfileContextProviderProps) {
|
||||
}: Readonly<UserProfileContextProviderProps>) {
|
||||
const { userDetails } = useAppSelector((state) => state.userDetails);
|
||||
|
||||
const [userStats, setUserStats] = useState<UserStats | null>(null);
|
||||
|
||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
||||
const [badges, setBadges] = useState<Badge[]>([]);
|
||||
const [heroBackground, setHeroBackground] = useState(
|
||||
DEFAULT_USER_PROFILE_BACKGROUND
|
||||
);
|
||||
@@ -101,12 +104,18 @@ export function UserProfileContextProvider({
|
||||
});
|
||||
}, [navigate, getUserStats, showErrorToast, userId, t]);
|
||||
|
||||
const getBadges = useCallback(async () => {
|
||||
const badges = await window.electron.getBadges();
|
||||
setBadges(badges);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setUserProfile(null);
|
||||
setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND);
|
||||
|
||||
getUserProfile();
|
||||
}, [getUserProfile]);
|
||||
getBadges();
|
||||
}, [getUserProfile, getBadges]);
|
||||
|
||||
return (
|
||||
<Provider
|
||||
@@ -118,6 +127,7 @@ export function UserProfileContextProvider({
|
||||
setSelectedBackgroundImage,
|
||||
backgroundImage: getBackgroundImageUrl(),
|
||||
userStats,
|
||||
badges,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
27
src/renderer/src/declaration.d.ts
vendored
27
src/renderer/src/declaration.d.ts
vendored
@@ -29,6 +29,9 @@ import type {
|
||||
LibraryGame,
|
||||
GameRunning,
|
||||
TorBoxUser,
|
||||
Theme,
|
||||
Badge,
|
||||
Auth,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type disk from "diskusage";
|
||||
@@ -85,6 +88,11 @@ declare global {
|
||||
getDevelopers: () => Promise<string[]>;
|
||||
|
||||
/* Library */
|
||||
toggleAutomaticCloudSync: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
automaticCloudSync: boolean
|
||||
) => Promise<void>;
|
||||
addGameToLibrary: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
@@ -216,6 +224,7 @@ declare global {
|
||||
) => Promise<Electron.OpenDialogReturnValue>;
|
||||
showItemInFolder: (path: string) => Promise<void>;
|
||||
getFeatures: () => Promise<string[]>;
|
||||
getBadges: () => Promise<Badge[]>;
|
||||
platform: NodeJS.Platform;
|
||||
|
||||
/* Auto update */
|
||||
@@ -226,6 +235,7 @@ declare global {
|
||||
restartAndInstallUpdate: () => Promise<void>;
|
||||
|
||||
/* Auth */
|
||||
getAuth: () => Promise<Auth | null>;
|
||||
signOut: () => Promise<void>;
|
||||
openAuthWindow: (page: AuthPage) => Promise<void>;
|
||||
getSessionHash: () => Promise<string | null>;
|
||||
@@ -279,6 +289,23 @@ declare global {
|
||||
|
||||
/* Notifications */
|
||||
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
|
||||
|
||||
/* Themes */
|
||||
addCustomTheme: (theme: Theme) => Promise<void>;
|
||||
getAllCustomThemes: () => Promise<Theme[]>;
|
||||
deleteAllCustomThemes: () => Promise<void>;
|
||||
deleteCustomTheme: (themeId: string) => Promise<void>;
|
||||
updateCustomTheme: (themeId: string, code: string) => Promise<void>;
|
||||
getCustomThemeById: (themeId: string) => Promise<Theme | null>;
|
||||
getActiveCustomTheme: () => Promise<Theme | null>;
|
||||
toggleCustomTheme: (themeId: string, isActive: boolean) => Promise<void>;
|
||||
|
||||
/* Editor */
|
||||
openEditorWindow: (themeId: string) => Promise<void>;
|
||||
onCssInjected: (
|
||||
cb: (cssString: string) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
closeEditorWindow: (themeId?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
@@ -26,7 +26,7 @@ export const toastSlice = createSlice({
|
||||
state.title = action.payload.title;
|
||||
state.message = action.payload.message;
|
||||
state.type = action.payload.type;
|
||||
state.duration = action.payload.duration ?? 5000;
|
||||
state.duration = action.payload.duration ?? 2000;
|
||||
state.visible = true;
|
||||
},
|
||||
closeToast: (state) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
import Color from "color";
|
||||
import { THEME_WEB_STORE_URL } from "./constants";
|
||||
|
||||
export const formatDownloadProgress = (
|
||||
progress?: number,
|
||||
@@ -53,3 +54,36 @@ export const buildGameAchievementPath = (
|
||||
|
||||
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
|
||||
new Color(color).darken(amount).alpha(alpha).toString();
|
||||
|
||||
export const injectCustomCss = (css: string) => {
|
||||
try {
|
||||
const currentCustomCss = document.getElementById("custom-css");
|
||||
if (currentCustomCss) {
|
||||
currentCustomCss.remove();
|
||||
}
|
||||
|
||||
if (css.startsWith(THEME_WEB_STORE_URL)) {
|
||||
const link = document.createElement("link");
|
||||
link.id = "custom-css";
|
||||
link.rel = "stylesheet";
|
||||
link.href = css;
|
||||
document.head.appendChild(link);
|
||||
} else {
|
||||
const style = document.createElement("style");
|
||||
style.id = "custom-css";
|
||||
style.textContent = `
|
||||
${css}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("failed to inject custom css:", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeCustomCss = () => {
|
||||
const currentCustomCss = document.getElementById("custom-css");
|
||||
if (currentCustomCss) {
|
||||
currentCustomCss.remove();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ export function useDownload() {
|
||||
const pauseDownload = async (shop: GameShop, objectId: string) => {
|
||||
await window.electron.pauseGameDownload(shop, objectId);
|
||||
await updateLibrary();
|
||||
dispatch(clearDownload());
|
||||
if (lastPacket?.gameId === `${shop}:${objectId}`) dispatch(clearDownload());
|
||||
};
|
||||
|
||||
const resumeDownload = async (shop: GameShop, objectId: string) => {
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
enum Feature {
|
||||
CheckDownloadWritePermission = "CHECK_DOWNLOAD_WRITE_PERMISSION",
|
||||
Torbox = "TORBOX",
|
||||
}
|
||||
|
||||
export function useFeature() {
|
||||
const [features, setFeatures] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.getFeatures().then((features) => {
|
||||
localStorage.setItem("features", JSON.stringify(features || []));
|
||||
setFeatures(features || []);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isFeatureEnabled = (feature: Feature) => {
|
||||
const features = JSON.parse(localStorage.getItem("features") || "[]");
|
||||
if (!features) {
|
||||
const features = JSON.parse(localStorage.getItem("features") ?? "[]");
|
||||
return features.includes(feature);
|
||||
}
|
||||
|
||||
return features.includes(feature);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import "@fontsource/noto-sans/500.css";
|
||||
import "@fontsource/noto-sans/700.css";
|
||||
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
import "react-tooltip/dist/react-tooltip.css";
|
||||
|
||||
import { App } from "./app";
|
||||
|
||||
@@ -18,23 +19,17 @@ import { store } from "./store";
|
||||
|
||||
import resources from "@locales";
|
||||
|
||||
import { SuspenseWrapper } from "./components";
|
||||
import { logger } from "./logger";
|
||||
import { addCookieInterceptor } from "./cookies";
|
||||
|
||||
const Home = React.lazy(() => import("./pages/home/home"));
|
||||
const GameDetails = React.lazy(
|
||||
() => import("./pages/game-details/game-details")
|
||||
);
|
||||
const Downloads = React.lazy(() => import("./pages/downloads/downloads"));
|
||||
const Settings = React.lazy(() => import("./pages/settings/settings"));
|
||||
const Catalogue = React.lazy(() => import("./pages/catalogue/catalogue"));
|
||||
const Profile = React.lazy(() => import("./pages/profile/profile"));
|
||||
const Achievements = React.lazy(
|
||||
() => import("./pages/achievements/achievements")
|
||||
);
|
||||
|
||||
import * as Sentry from "@sentry/react";
|
||||
import Catalogue from "./pages/catalogue/catalogue";
|
||||
import Home from "./pages/home/home";
|
||||
import Downloads from "./pages/downloads/downloads";
|
||||
import GameDetails from "./pages/game-details/game-details";
|
||||
import Settings from "./pages/settings/settings";
|
||||
import Profile from "./pages/profile/profile";
|
||||
import Achievements from "./pages/achievements/achievements";
|
||||
import ThemeEditor from "./pages/theme-editor/theme-editor";
|
||||
|
||||
Sentry.init({
|
||||
dsn: import.meta.env.RENDERER_VITE_SENTRY_DSN,
|
||||
@@ -79,32 +74,16 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route element={<App />}>
|
||||
<Route path="/" element={<SuspenseWrapper Component={Home} />} />
|
||||
<Route
|
||||
path="/catalogue"
|
||||
element={<SuspenseWrapper Component={Catalogue} />}
|
||||
/>
|
||||
<Route
|
||||
path="/downloads"
|
||||
element={<SuspenseWrapper Component={Downloads} />}
|
||||
/>
|
||||
<Route
|
||||
path="/game/:shop/:objectId"
|
||||
element={<SuspenseWrapper Component={GameDetails} />}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<SuspenseWrapper Component={Settings} />}
|
||||
/>
|
||||
<Route
|
||||
path="/profile/:userId"
|
||||
element={<SuspenseWrapper Component={Profile} />}
|
||||
/>
|
||||
<Route
|
||||
path="/achievements"
|
||||
element={<SuspenseWrapper Component={Achievements} />}
|
||||
/>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/catalogue" element={<Catalogue />} />
|
||||
<Route path="/downloads" element={<Downloads />} />
|
||||
<Route path="/game/:shop/:objectId" element={<GameDetails />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/profile/:userId" element={<Profile />} />
|
||||
<Route path="/achievements" element={<Achievements />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/theme-editor" element={<ThemeEditor />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
|
||||
@@ -31,7 +31,7 @@ $logo-max-width: 200px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: globals.$spacing-unit / 2;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
color: globals.$body-color;
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export function DeleteGameModal({
|
||||
onClose,
|
||||
visible,
|
||||
deleteGame,
|
||||
}: DeleteGameModalProps) {
|
||||
}: Readonly<DeleteGameModalProps>) {
|
||||
const { t } = useTranslation("downloads");
|
||||
|
||||
const handleDeleteGame = () => {
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
|
||||
&__details-with-article {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
align-self: flex-start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DownloadIcon,
|
||||
LinkIcon,
|
||||
PlayIcon,
|
||||
QuestionIcon,
|
||||
ThreeBarsIcon,
|
||||
TrashIcon,
|
||||
UnlinkIcon,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
} from "@primer/octicons-react";
|
||||
|
||||
import torBoxLogo from "@renderer/assets/icons/torbox.webp";
|
||||
|
||||
export interface DownloadGroupProps {
|
||||
library: LibraryGame[];
|
||||
title: string;
|
||||
@@ -122,8 +124,12 @@ export function DownloadGroup({
|
||||
</p>
|
||||
|
||||
{download.downloader === Downloader.Torrent && (
|
||||
<small>
|
||||
<small
|
||||
className="download-group__details-with-article"
|
||||
data-open-article="peers-and-seeds"
|
||||
>
|
||||
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
|
||||
<QuestionIcon size={12} />
|
||||
</small>
|
||||
)}
|
||||
</>
|
||||
@@ -136,7 +142,14 @@ export function DownloadGroup({
|
||||
return download.status === "seeding" &&
|
||||
download.downloader === Downloader.Torrent ? (
|
||||
<>
|
||||
<p>{t("seeding")}</p>
|
||||
<p
|
||||
data-open-article="seeding"
|
||||
className="download-group__details-with-article"
|
||||
>
|
||||
{t("seeding")}
|
||||
|
||||
<QuestionIcon />
|
||||
</p>
|
||||
{uploadSpeed && <p>{uploadSpeed}/s</p>}
|
||||
</>
|
||||
) : (
|
||||
@@ -174,7 +187,7 @@ export function DownloadGroup({
|
||||
|
||||
const deleting = isGameDeleting(game.id);
|
||||
|
||||
if (download?.progress === 1) {
|
||||
if (game.download?.progress === 1) {
|
||||
return [
|
||||
{
|
||||
label: t("install"),
|
||||
@@ -189,8 +202,8 @@ export function DownloadGroup({
|
||||
disabled: deleting,
|
||||
icon: <UnlinkIcon />,
|
||||
show:
|
||||
download.status === "seeding" &&
|
||||
download.downloader === Downloader.Torrent,
|
||||
game.download?.status === "seeding" &&
|
||||
game.download?.downloader === Downloader.Torrent,
|
||||
onClick: () => {
|
||||
pauseSeeding(game.shop, game.objectId);
|
||||
},
|
||||
@@ -200,8 +213,8 @@ export function DownloadGroup({
|
||||
disabled: deleting,
|
||||
icon: <LinkIcon />,
|
||||
show:
|
||||
download.status !== "seeding" &&
|
||||
download.downloader === Downloader.Torrent,
|
||||
game.download?.status !== "seeding" &&
|
||||
game.download?.downloader === Downloader.Torrent,
|
||||
onClick: () => {
|
||||
resumeSeeding(game.shop, game.objectId);
|
||||
},
|
||||
@@ -217,7 +230,7 @@ export function DownloadGroup({
|
||||
];
|
||||
}
|
||||
|
||||
if (isGameDownloading || download?.status === "active") {
|
||||
if (isGameDownloading) {
|
||||
return [
|
||||
{
|
||||
label: t("pause"),
|
||||
|
||||
@@ -8,7 +8,7 @@ import "./downloads.scss";
|
||||
import { DeleteGameModal } from "./delete-game-modal";
|
||||
import { DownloadGroup } from "./download-group";
|
||||
import type { GameShop, LibraryGame, SeedingStatus } from "@types";
|
||||
import { orderBy, sortBy } from "lodash-es";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { ArrowDownIcon } from "@primer/octicons-react";
|
||||
|
||||
export default function Downloads() {
|
||||
@@ -58,24 +58,24 @@ export default function Downloads() {
|
||||
complete: [],
|
||||
};
|
||||
|
||||
const result = sortBy(library, (game) => game.download?.timestamp).reduce(
|
||||
(prev, next) => {
|
||||
/* Game has been manually added to the library or has been canceled */
|
||||
if (!next.download?.status || next.download?.status === "removed")
|
||||
return prev;
|
||||
const result = orderBy(
|
||||
library,
|
||||
(game) => game.download?.timestamp,
|
||||
"desc"
|
||||
).reduce((prev, next) => {
|
||||
/* Game has been manually added to the library */
|
||||
if (!next.download) return prev;
|
||||
|
||||
/* Is downloading */
|
||||
if (lastPacket?.gameId === next.id)
|
||||
return { ...prev, downloading: [...prev.downloading, next] };
|
||||
/* Is downloading */
|
||||
if (lastPacket?.gameId === next.id)
|
||||
return { ...prev, downloading: [...prev.downloading, next] };
|
||||
|
||||
/* Is either queued or paused */
|
||||
if (next.download.queued || next.download?.status === "paused")
|
||||
return { ...prev, queued: [...prev.queued, next] };
|
||||
/* Is either queued or paused */
|
||||
if (next.download.queued || next.download?.status === "paused")
|
||||
return { ...prev, queued: [...prev.queued, next] };
|
||||
|
||||
return { ...prev, complete: [...prev.complete, next] };
|
||||
},
|
||||
initialValue
|
||||
);
|
||||
return { ...prev, complete: [...prev.complete, next] };
|
||||
}, initialValue);
|
||||
|
||||
const queued = orderBy(result.queued, (game) => game.download?.timestamp, [
|
||||
"desc",
|
||||
|
||||
@@ -203,9 +203,10 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
|
||||
<div className="cloud-sync-modal__artifact-info">
|
||||
<div className="cloud-sync-modal__artifact-header">
|
||||
<h3>
|
||||
{t("backup_from", {
|
||||
date: format(artifact.createdAt, "dd/MM/yyyy"),
|
||||
})}
|
||||
{artifact.label ??
|
||||
t("backup_from", {
|
||||
date: format(artifact.createdAt, "dd/MM/yyyy"),
|
||||
})}
|
||||
</h3>
|
||||
<small>{formatBytes(artifact.artifactLengthInBytes)}</small>
|
||||
</div>
|
||||
|
||||
@@ -16,13 +16,8 @@ import { useUserDetails } from "@renderer/hooks";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
import "./game-details.scss";
|
||||
|
||||
const HERO_HEIGHT = 300;
|
||||
const HERO_ANIMATION_THRESHOLD = 25;
|
||||
|
||||
export function GameDetailsContent() {
|
||||
const heroRef = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isHeaderStuck, setIsHeaderStuck] = useState(false);
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
@@ -61,7 +56,7 @@ export function GameDetailsContent() {
|
||||
return t("no_shop_details");
|
||||
}, [shopDetails, t]);
|
||||
|
||||
const [backdropOpactiy, setBackdropOpacity] = useState(1);
|
||||
const [backdropOpacity, setBackdropOpacity] = useState(1);
|
||||
|
||||
const handleHeroLoad = async () => {
|
||||
const output = await average(steamUrlBuilder.libraryHero(objectId!), {
|
||||
@@ -80,26 +75,6 @@ export function GameDetailsContent() {
|
||||
setBackdropOpacity(1);
|
||||
}, [objectId]);
|
||||
|
||||
const onScroll: React.UIEventHandler<HTMLElement> = (event) => {
|
||||
const heroHeight = heroRef.current?.clientHeight ?? HERO_HEIGHT;
|
||||
|
||||
const scrollY = (event.target as HTMLDivElement).scrollTop;
|
||||
const opacity = Math.max(
|
||||
0,
|
||||
1 - scrollY / (heroHeight - HERO_ANIMATION_THRESHOLD)
|
||||
);
|
||||
|
||||
if (scrollY >= heroHeight && !isHeaderStuck) {
|
||||
setIsHeaderStuck(true);
|
||||
}
|
||||
|
||||
if (scrollY <= heroHeight && isHeaderStuck) {
|
||||
setIsHeaderStuck(false);
|
||||
}
|
||||
|
||||
setBackdropOpacity(opacity);
|
||||
};
|
||||
|
||||
const handleCloudSaveButtonClick = () => {
|
||||
if (!userDetails) {
|
||||
window.electron.openAuthWindow(AuthPage.SignIn);
|
||||
@@ -122,31 +97,25 @@ export function GameDetailsContent() {
|
||||
<div
|
||||
className={`game-details__wrapper ${hasNSFWContentBlocked ? "game-details__wrapper--blurred" : ""}`}
|
||||
>
|
||||
<img
|
||||
src={steamUrlBuilder.libraryHero(objectId!)}
|
||||
className="game-details__hero-image"
|
||||
alt={game?.title}
|
||||
onLoad={handleHeroLoad}
|
||||
/>
|
||||
|
||||
<section
|
||||
ref={containerRef}
|
||||
onScroll={onScroll}
|
||||
className="game-details__container"
|
||||
>
|
||||
<section className="game-details__container">
|
||||
<div ref={heroRef} className="game-details__hero">
|
||||
<img
|
||||
src={steamUrlBuilder.libraryHero(objectId!)}
|
||||
className="game-details__hero-image"
|
||||
alt={game?.title}
|
||||
onLoad={handleHeroLoad}
|
||||
/>
|
||||
<div
|
||||
className="game-details__hero-backdrop"
|
||||
style={{
|
||||
backgroundColor: gameColor,
|
||||
flex: 1,
|
||||
opacity: Math.min(1, 1 - backdropOpactiy),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="game-details__hero-logo-backdrop"
|
||||
style={{ opacity: backdropOpactiy }}
|
||||
style={{ opacity: backdropOpacity }}
|
||||
>
|
||||
<div className="game-details__hero-content">
|
||||
<img
|
||||
@@ -173,7 +142,7 @@ export function GameDetailsContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeroPanel isHeaderStuck={isHeaderStuck} />
|
||||
<HeroPanel />
|
||||
|
||||
<div className="game-details__description-container">
|
||||
<div className="game-details__description-content">
|
||||
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
PlusCircleIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { Button } from "@renderer/components";
|
||||
import { useDownload, useLibrary } from "@renderer/hooks";
|
||||
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
|
||||
import { useContext, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
|
||||
import "./hero-panel-actions.scss";
|
||||
|
||||
export function HeroPanelActions() {
|
||||
@@ -39,6 +40,8 @@ export function HeroPanelActions() {
|
||||
|
||||
const { updateLibrary } = useLibrary();
|
||||
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const addGameToLibrary = async () => {
|
||||
@@ -54,25 +57,24 @@ export function HeroPanelActions() {
|
||||
}
|
||||
};
|
||||
|
||||
const addGameToFavorites = async () => {
|
||||
const toggleGameFavorite = async () => {
|
||||
setToggleLibraryGameDisabled(true);
|
||||
|
||||
try {
|
||||
if (!objectId) throw new Error("objectId is required");
|
||||
await window.electron.addGameToFavorites(shop, objectId);
|
||||
updateLibrary();
|
||||
updateGame();
|
||||
} finally {
|
||||
setToggleLibraryGameDisabled(false);
|
||||
}
|
||||
};
|
||||
if (game?.favorite && objectId) {
|
||||
await window.electron
|
||||
.removeGameFromFavorites(shop, objectId)
|
||||
.then(() => {
|
||||
showSuccessToast(t("game_removed_from_favorites"));
|
||||
});
|
||||
} else {
|
||||
if (!objectId) return;
|
||||
|
||||
const removeGameFromFavorites = async () => {
|
||||
setToggleLibraryGameDisabled(true);
|
||||
await window.electron.addGameToFavorites(shop, objectId).then(() => {
|
||||
showSuccessToast(t("game_added_to_favorites"));
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (!objectId) throw new Error("objectId is required");
|
||||
await window.electron.removeGameFromFavorites(shop, objectId);
|
||||
updateLibrary();
|
||||
updateGame();
|
||||
} finally {
|
||||
@@ -188,7 +190,7 @@ export function HeroPanelActions() {
|
||||
{gameActionButton()}
|
||||
<div className="hero-panel-actions__separator" />
|
||||
<Button
|
||||
onClick={game.favorite ? removeGameFromFavorites : addGameToFavorites}
|
||||
onClick={toggleGameFavorite}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
className="hero-panel-actions__action"
|
||||
|
||||
@@ -9,11 +9,7 @@ import { HeroPanelPlaytime } from "./hero-panel-playtime";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import "./hero-panel.scss";
|
||||
|
||||
export interface HeroPanelProps {
|
||||
isHeaderStuck: boolean;
|
||||
}
|
||||
|
||||
export function HeroPanel({ isHeaderStuck }: HeroPanelProps) {
|
||||
export function HeroPanel() {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { formatDate } = useDate();
|
||||
@@ -54,10 +50,7 @@ export function HeroPanel({ isHeaderStuck }: HeroPanelProps) {
|
||||
game?.download?.status === "paused";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ backgroundColor: gameColor }}
|
||||
className={`hero-panel ${isHeaderStuck ? "hero-panel--stuck" : ""}`}
|
||||
>
|
||||
<div style={{ backgroundColor: gameColor }} className="hero-panel">
|
||||
<div className="hero-panel__content">{getInfo()}</div>
|
||||
<div className="hero-panel__actions">
|
||||
<HeroPanelActions />
|
||||
|
||||
@@ -44,10 +44,9 @@ export function DownloadSettingsModal({
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const getDiskFreeSpace = (path: string) => {
|
||||
window.electron.getDiskFreeSpace(path).then((result) => {
|
||||
setDiskFreeSpace(result.free);
|
||||
});
|
||||
const getDiskFreeSpace = async (path: string) => {
|
||||
const result = await window.electron.getDiskFreeSpace(path);
|
||||
setDiskFreeSpace(result.free);
|
||||
};
|
||||
|
||||
const checkFolderWritePermission = useCallback(
|
||||
@@ -100,6 +99,7 @@ export function DownloadSettingsModal({
|
||||
userPreferences?.downloadsPath,
|
||||
downloaders,
|
||||
userPreferences?.realDebridApiToken,
|
||||
userPreferences?.torBoxApiToken,
|
||||
]);
|
||||
|
||||
const handleChooseDownloadsPath = async () => {
|
||||
@@ -155,25 +155,30 @@ export function DownloadSettingsModal({
|
||||
<span>{t("downloader")}</span>
|
||||
|
||||
<div className="download-settings-modal__downloaders">
|
||||
{downloaders.map((downloader) => (
|
||||
<Button
|
||||
key={downloader}
|
||||
className="download-settings-modal__downloader-option"
|
||||
theme={
|
||||
selectedDownloader === downloader ? "primary" : "outline"
|
||||
}
|
||||
disabled={
|
||||
downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken
|
||||
}
|
||||
onClick={() => setSelectedDownloader(downloader)}
|
||||
>
|
||||
{selectedDownloader === downloader && (
|
||||
<CheckCircleFillIcon className="download-settings-modal__downloader-icon" />
|
||||
)}
|
||||
{DOWNLOADER_NAME[downloader]}
|
||||
</Button>
|
||||
))}
|
||||
{downloaders.map((downloader) => {
|
||||
const shouldDisableButton =
|
||||
(downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken) ||
|
||||
(downloader === Downloader.TorBox &&
|
||||
!userPreferences?.torBoxApiToken);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={downloader}
|
||||
className="download-settings-modal__downloader-option"
|
||||
theme={
|
||||
selectedDownloader === downloader ? "primary" : "outline"
|
||||
}
|
||||
disabled={shouldDisableButton}
|
||||
onClick={() => setSelectedDownloader(downloader)}
|
||||
>
|
||||
{selectedDownloader === downloader && (
|
||||
<CheckCircleFillIcon className="download-settings-modal__downloader-icon" />
|
||||
)}
|
||||
{DOWNLOADER_NAME[downloader]}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,6 +23,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__cloud-sync-label {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__cloud-sync-hydra-cloud {
|
||||
background: linear-gradient(270deg, #16b195 50%, #3e62c0 100%);
|
||||
color: #fff;
|
||||
padding: 0 globals.$spacing-unit;
|
||||
border-radius: 4px;
|
||||
font-size: globals.$small-font-size;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useContext, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Modal, TextField } from "@renderer/components";
|
||||
import { Button, CheckboxField, Modal, TextField } from "@renderer/components";
|
||||
import type { LibraryGame } from "@types";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
|
||||
@@ -34,12 +34,17 @@ export function GameOptionsModal({
|
||||
achievements,
|
||||
} = useContext(gameDetailsContext);
|
||||
|
||||
const { hasActiveSubscription } = useUserDetails();
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showRemoveGameModal, setShowRemoveGameModal] = useState(false);
|
||||
const [launchOptions, setLaunchOptions] = useState(game.launchOptions ?? "");
|
||||
const [showResetAchievementsModal, setShowResetAchievementsModal] =
|
||||
useState(false);
|
||||
const [isDeletingAchievements, setIsDeletingAchievements] = useState(false);
|
||||
const [automaticCloudSync, setAutomaticCloudSync] = useState(
|
||||
game.automaticCloudSync ?? false
|
||||
);
|
||||
|
||||
const {
|
||||
removeGameInstaller,
|
||||
@@ -183,6 +188,20 @@ export function GameOptionsModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAutomaticCloudSync = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
setAutomaticCloudSync(event.target.checked);
|
||||
|
||||
await window.electron.toggleAutomaticCloudSync(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
event.target.checked
|
||||
);
|
||||
|
||||
updateGame();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteGameModal
|
||||
@@ -266,6 +285,20 @@ export function GameOptionsModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CheckboxField
|
||||
label={
|
||||
<div className="game-options-modal__cloud-sync-label">
|
||||
{t("enable_automatic_cloud_sync")}
|
||||
<span className="game-options-modal__cloud-sync-hydra-cloud">
|
||||
Hydra Cloud
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
checked={automaticCloudSync}
|
||||
disabled={!hasActiveSubscription || !game.executablePath}
|
||||
onChange={handleToggleAutomaticCloudSync}
|
||||
/>
|
||||
|
||||
{shouldShowWinePrefixConfiguration && (
|
||||
<div className="game-options-modal__wine-prefix">
|
||||
<div className="game-options-modal__header">
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__badges {
|
||||
display: flex;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
}
|
||||
|
||||
&__user-information {
|
||||
display: flex;
|
||||
padding: calc(globals.$spacing-unit * 7) calc(globals.$spacing-unit * 3);
|
||||
@@ -65,6 +70,12 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__display-name-container {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__display-name {
|
||||
font-weight: bold;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { FriendRequestAction } from "@types";
|
||||
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 { Tooltip } from "react-tooltip";
|
||||
import "./profile-hero.scss";
|
||||
|
||||
type FriendAction =
|
||||
@@ -34,8 +35,14 @@ export function ProfileHero() {
|
||||
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
|
||||
const [isPerformingAction, setIsPerformingAction] = useState(false);
|
||||
|
||||
const { isMe, getUserProfile, userProfile, heroBackground, backgroundImage } =
|
||||
useContext(userProfileContext);
|
||||
const {
|
||||
isMe,
|
||||
badges,
|
||||
getUserProfile,
|
||||
userProfile,
|
||||
heroBackground,
|
||||
backgroundImage,
|
||||
} = useContext(userProfileContext);
|
||||
const {
|
||||
signOut,
|
||||
updateFriendRequestState,
|
||||
@@ -260,14 +267,6 @@ export function ProfileHero() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* <ConfirmationModal
|
||||
visible
|
||||
title={t("sign_out_modal_title")}
|
||||
descriptionText={t("sign_out_modal_text")}
|
||||
confirmButtonLabel={t("sign_out")}
|
||||
cancelButtonLabel={t("cancel")}
|
||||
/> */}
|
||||
|
||||
<EditProfileModal
|
||||
visible={showEditProfileModal}
|
||||
onClose={() => setShowEditProfileModal(false)}
|
||||
@@ -307,9 +306,34 @@ export function ProfileHero() {
|
||||
|
||||
<div className="profile-hero__information">
|
||||
{userProfile ? (
|
||||
<h2 className="profile-hero__display-name">
|
||||
{userProfile?.displayName}
|
||||
</h2>
|
||||
<div className="profile-hero__display-name-container">
|
||||
<h2 className="profile-hero__display-name">
|
||||
{userProfile?.displayName}
|
||||
</h2>
|
||||
|
||||
<div className="profile-hero__badges">
|
||||
{userProfile.badges.map((badgeName) => {
|
||||
const badge = badges.find((b) => b.name === badgeName);
|
||||
|
||||
if (!badge) return null;
|
||||
|
||||
return (
|
||||
<img
|
||||
key={badge.name}
|
||||
src={badge.badge.url}
|
||||
alt={badge.name}
|
||||
width={24}
|
||||
height={24}
|
||||
data-tooltip-place="top"
|
||||
data-tooltip-content={badge.description}
|
||||
data-tooltip-id="badge-name"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Tooltip id="badge-name" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton width={150} height={28} />
|
||||
)}
|
||||
|
||||
@@ -74,7 +74,10 @@ export function ReportProfile() {
|
||||
title={t("report_profile")}
|
||||
clickOutsideToClose={false}
|
||||
>
|
||||
<form className="report-profile__form">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="report-profile__form"
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="reason"
|
||||
@@ -101,12 +104,7 @@ export function ReportProfile() {
|
||||
error={errors.description?.message}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="report-profile__submit"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
>
|
||||
{t("report")}
|
||||
</Button>
|
||||
<Button className="report-profile__submit">{t("report")}</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export function AddDownloadSourceModal({
|
||||
visible,
|
||||
onClose,
|
||||
onAddDownloadSource,
|
||||
}: AddDownloadSourceModalProps) {
|
||||
}: Readonly<AddDownloadSourceModalProps>) {
|
||||
const [url, setUrl] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { GlobeIcon, TrashIcon, PlusIcon } from "@primer/octicons-react";
|
||||
import { Button } from "@renderer/components/button/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddThemeModal, DeleteAllThemesModal } from "../index";
|
||||
import "./theme-actions.scss";
|
||||
import { useState } from "react";
|
||||
import { THEME_WEB_STORE_URL } from "@renderer/constants";
|
||||
|
||||
interface ThemeActionsProps {
|
||||
onListUpdated: () => void;
|
||||
themesCount: number;
|
||||
}
|
||||
|
||||
export const ThemeActions = ({
|
||||
onListUpdated,
|
||||
themesCount,
|
||||
}: ThemeActionsProps) => {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const [addThemeModalVisible, setAddThemeModalVisible] = useState(false);
|
||||
const [deleteAllThemesModalVisible, setDeleteAllThemesModalVisible] =
|
||||
useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddThemeModal
|
||||
visible={addThemeModalVisible}
|
||||
onClose={() => setAddThemeModalVisible(false)}
|
||||
onThemeAdded={onListUpdated}
|
||||
/>
|
||||
|
||||
<DeleteAllThemesModal
|
||||
visible={deleteAllThemesModalVisible}
|
||||
onClose={() => setDeleteAllThemesModalVisible(false)}
|
||||
onThemesDeleted={onListUpdated}
|
||||
/>
|
||||
|
||||
<div className="settings-appearance__actions">
|
||||
<div className="settings-appearance__actions-left">
|
||||
<Button
|
||||
theme="primary"
|
||||
className="settings-appearance__button"
|
||||
onClick={() => {
|
||||
window.open(THEME_WEB_STORE_URL, "_blank");
|
||||
}}
|
||||
>
|
||||
<GlobeIcon />
|
||||
{t("web_store")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
theme="danger"
|
||||
className="settings-appearance__button"
|
||||
onClick={() => setDeleteAllThemesModalVisible(true)}
|
||||
disabled={themesCount < 1}
|
||||
>
|
||||
<TrashIcon />
|
||||
{t("clear_themes")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="settings-appearance__actions-right">
|
||||
<Button
|
||||
theme="outline"
|
||||
className="settings-appearance__button"
|
||||
onClick={() => setAddThemeModalVisible(true)}
|
||||
>
|
||||
<PlusIcon />
|
||||
{t("create_theme")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { PencilIcon, TrashIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@renderer/components/button/button";
|
||||
import type { Theme } from "@types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./theme-card.scss";
|
||||
import { useState } from "react";
|
||||
import { DeleteThemeModal } from "../modals/delete-theme-modal";
|
||||
import { injectCustomCss, removeCustomCss } from "@renderer/helpers";
|
||||
import { THEME_WEB_STORE_URL } from "@renderer/constants";
|
||||
|
||||
interface ThemeCardProps {
|
||||
theme: Theme;
|
||||
onListUpdated: () => void;
|
||||
}
|
||||
|
||||
export const ThemeCard = ({ theme, onListUpdated }: ThemeCardProps) => {
|
||||
const { t } = useTranslation("settings");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [deleteThemeModalVisible, setDeleteThemeModalVisible] = useState(false);
|
||||
|
||||
const handleSetTheme = async () => {
|
||||
try {
|
||||
const currentTheme = await window.electron.getCustomThemeById(theme.id);
|
||||
|
||||
if (!currentTheme) return;
|
||||
|
||||
const activeTheme = await window.electron.getActiveCustomTheme();
|
||||
|
||||
if (activeTheme) {
|
||||
removeCustomCss();
|
||||
await window.electron.toggleCustomTheme(activeTheme.id, false);
|
||||
}
|
||||
|
||||
if (currentTheme.code) {
|
||||
injectCustomCss(currentTheme.code);
|
||||
}
|
||||
|
||||
await window.electron.toggleCustomTheme(currentTheme.id, true);
|
||||
|
||||
onListUpdated();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnsetTheme = async () => {
|
||||
try {
|
||||
removeCustomCss();
|
||||
await window.electron.toggleCustomTheme(theme.id, false);
|
||||
|
||||
onListUpdated();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteThemeModal
|
||||
visible={deleteThemeModalVisible}
|
||||
onClose={() => setDeleteThemeModalVisible(false)}
|
||||
onThemeDeleted={onListUpdated}
|
||||
themeId={theme.id}
|
||||
themeName={theme.name}
|
||||
isActive={theme.isActive}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`theme-card ${theme.isActive ? "theme-card--active" : ""}`}
|
||||
key={theme.name}
|
||||
>
|
||||
<div className="theme-card__header">
|
||||
<div className="theme-card__header__title">{theme.name}</div>
|
||||
</div>
|
||||
|
||||
{theme.authorName && (
|
||||
<p className="theme-card__author">
|
||||
{t("by")}
|
||||
|
||||
<button
|
||||
className="theme-card__author__name"
|
||||
onClick={() => navigate(`/profile/${theme.author}`)}
|
||||
>
|
||||
{theme.authorName}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="theme-card__actions">
|
||||
<div className="theme-card__actions__left">
|
||||
{theme.isActive ? (
|
||||
<Button onClick={handleUnsetTheme} theme="dark">
|
||||
{t("unset_theme")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSetTheme} theme="outline">
|
||||
{t("set_theme")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="theme-card__actions__right">
|
||||
<Button
|
||||
className={
|
||||
theme.code.startsWith(THEME_WEB_STORE_URL)
|
||||
? "theme-card__actions__right--external"
|
||||
: ""
|
||||
}
|
||||
onClick={() => window.electron.openEditorWindow(theme.id)}
|
||||
title={t("edit_theme")}
|
||||
theme="outline"
|
||||
>
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setDeleteThemeModalVisible(true)}
|
||||
title={t("delete_theme")}
|
||||
theme="outline"
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { AlertIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./theme-placeholder.scss";
|
||||
import { AddThemeModal } from "../modals/add-theme-modal";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ThemePlaceholderProps {
|
||||
onListUpdated: () => void;
|
||||
}
|
||||
|
||||
export const ThemePlaceholder = ({ onListUpdated }: ThemePlaceholderProps) => {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const [addThemeModalVisible, setAddThemeModalVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddThemeModal
|
||||
visible={addThemeModalVisible}
|
||||
onClose={() => setAddThemeModalVisible(false)}
|
||||
onThemeAdded={onListUpdated}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="theme-placeholder"
|
||||
onClick={() => setAddThemeModalVisible(true)}
|
||||
>
|
||||
<div className="theme-placeholder__icon">
|
||||
<AlertIcon />
|
||||
</div>
|
||||
|
||||
<p className="theme-placeholder__text">{t("no_themes")}</p>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
7
src/renderer/src/pages/settings/aparence/index.ts
Normal file
7
src/renderer/src/pages/settings/aparence/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { SettingsAppearance } from "./settings-appearance";
|
||||
export { AddThemeModal } from "./modals/add-theme-modal";
|
||||
export { DeleteAllThemesModal } from "./modals/delete-all-themes-modal";
|
||||
export { DeleteThemeModal } from "./modals/delete-theme-modal";
|
||||
export { ThemeCard } from "./components/theme-card";
|
||||
export { ThemePlaceholder } from "./components/theme-placeholder";
|
||||
export { ThemeActions } from "./components/theme-actions";
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Modal } from "@renderer/components/modal/modal";
|
||||
import { TextField } from "@renderer/components/text-field/text-field";
|
||||
import { Button } from "@renderer/components/button/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUserDetails } from "@renderer/hooks";
|
||||
import { Theme } from "@types";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import * as yup from "yup";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import "./modals.scss";
|
||||
|
||||
interface AddThemeModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onThemeAdded: () => void;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const DEFAULT_THEME_CODE = `
|
||||
/*
|
||||
Here you can edit CSS for your theme and apply it on Hydra.
|
||||
There are a few classes already in place, you can use them to style the launcher.
|
||||
|
||||
If you want to learn more about how to run Hydra in dev mode (which will allow you to inspect the DOM and view the classes)
|
||||
or how to publish your theme in the theme store, you can check the docs:
|
||||
https://docs.hydralauncher.gg/
|
||||
|
||||
Happy hacking!
|
||||
*/
|
||||
|
||||
/* Header */
|
||||
.header {}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {}
|
||||
|
||||
/* Main content */
|
||||
.container__content {}
|
||||
|
||||
/* Bottom panel */
|
||||
.bottom-panel {}
|
||||
|
||||
/* Toast */
|
||||
.toast {}
|
||||
|
||||
/* Button */
|
||||
.button {}
|
||||
|
||||
`;
|
||||
|
||||
export function AddThemeModal({
|
||||
visible,
|
||||
onClose,
|
||||
onThemeAdded,
|
||||
}: Readonly<AddThemeModalProps>) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { userDetails } = useUserDetails();
|
||||
|
||||
const schema = yup.object({
|
||||
name: yup
|
||||
.string()
|
||||
.required(t("required_field"))
|
||||
.min(3, t("name_min_length")),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: yupResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (values: FormValues) => {
|
||||
const theme: Theme = {
|
||||
id: crypto.randomUUID(),
|
||||
name: values.name,
|
||||
isActive: false,
|
||||
author: userDetails?.id,
|
||||
authorName: userDetails?.username,
|
||||
code: DEFAULT_THEME_CODE,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await window.electron.addCustomTheme(theme);
|
||||
onThemeAdded();
|
||||
onClose();
|
||||
reset();
|
||||
},
|
||||
[onClose, onThemeAdded, userDetails?.id, userDetails?.username, reset]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("create_theme_modal_title")}
|
||||
description={t("create_theme_modal_description")}
|
||||
onClose={onClose}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="add-theme-modal__container"
|
||||
>
|
||||
<TextField
|
||||
{...register("name")}
|
||||
label={t("theme_name")}
|
||||
placeholder={t("insert_theme_name")}
|
||||
hint={errors.name?.message}
|
||||
error={errors.name?.message}
|
||||
/>
|
||||
|
||||
<Button type="submit" theme="primary" disabled={isSubmitting}>
|
||||
{t("create_theme")}
|
||||
</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Button } from "@renderer/components/button/button";
|
||||
import { Modal } from "@renderer/components/modal/modal";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./modals.scss";
|
||||
import { removeCustomCss } from "@renderer/helpers";
|
||||
|
||||
interface DeleteAllThemesModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onThemesDeleted: () => void;
|
||||
}
|
||||
|
||||
export const DeleteAllThemesModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
onThemesDeleted,
|
||||
}: DeleteAllThemesModalProps) => {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const handleDeleteAllThemes = async () => {
|
||||
const activeTheme = await window.electron.getActiveCustomTheme();
|
||||
|
||||
if (activeTheme) {
|
||||
removeCustomCss();
|
||||
}
|
||||
|
||||
await window.electron.deleteAllCustomThemes();
|
||||
await window.electron.closeEditorWindow();
|
||||
onClose();
|
||||
onThemesDeleted();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("delete_all_themes")}
|
||||
description={t("delete_all_themes_description")}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="delete-all-themes-modal__container">
|
||||
<Button theme="outline" onClick={handleDeleteAllThemes}>
|
||||
{t("delete_all_themes")}
|
||||
</Button>
|
||||
|
||||
<Button theme="primary" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Button } from "@renderer/components/button/button";
|
||||
import { Modal } from "@renderer/components/modal/modal";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./modals.scss";
|
||||
import { removeCustomCss } from "@renderer/helpers";
|
||||
|
||||
interface DeleteThemeModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
themeId: string;
|
||||
isActive: boolean;
|
||||
onThemeDeleted: () => void;
|
||||
themeName: string;
|
||||
}
|
||||
|
||||
export const DeleteThemeModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
themeId,
|
||||
isActive,
|
||||
onThemeDeleted,
|
||||
themeName,
|
||||
}: DeleteThemeModalProps) => {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const handleDeleteTheme = async () => {
|
||||
if (isActive) {
|
||||
removeCustomCss();
|
||||
}
|
||||
|
||||
await window.electron.deleteCustomTheme(themeId);
|
||||
await window.electron.closeEditorWindow(themeId);
|
||||
onThemeDeleted();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("delete_theme")}
|
||||
description={t("delete_theme_description", { theme: themeName })}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="delete-all-themes-modal__container">
|
||||
<Button theme="outline" onClick={handleDeleteTheme}>
|
||||
{t("delete_theme")}
|
||||
</Button>
|
||||
|
||||
<Button theme="primary" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user