feat: support cloud storage management (#2636)

This commit is contained in:
Huang Xin
2025-12-07 03:25:40 +08:00
committed by GitHub
parent 00f36af03a
commit 42b47d73b7
35 changed files with 1981 additions and 48 deletions
@@ -749,5 +749,51 @@
"Enter {{terms}}": "أدخل {{terms}}",
"No search results found": "لم يتم العثور على نتائج بحث",
"Failed to load OPDS feed: {{status}} {{statusText}}": "فشل في تحميل تغذية OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "البحث في {{title}}"
"Search in {{title}}": "البحث في {{title}}",
"Manage Storage": "إدارة التخزين",
"Failed to load files": "فشل في تحميل الملفات",
"Deleted {{count}} file(s)_zero": "لم يتم حذف أي ملفات",
"Deleted {{count}} file(s)_one": "تم حذف ملف واحد",
"Deleted {{count}} file(s)_two": "تم حذف ملفين",
"Deleted {{count}} file(s)_few": "تم حذف {{count}} ملفات",
"Deleted {{count}} file(s)_many": "تم حذف {{count}} ملفًا",
"Deleted {{count}} file(s)_other": "تم حذف {{count}} ملف",
"Failed to delete {{count}} file(s)_zero": "فشل حذف أي ملفات",
"Failed to delete {{count}} file(s)_one": "فشل حذف ملف واحد",
"Failed to delete {{count}} file(s)_two": "فشل حذف ملفين",
"Failed to delete {{count}} file(s)_few": "فشل حذف {{count}} ملفات",
"Failed to delete {{count}} file(s)_many": "فشل حذف {{count}} ملفًا",
"Failed to delete {{count}} file(s)_other": "فشل حذف {{count}} ملف",
"Failed to delete files": "فشل حذف الملفات",
"Total Files": "إجمالي الملفات",
"Total Size": "إجمالي الحجم",
"Quota": "الحصة",
"Used": "المستخدم",
"Files": "الملفات",
"Search files...": "ابحث في الملفات...",
"Newest First": "الأحدث أولاً",
"Oldest First": "الأقدم أولاً",
"Largest First": "الأكبر أولاً",
"Smallest First": "الأصغر أولاً",
"Name A-Z": "الاسم من أ إلى ي",
"Name Z-A": "الاسم من ي إلى أ",
"{{count}} selected_zero": "لا يوجد عناصر محددة",
"{{count}} selected_one": "عنصر واحد محدد",
"{{count}} selected_two": "عنصران محددان",
"{{count}} selected_few": "{{count}} عناصر محددة",
"{{count}} selected_many": "{{count}} عنصرًا محددًا",
"{{count}} selected_other": "{{count}} عنصر محدد",
"Delete Selected": "حذف المحدد",
"Created": "تاريخ الإنشاء",
"No files found": "لا توجد ملفات",
"No files uploaded yet": "لم يتم رفع أي ملفات بعد",
"files": "الملفات",
"Page {{current}} of {{total}}": "الصفحة {{current}} من {{total}}",
"Are you sure to delete {{count}} selected file(s)?_zero": "هل أنت متأكد من حذف العناصر المحددة؟ (لا توجد عناصر)",
"Are you sure to delete {{count}} selected file(s)?_one": "هل أنت متأكد من حذف ملف واحد محدد؟",
"Are you sure to delete {{count}} selected file(s)?_two": "هل أنت متأكد من حذف ملفين محددين؟",
"Are you sure to delete {{count}} selected file(s)?_few": "هل أنت متأكد من حذف {{count}} ملفات محددة؟",
"Are you sure to delete {{count}} selected file(s)?_many": "هل أنت متأكد من حذف {{count}} ملفًا محددًا؟",
"Are you sure to delete {{count}} selected file(s)?_other": "هل أنت متأكد من حذف {{count}} ملف محدد؟",
"Cloud Storage Usage": "استخدام التخزين السحابي"
}
@@ -729,5 +729,35 @@
"Enter {{terms}}": "{{terms}} লিখুন",
"No search results found": "কোনও অনুসন্ধান ফলাফল পাওয়া যায়নি",
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS ফিড লোড করতে ব্যর্থ: {{status}} {{statusText}}",
"Search in {{title}}": "{{title}} এ অনুসন্ধান করুন"
"Search in {{title}}": "{{title}} এ অনুসন্ধান করুন",
"Manage Storage": "স্টোরেজ ম্যানেজমেন্ট",
"Failed to load files": "ফাইল লোড করতে ব্যর্থ",
"Deleted {{count}} file(s)_one": "{{count}}টি ফাইল মুছে ফেলা হয়েছে",
"Deleted {{count}} file(s)_other": "{{count}}টি ফাইল মুছে ফেলা হয়েছে",
"Failed to delete {{count}} file(s)_one": "{{count}}টি ফাইল মুছতে ব্যর্থ",
"Failed to delete {{count}} file(s)_other": "{{count}}টি ফাইল মুছতে ব্যর্থ",
"Failed to delete files": "ফাইল মুছতে ব্যর্থ",
"Total Files": "মোট ফাইল",
"Total Size": "মোট আকার",
"Quota": "কোটা",
"Used": "ব্যবহৃত",
"Files": "ফাইল",
"Search files...": "ফাইল খুঁজুন...",
"Newest First": "নতুন আগে",
"Oldest First": "পুরনো আগে",
"Largest First": "বড় আগে",
"Smallest First": "ছোট আগে",
"Name A-Z": "নাম A-Z",
"Name Z-A": "নাম Z-A",
"{{count}} selected_one": "{{count}}টি নির্বাচিত",
"{{count}} selected_other": "{{count}}টি নির্বাচিত",
"Delete Selected": "নির্বাচিত মুছুন",
"Created": "তৈরি হয়েছে",
"No files found": "কোনো ফাইল পাওয়া যায়নি",
"No files uploaded yet": "এখনও কোনো ফাইল আপলোড করা হয়নি",
"files": "ফাইল",
"Page {{current}} of {{total}}": "{{total}}টির মধ্যে {{current}} পৃষ্ঠা",
"Are you sure to delete {{count}} selected file(s)?_one": "আপনি কি নিশ্চিত যে {{count}}টি নির্বাচিত ফাইল মুছতে চান?",
"Are you sure to delete {{count}} selected file(s)?_other": "আপনি কি নিশ্চিত যে {{count}}টি নির্বাচিত ফাইল মুছতে চান?",
"Cloud Storage Usage": "ক্লাউড স্টোরেজ ব্যবহৃত"
}
@@ -724,5 +724,31 @@
"Enter {{terms}}": "{{terms}} ལ་འགྲོ།",
"No search results found": "འཚོལ་ཞིབ་རྫོགས་མ་ཐུབ།",
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS འཕྲིན་འདེམས་བྱས་མ་ཐུབ།: {{status}} {{statusText}}",
"Search in {{title}}": "{{title}} ནང་འཚོལ།"
"Search in {{title}}": "{{title}} ནང་འཚོལ།",
"Manage Storage": "སྣོད་གསོག་དོ་དམ་བྱེད་པ",
"Failed to load files": "ཡིག་ཆ་སྣོན་པ་ཕམ་པ",
"Deleted {{count}} file(s)_other": "ཡིག་ཆ་ {{count}} བསུབས་ཟིན་པ",
"Failed to delete {{count}} file(s)_other": "ཡིག་ཆ་ {{count}} བསུབས་པ་ཕམ་པ",
"Failed to delete files": "ཡིག་ཆ་བསུབས་པ་ཕམ་པ",
"Total Files": "ཡིག་ཆ་ཡོངས་བསྡོམས",
"Total Size": "ཆེས་ཆེར་ཆེ་ཆུང",
"Quota": "ཁུལ་ཚད",
"Used": "ལག་ལེན་བྱས་ཟིན་པ",
"Files": "ཡིག་ཆ",
"Search files...": "ཡིག་ཆ་འཚོལ...",
"Newest First": "གསར་ཤོས་སྔོན་དུ",
"Oldest First": "རྙིང་ཤོས་སྔོན་དུ",
"Largest First": "ཆེ་ཤོས་སྔོན་དུ",
"Smallest First": "ཆུང་ཤོས་སྔོན་དུ",
"Name A-Z": "མིང་ A-Z",
"Name Z-A": "མིང་ Z-A",
"{{count}} selected_other": "{{count}} ཡིག་ཆ་འདེམས་ཟིན་པ",
"Delete Selected": "འདེམས་པ་བསུབས་པ",
"Created": "སྤེལ་བྱས་ཟིན་པ",
"No files found": "ཡིག་ཆ་མ་རྙེད་པ",
"No files uploaded yet": "ད་ཚུན་ཡིག་ཆ་སྣོན་མི་འདུག",
"files": "ཡིག་ཆ",
"Page {{current}} of {{total}}": "ཤོག་ངོས་ {{total}} ནས་ {{current}}",
"Are you sure to delete {{count}} selected file(s)?_other": "ཁྱེད་ཀྱིས་འདེམས་པའི་ཡིག་ཆ་ {{count}} བསུབས་དགོས་པ་ངེས་ཡིན་ན?",
"Cloud Storage Usage": "སྤྲིན་གནས་སྣོད་གསོག་ལུས་སྐོར།"
}
@@ -729,5 +729,35 @@
"Enter {{terms}}": "Gib {{terms}} ein",
"No search results found": "Keine Suchergebnisse gefunden",
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS-Feed konnte nicht geladen werden: {{status}} {{statusText}}",
"Search in {{title}}": "Suche in {{title}}"
"Search in {{title}}": "Suche in {{title}}",
"Manage Storage": "Speicher verwalten",
"Failed to load files": "Dateien konnten nicht geladen werden",
"Deleted {{count}} file(s)_one": "{{count}} Datei gelöscht",
"Deleted {{count}} file(s)_other": "{{count}} Dateien gelöscht",
"Failed to delete {{count}} file(s)_one": "Löschen von {{count}} Datei fehlgeschlagen",
"Failed to delete {{count}} file(s)_other": "Löschen von {{count}} Dateien fehlgeschlagen",
"Failed to delete files": "Dateien konnten nicht gelöscht werden",
"Total Files": "Gesamtdateien",
"Total Size": "Gesamtgröße",
"Quota": "Kontingent",
"Used": "Verwendet",
"Files": "Dateien",
"Search files...": "Dateien suchen...",
"Newest First": "Neueste zuerst",
"Oldest First": "Älteste zuerst",
"Largest First": "Größte zuerst",
"Smallest First": "Kleinste zuerst",
"Name A-Z": "Name AZ",
"Name Z-A": "Name ZA",
"{{count}} selected_one": "{{count}} ausgewählt",
"{{count}} selected_other": "{{count}} ausgewählt",
"Delete Selected": "Ausgewählte löschen",
"Created": "Erstellt",
"No files found": "Keine Dateien gefunden",
"No files uploaded yet": "Noch keine Dateien hochgeladen",
"files": "Dateien",
"Page {{current}} of {{total}}": "Seite {{current}} von {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Möchten Sie {{count}} ausgewählte Datei wirklich löschen?",
"Are you sure to delete {{count}} selected file(s)?_other": "Möchten Sie {{count}} ausgewählte Dateien wirklich löschen?",
"Cloud Storage Usage": "Cloud-Speichernutzung"
}
@@ -729,5 +729,35 @@
"Enter {{terms}}": "Εισαγάγετε {{terms}}",
"No search results found": "Δεν βρέθηκαν αποτελέσματα αναζήτησης",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Αποτυχία φόρτωσης ροής OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Αναζήτηση στο {{title}}"
"Search in {{title}}": "Αναζήτηση στο {{title}}",
"Manage Storage": "Διαχείριση αποθήκευσης",
"Failed to load files": "Αποτυχία φόρτωσης αρχείων",
"Deleted {{count}} file(s)_one": "Διαγράφηκε {{count}} αρχείο",
"Deleted {{count}} file(s)_other": "Διαγράφηκαν {{count}} αρχεία",
"Failed to delete {{count}} file(s)_one": "Αποτυχία διαγραφής {{count}} αρχείου",
"Failed to delete {{count}} file(s)_other": "Αποτυχία διαγραφής {{count}} αρχείων",
"Failed to delete files": "Αποτυχία διαγραφής αρχείων",
"Total Files": "Σύνολο αρχείων",
"Total Size": "Συνολικό μέγεθος",
"Quota": "Όριο",
"Used": "Χρησιμοποιήθηκε",
"Files": "Αρχεία",
"Search files...": "Αναζήτηση αρχείων...",
"Newest First": "Νεότερα πρώτα",
"Oldest First": "Παλαιότερα πρώτα",
"Largest First": "Μεγαλύτερα πρώτα",
"Smallest First": "Μικρότερα πρώτα",
"Name A-Z": "Όνομα AZ",
"Name Z-A": "Όνομα ZA",
"{{count}} selected_one": "{{count}} επιλεγμένο",
"{{count}} selected_other": "{{count}} επιλεγμένα",
"Delete Selected": "Διαγραφή επιλεγμένων",
"Created": "Δημιουργήθηκε",
"No files found": "Δεν βρέθηκαν αρχεία",
"No files uploaded yet": "Δεν έχουν ανέβει αρχεία ακόμη",
"files": "αρχεία",
"Page {{current}} of {{total}}": "Σελίδα {{current}} από {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Σίγουρα θέλετε να διαγράψετε {{count}} επιλεγμένο αρχείο;",
"Are you sure to delete {{count}} selected file(s)?_other": "Σίγουρα θέλετε να διαγράψετε {{count}} επιλεγμένα αρχεία;",
"Cloud Storage Usage": "Χρήση αποθήκευσης cloud"
}
@@ -9,5 +9,13 @@
"Search in {{count}} Book(s)..._one": "Search in {{count}} book...",
"Search in {{count}} Book(s)..._other": "Search in {{count}} books...",
"{{count}} pages left in chapter_one": "{{count}} page left in chapter",
"{{count}} pages left in chapter_other": "{{count}} pages left in chapter"
"{{count}} pages left in chapter_other": "{{count}} pages left in chapter",
"Deleted {{count}} file(s)_one": "Deleted {{count}} file",
"Deleted {{count}} file(s)_other": "Deleted {{count}} files",
"Failed to delete {{count}} file(s)_one": "Failed to delete {{count}} file",
"Failed to delete {{count}} file(s)_other": "Failed to delete {{count}} files",
"{{count}} selected_one": "{{count}} selected",
"{{count}} selected_other": "{{count}} selected",
"Are you sure to delete {{count}} selected file(s)?_one": "Are you sure to delete {{count}} selected file?",
"Are you sure to delete {{count}} selected file(s)?_other": "Are you sure to delete {{count}} selected files?"
}
@@ -734,5 +734,39 @@
"Enter {{terms}}": "Ingrese {{terms}}",
"No search results found": "No se encontraron resultados de búsqueda",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Error al cargar el feed OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Buscar en {{title}}"
"Search in {{title}}": "Buscar en {{title}}",
"Manage Storage": "Administrar almacenamiento",
"Failed to load files": "Error al cargar los archivos",
"Deleted {{count}} file(s)_one": "Se eliminó {{count}} archivo",
"Deleted {{count}} file(s)_many": "Se eliminaron {{count}} archivos",
"Deleted {{count}} file(s)_other": "Se eliminaron {{count}} archivos",
"Failed to delete {{count}} file(s)_one": "Error al eliminar {{count}} archivo",
"Failed to delete {{count}} file(s)_many": "Error al eliminar {{count}} archivos",
"Failed to delete {{count}} file(s)_other": "Error al eliminar {{count}} archivos",
"Failed to delete files": "Error al eliminar los archivos",
"Total Files": "Total de archivos",
"Total Size": "Tamaño total",
"Quota": "Cuota",
"Used": "Usado",
"Files": "Archivos",
"Search files...": "Buscar archivos...",
"Newest First": "Más nuevos primero",
"Oldest First": "Más antiguos primero",
"Largest First": "Más grandes primero",
"Smallest First": "Más pequeños primero",
"Name A-Z": "Nombre AZ",
"Name Z-A": "Nombre ZA",
"{{count}} selected_one": "{{count}} seleccionado",
"{{count}} selected_many": "{{count}} seleccionados",
"{{count}} selected_other": "{{count}} seleccionados",
"Delete Selected": "Eliminar seleccionados",
"Created": "Creado",
"No files found": "No se encontraron archivos",
"No files uploaded yet": "Aún no se han subido archivos",
"files": "archivos",
"Page {{current}} of {{total}}": "Página {{current}} de {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "¿Seguro que deseas eliminar {{count}} archivo seleccionado?",
"Are you sure to delete {{count}} selected file(s)?_many": "¿Seguro que deseas eliminar {{count}} archivos seleccionados?",
"Are you sure to delete {{count}} selected file(s)?_other": "¿Seguro que deseas eliminar {{count}} archivos seleccionados?",
"Cloud Storage Usage": "Uso de almacenamiento en la nube"
}
@@ -729,5 +729,35 @@
"Enter {{terms}}": "وارد کردن {{terms}}",
"No search results found": "هیچ نتیجه‌ای یافت نشد",
"Failed to load OPDS feed: {{status}} {{statusText}}": "بارگذاری فید OPDS ناموفق بود: {{status}} {{statusText}}",
"Search in {{title}}": "جستجو در {{title}}"
"Search in {{title}}": "جستجو در {{title}}",
"Manage Storage": "مدیریت فضای ذخیره‌سازی",
"Failed to load files": "بارگیری فایل‌ها ناموفق بود",
"Deleted {{count}} file(s)_one": "{{count}} فایل حذف شد",
"Deleted {{count}} file(s)_other": "{{count}} فایل حذف شدند",
"Failed to delete {{count}} file(s)_one": "حذف {{count}} فایل ناموفق بود",
"Failed to delete {{count}} file(s)_other": "حذف {{count}} فایل ناموفق بود",
"Failed to delete files": "حذف فایل‌ها ناموفق بود",
"Total Files": "کل فایل‌ها",
"Total Size": "اندازه کل",
"Quota": "سهمیه",
"Used": "استفاده‌شده",
"Files": "فایل‌ها",
"Search files...": "جستجوی فایل‌ها...",
"Newest First": "جدیدترین‌ها",
"Oldest First": "قدیمی‌ترین‌ها",
"Largest First": "بزرگ‌ترین‌ها",
"Smallest First": "کوچک‌ترین‌ها",
"Name A-Z": "نام AZ",
"Name Z-A": "نام ZA",
"{{count}} selected_one": "{{count}} مورد انتخاب شد",
"{{count}} selected_other": "{{count}} مورد انتخاب شدند",
"Delete Selected": "حذف موارد انتخاب‌شده",
"Created": "ایجاد شده",
"No files found": "هیچ فایلی پیدا نشد",
"No files uploaded yet": "هنوز فایلی بارگذاری نشده است",
"files": "فایل‌ها",
"Page {{current}} of {{total}}": "صفحه {{current}} از {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "آیا از حذف {{count}} فایل انتخاب‌شده مطمئن هستید؟",
"Are you sure to delete {{count}} selected file(s)?_other": "آیا از حذف {{count}} فایل انتخاب‌شده مطمئن هستید؟",
"Cloud Storage Usage": "استفاده از فضای ذخیره‌سازی ابری"
}
@@ -734,5 +734,39 @@
"Enter {{terms}}": "Entrez {{terms}}",
"No search results found": "Aucun résultat trouvé",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Échec du chargement du flux OPDS : {{status}} {{statusText}}",
"Search in {{title}}": "Rechercher dans {{title}}"
"Search in {{title}}": "Rechercher dans {{title}}",
"Manage Storage": "Gérer le stockage",
"Failed to load files": "Échec du chargement des fichiers",
"Deleted {{count}} file(s)_one": "{{count}} fichier supprimé",
"Deleted {{count}} file(s)_many": "{{count}} fichiers supprimés",
"Deleted {{count}} file(s)_other": "{{count}} fichiers supprimés",
"Failed to delete {{count}} file(s)_one": "Échec de la suppression de {{count}} fichier",
"Failed to delete {{count}} file(s)_many": "Échec de la suppression de {{count}} fichiers",
"Failed to delete {{count}} file(s)_other": "Échec de la suppression de {{count}} fichiers",
"Failed to delete files": "Échec de la suppression des fichiers",
"Total Files": "Nombre total de fichiers",
"Total Size": "Taille totale",
"Quota": "Quota",
"Used": "Utilisé",
"Files": "Fichiers",
"Search files...": "Rechercher des fichiers...",
"Newest First": "Les plus récents",
"Oldest First": "Les plus anciens",
"Largest First": "Les plus volumineux",
"Smallest First": "Les moins volumineux",
"Name A-Z": "Nom A-Z",
"Name Z-A": "Nom Z-A",
"{{count}} selected_one": "{{count}} sélectionné",
"{{count}} selected_many": "{{count}} sélectionnés",
"{{count}} selected_other": "{{count}} sélectionnés",
"Delete Selected": "Supprimer la sélection",
"Created": "Créé",
"No files found": "Aucun fichier trouvé",
"No files uploaded yet": "Aucun fichier téléchargé pour le moment",
"files": "fichiers",
"Page {{current}} of {{total}}": "Page {{current}} sur {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Voulez-vous vraiment supprimer {{count}} fichier sélectionné ?",
"Are you sure to delete {{count}} selected file(s)?_many": "Voulez-vous vraiment supprimer {{count}} fichiers sélectionnés ?",
"Are you sure to delete {{count}} selected file(s)?_other": "Voulez-vous vraiment supprimer {{count}} fichiers sélectionnés ?",
"Cloud Storage Usage": "Utilisation du stockage cloud"
}
@@ -729,5 +729,35 @@
"Enter {{terms}}": "{{terms}} दर्ज करें",
"No search results found": "कोई खोज परिणाम नहीं मिला",
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS फ़ीड लोड करने में विफल: {{status}} {{statusText}}",
"Search in {{title}}": "{{title}} में खोजें"
"Search in {{title}}": "{{title}} में खोजें",
"Manage Storage": "स्टोरेज प्रबंधित करें",
"Failed to load files": "फ़ाइलें लोड करने में विफल",
"Deleted {{count}} file(s)_one": "{{count}} फ़ाइल हटाई गई",
"Deleted {{count}} file(s)_other": "{{count}} फ़ाइलें हटाई गईं",
"Failed to delete {{count}} file(s)_one": "{{count}} फ़ाइल हटाने में विफल",
"Failed to delete {{count}} file(s)_other": "{{count}} फ़ाइलें हटाने में विफल",
"Failed to delete files": "फ़ाइलें हटाने में विफल",
"Total Files": "कुल फ़ाइलें",
"Total Size": "कुल आकार",
"Quota": "कोटा",
"Used": "उपयोग किया गया",
"Files": "फ़ाइलें",
"Search files...": "फ़ाइलें खोजें...",
"Newest First": "नवीनतम पहले",
"Oldest First": "सबसे पुराने पहले",
"Largest First": "सबसे बड़ी पहले",
"Smallest First": "सबसे छोटी पहले",
"Name A-Z": "नाम A-Z",
"Name Z-A": "नाम Z-A",
"{{count}} selected_one": "{{count}} चयनित",
"{{count}} selected_other": "{{count}} चयनित",
"Delete Selected": "चयनित हटाएँ",
"Created": "बनाई गई",
"No files found": "कोई फ़ाइल नहीं मिली",
"No files uploaded yet": "अभी तक कोई फ़ाइल अपलोड नहीं की गई",
"files": "फ़ाइलें",
"Page {{current}} of {{total}}": "पृष्ठ {{current}} / {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "क्या आप वाकई {{count}} चयनित फ़ाइल हटाना चाहते हैं?",
"Are you sure to delete {{count}} selected file(s)?_other": "क्या आप वाकई {{count}} चयनित फ़ाइलें हटाना चाहते हैं?",
"Cloud Storage Usage": "क्लाउड स्टोरेज उपयोग"
}
@@ -724,5 +724,31 @@
"Enter {{terms}}": "Masukkan {{terms}}",
"No search results found": "Tidak ada hasil pencarian ditemukan",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Gagal memuat feed OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Cari di {{title}}"
"Search in {{title}}": "Cari di {{title}}",
"Manage Storage": "Kelola Penyimpanan",
"Failed to load files": "Gagal memuat file",
"Deleted {{count}} file(s)_other": "{{count}} file dihapus",
"Failed to delete {{count}} file(s)_other": "Gagal menghapus {{count}} file",
"Failed to delete files": "Gagal menghapus file",
"Total Files": "Total File",
"Total Size": "Total Ukuran",
"Quota": "Kuota",
"Used": "Digunakan",
"Files": "File",
"Search files...": "Cari file...",
"Newest First": "Terbaru",
"Oldest First": "Terlama",
"Largest First": "Terbesar",
"Smallest First": "Terkecil",
"Name A-Z": "Nama A-Z",
"Name Z-A": "Nama Z-A",
"{{count}} selected_other": "{{count}} dipilih",
"Delete Selected": "Hapus yang Dipilih",
"Created": "Dibuat",
"No files found": "Tidak ada file ditemukan",
"No files uploaded yet": "Belum ada file diunggah",
"files": "file",
"Page {{current}} of {{total}}": "Halaman {{current}} dari {{total}}",
"Are you sure to delete {{count}} selected file(s)?_other": "Yakin ingin menghapus {{count}} file yang dipilih?",
"Cloud Storage Usage": "Penggunaan Penyimpanan Cloud"
}
@@ -734,5 +734,39 @@
"Enter {{terms}}": "Inserisci {{terms}}",
"No search results found": "Nessun risultato di ricerca trovato",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Impossibile caricare il feed OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Cerca in {{title}}"
"Search in {{title}}": "Cerca in {{title}}",
"Manage Storage": "Gestisci archiviazione",
"Failed to load files": "Impossibile caricare i file",
"Deleted {{count}} file(s)_one": "È stato eliminato {{count}} file",
"Deleted {{count}} file(s)_many": "Sono stati eliminati {{count}} file",
"Deleted {{count}} file(s)_other": "Sono stati eliminati {{count}} file",
"Failed to delete {{count}} file(s)_one": "Impossibile eliminare {{count}} file",
"Failed to delete {{count}} file(s)_many": "Impossibile eliminare {{count}} file",
"Failed to delete {{count}} file(s)_other": "Impossibile eliminare {{count}} file",
"Failed to delete files": "Impossibile eliminare i file",
"Total Files": "File totali",
"Total Size": "Dimensione totale",
"Quota": "Quota",
"Used": "Utilizzato",
"Files": "File",
"Search files...": "Cerca file...",
"Newest First": "Più recenti",
"Oldest First": "Più vecchi",
"Largest First": "Più grandi",
"Smallest First": "Più piccoli",
"Name A-Z": "Nome A-Z",
"Name Z-A": "Nome Z-A",
"{{count}} selected_one": "{{count}} selezionato",
"{{count}} selected_many": "{{count}} selezionati",
"{{count}} selected_other": "{{count}} selezionati",
"Delete Selected": "Elimina selezionati",
"Created": "Creato",
"No files found": "Nessun file trovato",
"No files uploaded yet": "Nessun file caricato",
"files": "file",
"Page {{current}} of {{total}}": "Pagina {{current}} di {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Sei sicuro di voler eliminare {{count}} file selezionato?",
"Are you sure to delete {{count}} selected file(s)?_many": "Sei sicuro di voler eliminare {{count}} file selezionati?",
"Are you sure to delete {{count}} selected file(s)?_other": "Sei sicuro di voler eliminare {{count}} file selezionati?",
"Cloud Storage Usage": "Utilizzo archiviazione cloud"
}
@@ -724,5 +724,31 @@
"Enter {{terms}}": "{{terms}}を入力してください",
"No search results found": "検索結果が見つかりません",
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDSフィードの読み込みに失敗しました: {{status}} {{statusText}}",
"Search in {{title}}": "{{title}}内を検索"
"Search in {{title}}": "{{title}}内を検索",
"Manage Storage": "ストレージ管理",
"Failed to load files": "ファイルの読み込みに失敗しました",
"Deleted {{count}} file(s)_other": "{{count}} 件のファイルを削除しました",
"Failed to delete {{count}} file(s)_other": "{{count}} 件のファイルの削除に失敗しました",
"Failed to delete files": "ファイルの削除に失敗しました",
"Total Files": "ファイル合計",
"Total Size": "合計サイズ",
"Quota": "容量制限",
"Used": "使用済み",
"Files": "ファイル",
"Search files...": "ファイルを検索...",
"Newest First": "新しい順",
"Oldest First": "古い順",
"Largest First": "大きい順",
"Smallest First": "小さい順",
"Name A-Z": "名前 A-Z",
"Name Z-A": "名前 Z-A",
"{{count}} selected_other": "{{count}} 件選択済み",
"Delete Selected": "選択した項目を削除",
"Created": "作成日",
"No files found": "ファイルが見つかりません",
"No files uploaded yet": "まだファイルがアップロードされていません",
"files": "ファイル",
"Page {{current}} of {{total}}": "ページ {{current}} / {{total}}",
"Are you sure to delete {{count}} selected file(s)?_other": "選択した {{count}} 件のファイルを削除してもよろしいですか?",
"Cloud Storage Usage": "クラウドストレージ使用量"
}
@@ -724,5 +724,31 @@
"Enter {{terms}}": "{{terms}} 입력",
"No search results found": "검색 결과가 없습니다",
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS 피드를 불러오지 못했습니다: {{status}} {{statusText}}",
"Search in {{title}}": "{{title}}에서 검색"
"Search in {{title}}": "{{title}}에서 검색",
"Manage Storage": "저장소 관리",
"Failed to load files": "파일 로드 실패",
"Deleted {{count}} file(s)_other": "{{count}}개의 파일이 삭제되었습니다",
"Failed to delete {{count}} file(s)_other": "{{count}}개의 파일 삭제 실패",
"Failed to delete files": "파일 삭제 실패",
"Total Files": "총 파일",
"Total Size": "총 용량",
"Quota": "쿼터",
"Used": "사용됨",
"Files": "파일",
"Search files...": "파일 검색...",
"Newest First": "최신순",
"Oldest First": "오래된순",
"Largest First": "큰 파일순",
"Smallest First": "작은 파일순",
"Name A-Z": "이름 A-Z",
"Name Z-A": "이름 Z-A",
"{{count}} selected_other": "{{count}}개 선택됨",
"Delete Selected": "선택 삭제",
"Created": "생성됨",
"No files found": "파일이 없습니다",
"No files uploaded yet": "아직 업로드된 파일이 없습니다",
"files": "파일",
"Page {{current}} of {{total}}": "페이지 {{current}} / {{total}}",
"Are you sure to delete {{count}} selected file(s)?_other": "선택한 {{count}}개의 파일을 삭제하시겠습니까?",
"Cloud Storage Usage": "클라우드 저장소 사용량"
}
@@ -724,5 +724,31 @@
"Enter {{terms}}": "Masukkan {{terms}}",
"No search results found": "Tiada hasil carian ditemui",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Gagal memuatkan suapan OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Cari dalam {{title}}"
"Search in {{title}}": "Cari dalam {{title}}",
"Manage Storage": "Urus Penyimpanan",
"Failed to load files": "Gagal memuatkan fail",
"Deleted {{count}} file(s)_other": "{{count}} fail telah dipadam",
"Failed to delete {{count}} file(s)_other": "Gagal memadam {{count}} fail",
"Failed to delete files": "Gagal memadam fail",
"Total Files": "Jumlah Fail",
"Total Size": "Jumlah Saiz",
"Quota": "Kuota",
"Used": "Digunakan",
"Files": "Fail",
"Search files...": "Cari fail...",
"Newest First": "Terbaru dahulu",
"Oldest First": "Tertua dahulu",
"Largest First": "Terbesar dahulu",
"Smallest First": "Terkecil dahulu",
"Name A-Z": "Nama A-Z",
"Name Z-A": "Nama Z-A",
"{{count}} selected_other": "{{count}} dipilih",
"Delete Selected": "Padam Dipilih",
"Created": "Dicipta",
"No files found": "Tiada fail dijumpai",
"No files uploaded yet": "Belum ada fail dimuat naik",
"files": "fail",
"Page {{current}} of {{total}}": "Halaman {{current}} daripada {{total}}",
"Are you sure to delete {{count}} selected file(s)?_other": "Adakah anda pasti mahu memadam {{count}} fail yang dipilih?",
"Cloud Storage Usage": "Penggunaan Storan Awan"
}
@@ -729,5 +729,35 @@
"Enter {{terms}}": "Voer {{terms}} in",
"No search results found": "Geen zoekresultaten gevonden",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Het laden van de OPDS-feed is mislukt: {{status}} {{statusText}}",
"Search in {{title}}": "Zoeken in {{title}}"
"Search in {{title}}": "Zoeken in {{title}}",
"Manage Storage": "Opslag beheren",
"Failed to load files": "Bestanden laden mislukt",
"Deleted {{count}} file(s)_one": "{{count}} bestand verwijderd",
"Deleted {{count}} file(s)_other": "{{count}} bestanden verwijderd",
"Failed to delete {{count}} file(s)_one": "Kon {{count}} bestand niet verwijderen",
"Failed to delete {{count}} file(s)_other": "Kon {{count}} bestanden niet verwijderen",
"Failed to delete files": "Bestanden verwijderen mislukt",
"Total Files": "Totaal aantal bestanden",
"Total Size": "Totale grootte",
"Quota": "Quota",
"Used": "Gebruikt",
"Files": "Bestanden",
"Search files...": "Bestanden zoeken...",
"Newest First": "Nieuwste eerst",
"Oldest First": "Oudste eerst",
"Largest First": "Grootste eerst",
"Smallest First": "Kleinste eerst",
"Name A-Z": "Naam A-Z",
"Name Z-A": "Naam Z-A",
"{{count}} selected_one": "{{count}} geselecteerd",
"{{count}} selected_other": "{{count}} geselecteerd",
"Delete Selected": "Verwijder geselecteerde",
"Created": "Gemaakt",
"No files found": "Geen bestanden gevonden",
"No files uploaded yet": "Nog geen bestanden geüpload",
"files": "bestanden",
"Page {{current}} of {{total}}": "Pagina {{current}} van {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Weet je zeker dat je {{count}} geselecteerd bestand wilt verwijderen?",
"Are you sure to delete {{count}} selected file(s)?_other": "Weet je zeker dat je {{count}} geselecteerde bestanden wilt verwijderen?",
"Cloud Storage Usage": "Cloudopslaggebruik"
}
@@ -739,5 +739,43 @@
"Enter {{terms}}": "Wprowadź {{terms}}",
"No search results found": "Nie znaleziono wyników wyszukiwania",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Nie udało się załadować kanału OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Szukaj w {{title}}"
"Search in {{title}}": "Szukaj w {{title}}",
"Manage Storage": "Zarządzaj pamięcią",
"Failed to load files": "Nie udało się wczytać plików",
"Deleted {{count}} file(s)_one": "Usunięto {{count}} plik",
"Deleted {{count}} file(s)_few": "Usunięto {{count}} pliki",
"Deleted {{count}} file(s)_many": "Usunięto {{count}} plików",
"Deleted {{count}} file(s)_other": "Usunięto {{count}} pliku",
"Failed to delete {{count}} file(s)_one": "Nie udało się usunąć {{count}} pliku",
"Failed to delete {{count}} file(s)_few": "Nie udało się usunąć {{count}} plików",
"Failed to delete {{count}} file(s)_many": "Nie udało się usunąć {{count}} plików",
"Failed to delete {{count}} file(s)_other": "Nie udało się usunąć {{count}} pliku",
"Failed to delete files": "Nie udało się usunąć plików",
"Total Files": "Łączna liczba plików",
"Total Size": "Łączny rozmiar",
"Quota": "Limit",
"Used": "Użyto",
"Files": "Pliki",
"Search files...": "Szukaj plików...",
"Newest First": "Najnowsze najpierw",
"Oldest First": "Najstarsze najpierw",
"Largest First": "Największe najpierw",
"Smallest First": "Najmniejsze najpierw",
"Name A-Z": "Nazwa A-Z",
"Name Z-A": "Nazwa Z-A",
"{{count}} selected_one": "Wybrano {{count}} plik",
"{{count}} selected_few": "Wybrano {{count}} pliki",
"{{count}} selected_many": "Wybrano {{count}} plików",
"{{count}} selected_other": "Wybrano {{count}} pliku",
"Delete Selected": "Usuń wybrane",
"Created": "Utworzono",
"No files found": "Nie znaleziono plików",
"No files uploaded yet": "Nie przesłano jeszcze żadnych plików",
"files": "pliki",
"Page {{current}} of {{total}}": "Strona {{current}} z {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Czy na pewno chcesz usunąć {{count}} wybrany plik?",
"Are you sure to delete {{count}} selected file(s)?_few": "Czy na pewno chcesz usunąć {{count}} wybrane pliki?",
"Are you sure to delete {{count}} selected file(s)?_many": "Czy na pewno chcesz usunąć {{count}} wybranych plików?",
"Are you sure to delete {{count}} selected file(s)?_other": "Czy na pewno chcesz usunąć {{count}} wybrany plik?",
"Cloud Storage Usage": "Użycie pamięci w chmurze"
}
@@ -734,5 +734,39 @@
"Enter {{terms}}": "Insira {{terms}}",
"No search results found": "Nenhum resultado encontrado",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Falha ao carregar o feed OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Pesquisar em {{title}}"
"Search in {{title}}": "Pesquisar em {{title}}",
"Manage Storage": "Gerir Armazenamento",
"Failed to load files": "Falha ao carregar arquivos",
"Deleted {{count}} file(s)_one": "Apagado {{count}} ficheiro",
"Deleted {{count}} file(s)_many": "Apagados {{count}} ficheiros",
"Deleted {{count}} file(s)_other": "Apagado {{count}} ficheiro",
"Failed to delete {{count}} file(s)_one": "Falha ao apagar {{count}} ficheiro",
"Failed to delete {{count}} file(s)_many": "Falha ao apagar {{count}} ficheiros",
"Failed to delete {{count}} file(s)_other": "Falha ao apagar {{count}} ficheiro",
"Failed to delete files": "Falha ao apagar arquivos",
"Total Files": "Total de Arquivos",
"Total Size": "Tamanho Total",
"Quota": "Quota",
"Used": "Usado",
"Files": "Arquivos",
"Search files...": "Procurar arquivos...",
"Newest First": "Mais Recentes Primeiro",
"Oldest First": "Mais Antigos Primeiro",
"Largest First": "Maiores Primeiro",
"Smallest First": "Menores Primeiro",
"Name A-Z": "Nome A-Z",
"Name Z-A": "Nome Z-A",
"{{count}} selected_one": "{{count}} selecionado",
"{{count}} selected_many": "{{count}} selecionados",
"{{count}} selected_other": "{{count}} selecionado",
"Delete Selected": "Apagar Selecionados",
"Created": "Criado",
"No files found": "Nenhum arquivo encontrado",
"No files uploaded yet": "Nenhum arquivo enviado ainda",
"files": "arquivos",
"Page {{current}} of {{total}}": "Página {{current}} de {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Tem certeza de que deseja apagar {{count}} ficheiro selecionado?",
"Are you sure to delete {{count}} selected file(s)?_many": "Tem certeza de que deseja apagar {{count}} ficheiros selecionados?",
"Are you sure to delete {{count}} selected file(s)?_other": "Tem certeza de que deseja apagar {{count}} ficheiro selecionado?",
"Cloud Storage Usage": "Uso de Armazenamento na Nuvem"
}
@@ -739,5 +739,43 @@
"Enter {{terms}}": "Введите {{terms}}",
"No search results found": "Результаты поиска не найдены",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Не удалось загрузить ленту OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Поиск в {{title}}"
"Search in {{title}}": "Поиск в {{title}}",
"Manage Storage": "Управление хранилищем",
"Failed to load files": "Не удалось загрузить файлы",
"Deleted {{count}} file(s)_one": "Удалён {{count}} файл",
"Deleted {{count}} file(s)_few": "Удалено {{count}} файла",
"Deleted {{count}} file(s)_many": "Удалено {{count}} файлов",
"Deleted {{count}} file(s)_other": "Удалён {{count}} файл",
"Failed to delete {{count}} file(s)_one": "Не удалось удалить {{count}} файл",
"Failed to delete {{count}} file(s)_few": "Не удалось удалить {{count}} файла",
"Failed to delete {{count}} file(s)_many": "Не удалось удалить {{count}} файлов",
"Failed to delete {{count}} file(s)_other": "Не удалось удалить {{count}} файл",
"Failed to delete files": "Не удалось удалить файлы",
"Total Files": "Всего файлов",
"Total Size": "Общий размер",
"Quota": "Квота",
"Used": "Использовано",
"Files": "Файлы",
"Search files...": "Поиск файлов...",
"Newest First": "Сначала новые",
"Oldest First": "Сначала старые",
"Largest First": "Сначала крупные",
"Smallest First": "Сначала мелкие",
"Name A-Z": "Имя A-Z",
"Name Z-A": "Имя Z-A",
"{{count}} selected_one": "{{count}} выбранный",
"{{count}} selected_few": "{{count}} выбранных",
"{{count}} selected_many": "{{count}} выбранных",
"{{count}} selected_other": "{{count}} выбранный",
"Delete Selected": "Удалить выбранные",
"Created": "Создано",
"No files found": "Файлы не найдены",
"No files uploaded yet": "Файлы ещё не загружены",
"files": "файлы",
"Page {{current}} of {{total}}": "Страница {{current}} из {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Вы уверены, что хотите удалить {{count}} выбранный файл?",
"Are you sure to delete {{count}} selected file(s)?_few": "Вы уверены, что хотите удалить {{count}} выбранных файла?",
"Are you sure to delete {{count}} selected file(s)?_many": "Вы уверены, что хотите удалить {{count}} выбранных файлов?",
"Are you sure to delete {{count}} selected file(s)?_other": "Вы уверены, что хотите удалить {{count}} выбранный файл?",
"Cloud Storage Usage": "Использование облачного хранилища"
}
@@ -729,5 +729,35 @@
"Enter {{terms}}": "{{terms}} ඇතුළත් කරන්න",
"No search results found": "සෙවුම් ප්‍රතිඵල නොමැත",
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS ආහාරය පූරණය කිරීමට අසමත් විය: {{status}} {{statusText}}",
"Search in {{title}}": "{{title}} තුළ සෙවීම"
"Search in {{title}}": "{{title}} තුළ සෙවීම",
"Manage Storage": "ගබඩා කළමනාකරණය කරන්න",
"Failed to load files": "ගොනු උඩුගත කිරීමට නොහැකි විය",
"Deleted {{count}} file(s)_one": "ගොනුව මකා දමා ඇත",
"Deleted {{count}} file(s)_other": "ගොනු මකා දමා ඇත",
"Failed to delete {{count}} file(s)_one": "ගොනුව මකා දැමිය නොහැකි විය",
"Failed to delete {{count}} file(s)_other": "ගොනු මකා දැමිය නොහැකි විය",
"Failed to delete files": "ගොනු මකා දැමිය නොහැකි විය",
"Total Files": "මුළු ගොනු",
"Total Size": "මුළු ප්‍රමාණය",
"Quota": "කොටස",
"Used": "භාවිතා කරන ලදී",
"Files": "ගොනු",
"Search files...": "ගොනු සොයන්න...",
"Newest First": "නවතම පළමුව",
"Oldest First": "පැරණිම පළමුව",
"Largest First": "විශාලතම පළමුව",
"Smallest First": "කුඩාතම පළමුව",
"Name A-Z": "නම A-Z",
"Name Z-A": "නම Z-A",
"{{count}} selected_one": "තෝරාගත් ගොනුව",
"{{count}} selected_other": "තෝරාගත් ගොනු",
"Delete Selected": "තෝරාගත් මකා දමන්න",
"Created": "තනන ලදී",
"No files found": "ගොනු හමු නොවීය",
"No files uploaded yet": "ගොනු තවම උඩුගත කර නැත",
"files": "ගොනු",
"Page {{current}} of {{total}}": "පිටුව {{current}} / {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "තෝරාගත් ගොනුව මකන්නට විශ්වාසද?",
"Are you sure to delete {{count}} selected file(s)?_other": "තෝරාගත් ගොනු මකන්නට විශ්වාසද?",
"Cloud Storage Usage": "කලාප ගබඩා භාවිතය"
}
@@ -729,5 +729,35 @@
"Enter {{terms}}": "Ange {{terms}}",
"No search results found": "Inga sökresultat hittades",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Misslyckades med att ladda OPDS-flöde: {{status}} {{statusText}}",
"Search in {{title}}": "Sök i {{title}}"
"Search in {{title}}": "Sök i {{title}}",
"Manage Storage": "Hantera lagring",
"Failed to load files": "Misslyckades att ladda filer",
"Deleted {{count}} file(s)_one": "Raderade filen",
"Deleted {{count}} file(s)_other": "Raderade filerna",
"Failed to delete {{count}} file(s)_one": "Kunde inte radera filen",
"Failed to delete {{count}} file(s)_other": "Kunde inte radera filerna",
"Failed to delete files": "Kunde inte radera filer",
"Total Files": "Totalt antal filer",
"Total Size": "Total storlek",
"Quota": "Kvot",
"Used": "Använd",
"Files": "Filer",
"Search files...": "Sök filer...",
"Newest First": "Nyast först",
"Oldest First": "Äldst först",
"Largest First": "Störst först",
"Smallest First": "Minskst först",
"Name A-Z": "Namn A-Ö",
"Name Z-A": "Namn Ö-A",
"{{count}} selected_one": "Vald fil",
"{{count}} selected_other": "Valda filer",
"Delete Selected": "Radera valda",
"Created": "Skapad",
"No files found": "Inga filer hittades",
"No files uploaded yet": "Inga filer har laddats upp än",
"files": "filer",
"Page {{current}} of {{total}}": "Sida {{current}} av {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Är du säker på att du vill radera filen?",
"Are you sure to delete {{count}} selected file(s)?_other": "Är du säker på att du vill radera filerna?",
"Cloud Storage Usage": "Användning av molnlagring"
}
@@ -729,5 +729,35 @@
"Enter {{terms}}": "{{terms}} உள்ளிடவும்",
"No search results found": "தேடல் முடிவுகள் இல்லை",
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS ஊட்டத்தை ஏற்ற முடியவில்லை: {{status}} {{statusText}}",
"Search in {{title}}": "{{title}} இல் தேடவும்"
"Search in {{title}}": "{{title}} இல் தேடவும்",
"Manage Storage": "சேமிப்பை நிர்வகி",
"Failed to load files": "கோப்புகளை ஏற்ற முடியவில்லை",
"Deleted {{count}} file(s)_one": "கோப்பு நீக்கப்பட்டது",
"Deleted {{count}} file(s)_other": "கோப்புகள் நீக்கப்பட்டன",
"Failed to delete {{count}} file(s)_one": "கோப்பை நீக்க முடியவில்லை",
"Failed to delete {{count}} file(s)_other": "கோப்புகளை நீக்க முடியவில்லை",
"Failed to delete files": "கோப்புகளை நீக்க முடியவில்லை",
"Total Files": "மொத்த கோப்புகள்",
"Total Size": "மொத்த அளவு",
"Quota": "கோட்டா",
"Used": "பயன்படுத்தப்பட்டது",
"Files": "கோப்புகள்",
"Search files...": "கோப்புகளைத் தேடு...",
"Newest First": "புதியவை முதலில்",
"Oldest First": "பழையவை முதலில்",
"Largest First": "பெரியவை முதலில்",
"Smallest First": "சிறியவை முதலில்",
"Name A-Z": "பெயர் A-ஆல்",
"Name Z-A": "பெயர் ஆல்-A",
"{{count}} selected_one": "தேர்ந்தெடுக்கப்பட்ட கோப்பு",
"{{count}} selected_other": "தேர்ந்தெடுக்கப்பட்ட கோப்புகள்",
"Delete Selected": "தேர்ந்தெடுத்தவை நீக்கு",
"Created": "உருவாக்கப்பட்டது",
"No files found": "கோப்புகள் இல்லை",
"No files uploaded yet": "இன்னும் எந்த கோப்பும் பதிவேற்றப்படவில்லை",
"files": "கோப்புகள்",
"Page {{current}} of {{total}}": "பக்கம் {{current}} / {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "இந்த கோப்பை நீக்க விரும்புகிறீர்களா?",
"Are you sure to delete {{count}} selected file(s)?_other": "இந்த கோப்புகளை நீக்க விரும்புகிறீர்களா?",
"Cloud Storage Usage": "மேக சேமிப்பு பயன்பாடு"
}
@@ -724,5 +724,31 @@
"Enter {{terms}}": "ป้อน {{terms}}",
"No search results found": "ไม่พบผลการค้นหา",
"Failed to load OPDS feed: {{status}} {{statusText}}": "ไม่สามารถโหลดฟีด OPDS ได้: {{status}} {{statusText}}",
"Search in {{title}}": "ค้นหาใน {{title}}"
"Search in {{title}}": "ค้นหาใน {{title}}",
"Manage Storage": "จัดการพื้นที่เก็บข้อมูล",
"Failed to load files": "ไม่สามารถโหลดไฟล์ได้",
"Deleted {{count}} file(s)_other": "ลบไฟล์เรียบร้อยแล้ว",
"Failed to delete {{count}} file(s)_other": "ลบไฟล์ไม่สำเร็จ",
"Failed to delete files": "ลบไฟล์ไม่สำเร็จ",
"Total Files": "จำนวนไฟล์ทั้งหมด",
"Total Size": "ขนาดรวมทั้งหมด",
"Quota": "โควต้า",
"Used": "ใช้ไปแล้ว",
"Files": "ไฟล์",
"Search files...": "ค้นหาไฟล์...",
"Newest First": "ใหม่ที่สุดก่อน",
"Oldest First": "เก่าที่สุดก่อน",
"Largest First": "ใหญ่ที่สุดก่อน",
"Smallest First": "เล็กที่สุดก่อน",
"Name A-Z": "ชื่อ A-ฮ",
"Name Z-A": "ชื่อ ฮ-A",
"{{count}} selected_other": "เลือกไฟล์แล้ว",
"Delete Selected": "ลบไฟล์ที่เลือก",
"Created": "สร้างเมื่อ",
"No files found": "ไม่พบไฟล์",
"No files uploaded yet": "ยังไม่มีการอัปโหลดไฟล์",
"files": "ไฟล์",
"Page {{current}} of {{total}}": "หน้า {{current}} จาก {{total}}",
"Are you sure to delete {{count}} selected file(s)?_other": "คุณแน่ใจหรือว่าต้องการลบไฟล์ที่เลือก?",
"Cloud Storage Usage": "การใช้งานพื้นที่เก็บข้อมูลคลาวด์"
}
@@ -729,5 +729,35 @@
"Enter {{terms}}": "{{terms}} girin",
"No search results found": "Arama sonucu bulunamadı",
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS beslemesi yüklenemedi: {{status}} {{statusText}}",
"Search in {{title}}": "{{title}} içinde ara"
"Search in {{title}}": "{{title}} içinde ara",
"Manage Storage": "Depolamayı Yönet",
"Failed to load files": "Dosyalar yüklenemedi",
"Deleted {{count}} file(s)_one": "{{count}} dosya silindi",
"Deleted {{count}} file(s)_other": "{{count}} dosya silindi",
"Failed to delete {{count}} file(s)_one": "{{count}} dosya silinemedi",
"Failed to delete {{count}} file(s)_other": "{{count}} dosya silinemedi",
"Failed to delete files": "Dosyalar silinemedi",
"Total Files": "Toplam Dosya",
"Total Size": "Toplam Boyut",
"Quota": "Kota",
"Used": "Kullanıldı",
"Files": "Dosyalar",
"Search files...": "Dosyaları ara...",
"Newest First": "En Yeni Önce",
"Oldest First": "En Eski Önce",
"Largest First": "En Büyük Önce",
"Smallest First": "En Küçük Önce",
"Name A-Z": "İsim A-Z",
"Name Z-A": "İsim Z-A",
"{{count}} selected_one": "{{count}} seçili dosya",
"{{count}} selected_other": "{{count}} seçili dosya",
"Delete Selected": "Seçilenleri Sil",
"Created": "Oluşturulma Tarihi",
"No files found": "Dosya bulunamadı",
"No files uploaded yet": "Henüz dosya yüklenmedi",
"files": "dosyalar",
"Page {{current}} of {{total}}": "{{total}} sayfa içinde {{current}}. sayfa",
"Are you sure to delete {{count}} selected file(s)?_one": "Seçilen {{count}} dosyayı silmek istediğinizden emin misiniz?",
"Are you sure to delete {{count}} selected file(s)?_other": "Seçilen {{count}} dosyayı silmek istediğinizden emin misiniz?",
"Cloud Storage Usage": "Bulut Depolama Kullanımı"
}
@@ -739,5 +739,43 @@
"Enter {{terms}}": "Введіть {{terms}}",
"No search results found": "Результатів пошуку не знайдено",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Не вдалося завантажити стрічку OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Пошук у {{title}}"
"Search in {{title}}": "Пошук у {{title}}",
"Manage Storage": "Керування сховищем",
"Failed to load files": "Не вдалося завантажити файли",
"Deleted {{count}} file(s)_one": "Видалено {{count}} файл",
"Deleted {{count}} file(s)_few": "Видалено {{count}} файли",
"Deleted {{count}} file(s)_many": "Видалено {{count}} файлів",
"Deleted {{count}} file(s)_other": "Видалено {{count}} файлів",
"Failed to delete {{count}} file(s)_one": "Не вдалося видалити {{count}} файл",
"Failed to delete {{count}} file(s)_few": "Не вдалося видалити {{count}} файли",
"Failed to delete {{count}} file(s)_many": "Не вдалося видалити {{count}} файлів",
"Failed to delete {{count}} file(s)_other": "Не вдалося видалити {{count}} файлів",
"Failed to delete files": "Не вдалося видалити файли",
"Total Files": "Всього файлів",
"Total Size": "Загальний розмір",
"Quota": "Квота",
"Used": "Використано",
"Files": "Файли",
"Search files...": "Пошук файлів...",
"Newest First": "Спершу новіші",
"Oldest First": "Спершу старіші",
"Largest First": "Спершу найбільші",
"Smallest First": "Спершу найменші",
"Name A-Z": "Ім'я A-Z",
"Name Z-A": "Ім'я Z-A",
"{{count}} selected_one": "Вибрано {{count}} файл",
"{{count}} selected_few": "Вибрано {{count}} файли",
"{{count}} selected_many": "Вибрано {{count}} файлів",
"{{count}} selected_other": "Вибрано {{count}} файлів",
"Delete Selected": "Видалити вибране",
"Created": "Створено",
"No files found": "Файли не знайдено",
"No files uploaded yet": "Файли ще не завантажені",
"files": "файли",
"Page {{current}} of {{total}}": "Сторінка {{current}} з {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Ви впевнені, що хочете видалити {{count}} файл?",
"Are you sure to delete {{count}} selected file(s)?_few": "Ви впевнені, що хочете видалити {{count}} файли?",
"Are you sure to delete {{count}} selected file(s)?_many": "Ви впевнені, що хочете видалити {{count}} файлів?",
"Are you sure to delete {{count}} selected file(s)?_other": "Ви впевнені, що хочете видалити {{count}} файлів?",
"Cloud Storage Usage": "Використання хмарного сховища"
}
@@ -724,5 +724,31 @@
"Enter {{terms}}": "Nhập {{terms}}",
"No search results found": "Không tìm thấy kết quả tìm kiếm",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Không tải được nguồn cấp OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Tìm kiếm trong {{title}}"
"Search in {{title}}": "Tìm kiếm trong {{title}}",
"Manage Storage": "Quản lý bộ nhớ",
"Failed to load files": "Không tải được tệp",
"Deleted {{count}} file(s)_other": "Đã xóa {{count}} tệp",
"Failed to delete {{count}} file(s)_other": "Không xóa được {{count}} tệp",
"Failed to delete files": "Không xóa được tệp",
"Total Files": "Tổng số tệp",
"Total Size": "Tổng dung lượng",
"Quota": "Dung lượng cho phép",
"Used": "Đã sử dụng",
"Files": "Tệp",
"Search files...": "Tìm tệp...",
"Newest First": "Mới nhất trước",
"Oldest First": "Cũ nhất trước",
"Largest First": "Lớn nhất trước",
"Smallest First": "Nhỏ nhất trước",
"Name A-Z": "Tên A-Z",
"Name Z-A": "Tên Z-A",
"{{count}} selected_other": "Đã chọn {{count}} tệp",
"Delete Selected": "Xóa các mục đã chọn",
"Created": "Đã tạo",
"No files found": "Không tìm thấy tệp",
"No files uploaded yet": "Chưa tải lên tệp nào",
"files": "tệp",
"Page {{current}} of {{total}}": "Trang {{current}} trên {{total}}",
"Are you sure to delete {{count}} selected file(s)?_other": "Bạn có chắc muốn xóa {{count}} tệp đã chọn không?",
"Cloud Storage Usage": "Sử dụng lưu trữ đám mây"
}
@@ -724,5 +724,31 @@
"Enter {{terms}}": "输入 {{terms}}",
"No search results found": "未找到搜索结果",
"Failed to load OPDS feed: {{status}} {{statusText}}": "加载 OPDS 源失败:{{status}} {{statusText}}",
"Search in {{title}}": "在 {{title}} 中搜索"
"Search in {{title}}": "在 {{title}} 中搜索",
"Manage Storage": "管理存储",
"Failed to load files": "加载文件失败",
"Deleted {{count}} file(s)_other": "已删除 {{count}} 个文件",
"Failed to delete {{count}} file(s)_other": "删除 {{count}} 个文件失败",
"Failed to delete files": "删除文件失败",
"Total Files": "文件总数",
"Total Size": "总大小",
"Quota": "配额",
"Used": "已使用",
"Files": "文件",
"Search files...": "搜索文件...",
"Newest First": "最新优先",
"Oldest First": "最旧优先",
"Largest First": "最大优先",
"Smallest First": "最小优先",
"Name A-Z": "名称 A-Z",
"Name Z-A": "名称 Z-A",
"{{count}} selected_other": "已选择 {{count}} 个文件",
"Delete Selected": "删除选中项",
"Created": "创建时间",
"No files found": "未找到文件",
"No files uploaded yet": "尚未上传文件",
"files": "文件",
"Page {{current}} of {{total}}": "第 {{current}} 页,共 {{total}} 页",
"Are you sure to delete {{count}} selected file(s)?_other": "确定要删除已选择的 {{count}} 个文件吗?",
"Cloud Storage Usage": "云存储使用情况"
}
@@ -724,5 +724,31 @@
"Enter {{terms}}": "輸入 {{terms}}",
"No search results found": "未找到搜尋結果",
"Failed to load OPDS feed: {{status}} {{statusText}}": "載入 OPDS 資料源失敗:{{status}} {{statusText}}",
"Search in {{title}}": "在 {{title}} 中搜尋"
"Search in {{title}}": "在 {{title}} 中搜尋",
"Manage Storage": "管理儲存",
"Failed to load files": "載入檔案失敗",
"Deleted {{count}} file(s)_other": "已刪除 {{count}} 個檔案",
"Failed to delete {{count}} file(s)_other": "刪除 {{count}} 個檔案失敗",
"Failed to delete files": "刪除檔案失敗",
"Total Files": "檔案總數",
"Total Size": "總大小",
"Quota": "配額",
"Used": "已使用",
"Files": "檔案",
"Search files...": "搜尋檔案...",
"Newest First": "最新優先",
"Oldest First": "最舊優先",
"Largest First": "最大優先",
"Smallest First": "最小優先",
"Name A-Z": "名稱 A-Z",
"Name Z-A": "名稱 Z-A",
"{{count}} selected_other": "已選擇 {{count}} 個檔案",
"Delete Selected": "刪除所選",
"Created": "建立時間",
"No files found": "找不到檔案",
"No files uploaded yet": "尚未上傳檔案",
"files": "檔案",
"Page {{current}} of {{total}}": "第 {{current}} 頁,共 {{total}} 頁",
"Are you sure to delete {{count}} selected file(s)?_other": "確定要刪除已選擇的 {{count}} 個檔案嗎?",
"Cloud Storage Usage": "雲端儲存使用情況"
}
@@ -53,6 +53,7 @@ interface AccountActionsProps {
onConfirmDelete: () => void;
onRestorePurchase?: () => void;
onManageSubscription?: () => void;
onManageStorage?: () => void;
}
const AccountActions: React.FC<AccountActionsProps> = ({
@@ -63,6 +64,7 @@ const AccountActions: React.FC<AccountActionsProps> = ({
onConfirmDelete,
onRestorePurchase,
onManageSubscription,
onManageStorage,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
@@ -104,6 +106,14 @@ const AccountActions: React.FC<AccountActionsProps> = ({
</button>
)
)}
{onManageStorage && (
<button
onClick={onManageStorage}
className='w-full rounded-lg bg-purple-100 px-6 py-3 font-medium text-purple-600 transition-colors hover:bg-purple-200 md:w-auto'
>
{_('Manage Storage')}
</button>
)}
<button
onClick={onResetPassword}
className='w-full rounded-lg bg-gray-200 px-6 py-3 font-medium text-gray-800 transition-colors hover:bg-gray-300 md:w-auto'
@@ -0,0 +1,570 @@
'use client';
import clsx from 'clsx';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibrary } from '@/hooks/useLibrary';
import {
listFiles,
getStorageStats,
purgeFiles,
type FileRecord,
type StorageStats,
type ListFilesParams,
} from '@/libs/storage';
import { eventDispatcher } from '@/utils/event';
import { debounce } from '@/utils/debounce';
import Spinner from '@/components/Spinner';
import Alert from '@/components/Alert';
const StorageManager = () => {
const _ = useTranslation();
const { appService } = useEnv();
const { libraryLoaded } = useLibrary();
const { safeAreaInsets } = useThemeStore();
const [loading, setLoading] = useState(false);
const [filesLoaded, setFilesLoaded] = useState(false);
const [files, setFiles] = useState<FileRecord[]>([]);
const [stats, setStats] = useState<StorageStats | null>(null);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [searchInput, setSearchInput] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [sortBy, setSortBy] = useState<ListFilesParams['sortBy']>('created_at');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const [expandedBooks, setExpandedBooks] = useState<Set<string>>(new Set());
const loadFiles = useCallback(async () => {
setLoading(true);
try {
const params: ListFilesParams = {
page: currentPage,
pageSize: 20,
sortBy,
sortOrder,
};
if (searchQuery.trim()) {
params.search = searchQuery.trim();
}
const response = await listFiles(params);
setFiles(response.files);
setTotalPages(response.totalPages);
setFilesLoaded(true);
} catch (error) {
console.error('Failed to load files:', error);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to load files'),
});
} finally {
setLoading(false);
}
}, [currentPage, sortBy, sortOrder, searchQuery, _]);
const loadStats = useCallback(async () => {
setLoading(true);
try {
const statsData = await getStorageStats();
setStats(statsData);
} catch (error) {
console.error('Failed to load stats:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadFiles();
loadStats();
}, [loadFiles, loadStats]);
// Group files by book_hash
const groupedFiles = React.useMemo(() => {
const groups = new Map<string, FileRecord[]>();
files.forEach((file) => {
const bookHash = file.book_hash || 'no-book';
if (!groups.has(bookHash)) {
groups.set(bookHash, []);
}
groups.get(bookHash)!.push(file);
});
return groups;
}, [files]);
const getFileName = (fileKey: string): string => {
const parts = fileKey.split('/');
return parts[parts.length - 1] || fileKey;
};
const isCoverFile = (file: FileRecord): boolean => {
return getFileName(file.file_key).toLowerCase() === 'cover.png';
};
// Get main book file (first non-cover file)
const getMainBookFile = (bookFiles: FileRecord[]): FileRecord | null => {
return bookFiles.find((f) => !isCoverFile(f)) || bookFiles[0] || null;
};
// Get all files for a book including covers
const getAllBookFiles = (bookFiles: FileRecord[]): FileRecord[] => {
return bookFiles;
};
const toggleBookExpansion = (bookHash: string) => {
const newExpanded = new Set(expandedBooks);
if (newExpanded.has(bookHash)) {
newExpanded.delete(bookHash);
} else {
newExpanded.add(bookHash);
}
setExpandedBooks(newExpanded);
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedFiles(new Set(files.map((f) => f.file_key)));
} else {
setSelectedFiles(new Set());
}
};
const handleSelectBook = (bookFiles: FileRecord[], checked: boolean) => {
const newSelected = new Set(selectedFiles);
const allBookFiles = getAllBookFiles(bookFiles);
allBookFiles.forEach((file) => {
if (checked) {
newSelected.add(file.file_key);
} else {
newSelected.delete(file.file_key);
}
});
setSelectedFiles(newSelected);
};
const handleSelectFile = (fileKey: string, checked: boolean) => {
const newSelected = new Set(selectedFiles);
if (checked) {
newSelected.add(fileKey);
} else {
newSelected.delete(fileKey);
}
setSelectedFiles(newSelected);
};
const isBookSelected = (bookFiles: FileRecord[]): boolean => {
const allBookFiles = getAllBookFiles(bookFiles);
return allBookFiles.length > 0 && allBookFiles.every((f) => selectedFiles.has(f.file_key));
};
const isBookPartiallySelected = (bookFiles: FileRecord[]): boolean => {
const allBookFiles = getAllBookFiles(bookFiles);
const selectedCount = allBookFiles.filter((f) => selectedFiles.has(f.file_key)).length;
return selectedCount > 0 && selectedCount < allBookFiles.length;
};
const handleDeleteSelected = async () => {
if (selectedFiles.size === 0) return;
if (!libraryLoaded || !appService) return;
setLoading(true);
try {
const fileKeys = Array.from(selectedFiles);
const fileRecords = files.filter((f) => selectedFiles.has(f.file_key));
const selectedBookHashes = new Set(
fileRecords.map((f) => f.book_hash).filter((hash): hash is string => !!hash),
);
const result = await purgeFiles(fileKeys, true);
const { library, setLibrary } = useLibraryStore.getState();
library
.filter((book) => selectedBookHashes.has(book.hash))
.forEach((book) => {
book.uploadedAt = null;
book.updatedAt = Date.now();
});
setLibrary(library);
appService.saveLibraryBooks(library);
if (result.deletedCount > 0) {
await loadFiles();
await loadStats();
setSelectedFiles(new Set());
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Deleted {{count}} file(s)', { count: result.deletedCount }),
});
if (result.failedCount > 0) {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to delete {{count}} file(s)', { count: result.failedCount }),
});
}
}
} catch (error) {
console.error('Failed to delete files:', error);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to delete files'),
});
} finally {
setLoading(false);
setShowConfirmDelete(false);
}
};
const handleSearchChange = useMemo(
() =>
debounce((value: string) => {
setSearchQuery(value);
setCurrentPage(1);
}, 1000),
[setSearchQuery, setCurrentPage],
);
useEffect(() => {
handleSearchChange(searchInput);
}, [searchInput, handleSearchChange]);
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const getBookTotalSize = (bookFiles: FileRecord[]): number => {
return bookFiles.reduce((sum, file) => sum + file.file_size, 0);
};
const isAllSelected = files.length > 0 && selectedFiles.size === files.length;
return (
<div className='flex flex-col gap-6'>
{/* Stats Section */}
{stats ? (
<div className='bg-base-100 border-base-300 rounded-lg border p-4'>
<h3 className='text-base-content mb-4 text-lg font-semibold'>
{_('Cloud Storage Usage')}
</h3>
<div className='grid grid-cols-2 gap-4 sm:grid-cols-4'>
<div>
<div className='text-base-content/60 text-sm'>{_('Total Files')}</div>
<div className='text-base-content text-xl font-semibold'>{stats.totalFiles}</div>
</div>
<div>
<div className='text-base-content/60 text-sm'>{_('Total Size')}</div>
<div className='text-base-content text-xl font-semibold'>
{formatFileSize(stats.totalSize)}
</div>
</div>
<div>
<div className='text-base-content/60 text-sm'>{_('Quota')}</div>
<div className='text-base-content text-xl font-semibold'>
{formatFileSize(stats.quota)}
</div>
</div>
<div>
<div className='text-base-content/60 text-sm'>{_('Used')}</div>
<div className='text-base-content text-xl font-semibold'>
{stats.usagePercentage}%
</div>
</div>
</div>
<div className='bg-base-300 mt-4 h-2 w-full overflow-hidden rounded-full'>
<div
className='bg-primary h-full transition-all'
style={{ width: `${Math.min(stats.usagePercentage, 100)}%` }}
/>
</div>
</div>
) : (
<div className='bg-base-100 border-base-300 rounded-lg border p-4'>
<div className='skeleton mb-4 h-6 w-32'></div>
<div className='grid grid-cols-2 gap-4 sm:grid-cols-4'>
<div className='skeleton h-16 w-full'></div>
<div className='skeleton h-16 w-full'></div>
<div className='skeleton h-16 w-full'></div>
<div className='skeleton h-16 w-full'></div>
</div>
<div className='skeleton mt-4 h-2 w-full rounded-full'></div>
</div>
)}
{/* Files Section */}
<div className='bg-base-100 border-base-300 rounded-lg border'>
<div className='border-base-300 flex flex-col gap-4 border-b p-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='hidden items-center justify-center sm:flex'>
<h3 className='text-base-content text-lg font-semibold'>{_('Files')}</h3>
</div>
<div className='flex flex-col gap-2 sm:flex-row'>
<input
type='text'
placeholder={_('Search files...')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className='input input-bordered input-sm w-full sm:w-64'
disabled={loading}
/>
<select
value={`${sortBy}-${sortOrder}`}
onChange={(e) => {
const [newSortBy, newSortOrder] = e.target.value.split('-');
setSortBy(newSortBy as ListFilesParams['sortBy']);
setSortOrder(newSortOrder as 'asc' | 'desc');
}}
disabled={loading}
className='select select-bordered select-sm'
>
<option value='created_at-desc'>{_('Newest First')}</option>
<option value='created_at-asc'>{_('Oldest First')}</option>
<option value='file_size-desc'>{_('Largest First')}</option>
<option value='file_size-asc'>{_('Smallest First')}</option>
<option value='file_key-asc'>{_('Name A-Z')}</option>
<option value='file_key-desc'>{_('Name Z-A')}</option>
</select>
</div>
</div>
{/* Actions Bar */}
<div className='bg-base-200 border-base-300 flex items-center justify-between border-b p-4'>
<span className='text-base-content text-sm'>
{_('{{count}} selected', { count: selectedFiles.size })}
</span>
<button
onClick={() => setShowConfirmDelete(true)}
className='btn btn-error btn-sm'
disabled={loading || selectedFiles.size === 0}
>
{_('Delete Selected')}
</button>
</div>
{loading && <Spinner loading />}
{/* Files List - Grouped by Book */}
<div className='w-full'>
<table className='table-sm table w-full [&_td]:px-2 [&_td]:py-1 [&_th]:px-2 [&_th]:py-1'>
<thead className='h-10'>
<tr>
<th className='w-12'>
<div className='flex items-center'>
<input
type='checkbox'
checked={isAllSelected}
onChange={(e) => handleSelectAll(e.target.checked)}
className='checkbox checkbox-sm'
disabled={!filesLoaded || loading}
/>
</div>
</th>
<th className='!ps-0'>{_('File Name')}</th>
<th className='hidden sm:table-cell'>{_('Size')}</th>
<th className='hidden sm:table-cell'>{_('Created')}</th>
</tr>
</thead>
<tbody>
{!filesLoaded ? (
<>
{[...Array(5)].map((_, i) => (
<tr key={i}>
<td className='min-w-16'>
<div className='skeleton h-5 w-5'></div>
</td>
<td className='max-w-0 !ps-0 sm:w-[80%]'>
<div className='flex flex-col gap-2'>
<div className='skeleton h-4 w-3/4'></div>
<div className='skeleton h-3 w-1/2 sm:hidden'></div>
</div>
</td>
<td className='hidden sm:table-cell'>
<div className='skeleton h-4 w-16'></div>
</td>
<td className='hidden sm:table-cell'>
<div className='skeleton h-4 w-20'></div>
</td>
</tr>
))}
</>
) : groupedFiles.size === 0 ? (
<tr>
<td colSpan={4} className='text-center'>
<div className='text-base-content/60 py-8'>
{searchQuery ? _('No files found') : _('No files uploaded yet')}
</div>
</td>
</tr>
) : (
Array.from(groupedFiles.entries()).map(([bookHash, bookFiles]) => {
const mainFile = getMainBookFile(bookFiles);
const isExpanded = expandedBooks.has(bookHash);
const hasMultipleFiles = bookFiles.length > 1;
const bookSelected = isBookSelected(bookFiles);
const bookPartiallySelected = isBookPartiallySelected(bookFiles);
if (!mainFile) return null;
return (
<React.Fragment key={bookHash}>
{/* Main book row */}
<tr className='hover'>
<td>
<div className='flex items-center gap-1'>
<input
type='checkbox'
checked={bookSelected}
ref={(el) => {
if (el) el.indeterminate = bookPartiallySelected;
}}
onChange={(e) => handleSelectBook(bookFiles, e.target.checked)}
disabled={loading}
className='checkbox checkbox-sm'
/>
{hasMultipleFiles && (
<button
onClick={() => toggleBookExpansion(bookHash)}
className='btn btn-ghost btn-xs'
>
{isExpanded ? '' : '+'}
</button>
)}
</div>
</td>
<td className='max-w-0 !ps-0 sm:w-[80%]'>
<div className='flex flex-col'>
<div className='flex items-center gap-2'>
<span className='text-base-content block max-w-full truncate font-medium'>
{getFileName(mainFile.file_key)}
</span>
{hasMultipleFiles && (
<span className='text-base-content/60 flex-shrink-0 whitespace-nowrap text-xs'>
({bookFiles.length} {_('files')})
</span>
)}
</div>
<span className='text-base-content/60 text-xs sm:hidden'>
{formatFileSize(getBookTotalSize(bookFiles))} ·{' '}
{formatDate(mainFile.created_at)}
</span>
</div>
</td>
<td className='hidden whitespace-nowrap sm:table-cell'>
{formatFileSize(getBookTotalSize(bookFiles))}
</td>
<td className='hidden whitespace-nowrap sm:table-cell'>
{formatDate(mainFile.created_at)}
</td>
</tr>
{/* Expanded files (excluding covers unless expanded) */}
{isExpanded &&
bookFiles.map((file) => (
<tr key={file.file_key} className='hover bg-base-200/50'>
<td>
<div className='pl-4'>
<input
type='checkbox'
checked={selectedFiles.has(file.file_key)}
onChange={(e) =>
handleSelectFile(file.file_key, e.target.checked)
}
disabled={loading}
className='checkbox checkbox-sm'
/>
</div>
</td>
<td className='max-w-0 !ps-0 sm:w-[80%]'>
<div className='flex flex-col'>
<span className='text-base-content/80 text-xs'>
{getFileName(file.file_key)}
</span>
</div>
</td>
<td className='hidden sm:table-cell'>
{formatFileSize(file.file_size)}
</td>
<td className='hidden sm:table-cell'>{formatDate(file.created_at)}</td>
</tr>
))}
</React.Fragment>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className='border-base-300 flex items-center justify-between border-t p-4'>
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className='btn btn-sm'
>
{_('Previous')}
</button>
<span className='text-base-content text-sm'>
{_('Page {{current}} of {{total}}', { current: currentPage, total: totalPages })}
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className='btn btn-sm'
>
{_('Next')}
</button>
</div>
</div>
{/* Confirm Delete Modal */}
{showConfirmDelete && (
<div
className={clsx('fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
style={{
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
}}
>
<Alert
title={_('Confirm Deletion')}
message={_('Are you sure to delete {{count}} selected file(s)?', {
count: selectedFiles.size,
})}
onCancel={() => {
setShowConfirmDelete(false);
}}
onConfirm={() => {
handleDeleteSelected();
setShowConfirmDelete(false);
}}
/>
</div>
)}
</div>
);
};
export default StorageManager;
+37 -20
View File
@@ -22,6 +22,7 @@ import {
restoreIAPPurchases,
getSubscriptionSuccessUrl as getIAPSubscriptionSuccessUrl,
} from '@/libs/payment/iap/client';
import { isPurchaseProduct } from '@/libs/payment/iap/utils';
import {
createStripeCheckoutSession,
redirectToStripeCheckout,
@@ -38,8 +39,8 @@ import UserInfo from './components/UserInfo';
import UsageStats from './components/UsageStats';
import PlansComparison from './components/PlansComparison';
import AccountActions from './components/AccountActions';
import StorageManager from './components/StorageManager';
import Checkout from './components/Checkout';
import { isPurchaseProduct } from '@/libs/payment/iap/utils';
type CheckoutState = {
clientSecret: string;
@@ -56,6 +57,7 @@ const ProfilePage = () => {
const [loading, setLoading] = useState(false);
const [showEmbeddedCheckout, setShowEmbeddedCheckout] = useState(false);
const [showStorageManager, setShowStorageManager] = useState(false);
const [checkoutState, setCheckoutState] = useState<CheckoutState>({
clientSecret: '',
sessionId: '',
@@ -87,6 +89,8 @@ const ProfilePage = () => {
const handleGoBack = () => {
if (showEmbeddedCheckout) {
setShowEmbeddedCheckout(false);
} else if (showStorageManager) {
setShowStorageManager(false);
} else {
navigateToLibrary(router);
}
@@ -200,6 +204,10 @@ const ProfilePage = () => {
handleConfirmDelete(_('Failed to delete user. Please try again later.'));
};
const handleManageStorage = () => {
setShowStorageManager(true);
};
if (!mounted) {
return null;
}
@@ -262,28 +270,37 @@ const ProfilePage = () => {
planDetails={userPlanDetails}
/>
<UsageStats quotas={quotas} />
{!showStorageManager && <UsageStats quotas={quotas} />}
</div>
<div className='flex flex-col gap-y-8 sm:px-6'>
<PlansComparison
availablePlans={availablePlans}
userPlan={userProfilePlan}
onSubscribe={appService.hasIAP ? handleIAPSubscribe : handleStripeSubscribe}
/>
</div>
{showStorageManager ? (
<div className='flex flex-col gap-y-8 px-6'>
<StorageManager />
</div>
) : (
<>
<div className='flex flex-col gap-y-8 sm:px-6'>
<PlansComparison
availablePlans={availablePlans}
userPlan={userProfilePlan}
onSubscribe={appService.hasIAP ? handleIAPSubscribe : handleStripeSubscribe}
/>
</div>
<div className='flex flex-col gap-y-8 px-6'>
<AccountActions
userPlan={userProfilePlan}
onLogout={handleLogout}
onResetPassword={handleResetPassword}
onUpdateEmail={handleUpdateEmail}
onConfirmDelete={handleDeleteWithMessage}
onRestorePurchase={handleIAPRestorePurchase}
onManageSubscription={handleManageSubscription}
onManageStorage={handleManageStorage}
/>
</div>
</>
)}
<div className='flex flex-col gap-y-8 px-6'>
<AccountActions
userPlan={userProfilePlan}
onLogout={handleLogout}
onResetPassword={handleResetPassword}
onUpdateEmail={handleUpdateEmail}
onConfirmDelete={handleDeleteWithMessage}
onRestorePurchase={handleIAPRestorePurchase}
onManageSubscription={handleManageSubscription}
/>
</div>
<LegalLinks />
</div>
</div>
+119
View File
@@ -15,6 +15,9 @@ const API_ENDPOINTS = {
upload: getAPIBaseUrl() + '/storage/upload',
download: getAPIBaseUrl() + '/storage/download',
delete: getAPIBaseUrl() + '/storage/delete',
stats: getAPIBaseUrl() + '/storage/stats',
list: getAPIBaseUrl() + '/storage/list',
purge: getAPIBaseUrl() + '/storage/purge',
};
export const createProgressHandler = (
@@ -171,3 +174,119 @@ export const deleteFile = async (filePath: string) => {
throw new Error('File deletion failed');
}
};
export interface StorageStats {
totalFiles: number;
totalSize: number;
usage: number;
quota: number;
usagePercentage: number;
byBookHash: Array<{
bookHash: string | null;
fileCount: number;
totalSize: number;
}>;
}
export const getStorageStats = async (): Promise<StorageStats> => {
try {
const response = await fetchWithAuth(API_ENDPOINTS.stats, {
method: 'GET',
});
return await response.json();
} catch (error) {
console.error('Get storage stats failed:', error);
throw new Error('Get storage stats failed');
}
};
export interface FileRecord {
file_key: string;
file_size: number;
book_hash: string | null;
created_at: string;
updated_at: string | null;
}
export interface ListFilesParams {
page?: number;
pageSize?: number;
sortBy?: 'created_at' | 'updated_at' | 'file_size' | 'file_key';
sortOrder?: 'asc' | 'desc';
bookHash?: string;
search?: string;
}
interface ListFilesResponse {
files: FileRecord[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export const listFiles = async (params?: ListFilesParams): Promise<ListFilesResponse> => {
try {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.set('page', params.page.toString());
if (params?.pageSize) queryParams.set('pageSize', params.pageSize.toString());
if (params?.sortBy) queryParams.set('sortBy', params.sortBy);
if (params?.sortOrder) queryParams.set('sortOrder', params.sortOrder);
if (params?.bookHash) queryParams.set('bookHash', params.bookHash);
if (params?.search) queryParams.set('search', params.search);
const url = queryParams.toString()
? `${API_ENDPOINTS.list}?${queryParams.toString()}`
: API_ENDPOINTS.list;
const response = await fetchWithAuth(url, {
method: 'GET',
});
return await response.json();
} catch (error) {
console.error('List files failed:', error);
throw new Error('List files failed');
}
};
interface PurgeFilesResult {
success: string[];
failed: Array<{ fileKey: string; error: string }>;
deletedCount: number;
failedCount: number;
}
export const purgeFiles = async (
filePathsOrKeys: string[],
isFileKeys = false,
): Promise<PurgeFilesResult> => {
try {
let fileKeys: string[];
if (isFileKeys) {
fileKeys = filePathsOrKeys;
} else {
const userId = await getUserID();
if (!userId) {
throw new Error('Not authenticated');
}
fileKeys = filePathsOrKeys.map((path) => `${userId}/${path}`);
}
const response = await fetchWithAuth(API_ENDPOINTS.purge, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ fileKeys }),
});
return await response.json();
} catch (error) {
console.error('Purge files failed:', error);
throw new Error('Purge files failed');
}
};
@@ -0,0 +1,124 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { validateUserAndToken } from '@/utils/access';
interface FileRecord {
file_key: string;
file_size: number;
book_hash: string | null;
created_at: string;
updated_at: string | null;
}
interface ListFilesResponse {
files: FileRecord[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await runMiddleware(req, res, corsAllMethods);
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { user, token } = await validateUserAndToken(req.headers['authorization']);
if (!user || !token) {
return res.status(403).json({ error: 'Not authenticated' });
}
const reqQuery = req.query as {
page?: string;
pageSize?: string;
sortBy?: string;
sortOrder?: string;
bookHash?: string;
search?: string;
};
const page = parseInt(reqQuery.page as string) || 1;
const pageSize = Math.min(parseInt(reqQuery.pageSize as string) || 50, 100);
const sortBy = (reqQuery.sortBy as string) || 'created_at';
const sortOrder = (reqQuery.sortOrder as string) === 'asc' ? 'asc' : 'desc';
const bookHash = reqQuery.bookHash as string | undefined;
const search = reqQuery.search as string | undefined;
const supabase = createSupabaseAdminClient();
let query = supabase
.from('files')
.select('file_key, file_size, book_hash, created_at, updated_at', { count: 'exact' })
.eq('user_id', user.id)
.is('deleted_at', null);
if (bookHash) {
query = query.eq('book_hash', bookHash);
}
if (search) {
query = query.ilike('file_key', `%${search}%`);
}
const validSortColumns = ['created_at', 'updated_at', 'file_size', 'file_key'];
const sortColumn = validSortColumns.includes(sortBy) ? sortBy : 'created_at';
query = query.order(sortColumn, { ascending: sortOrder === 'asc' });
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
query = query.range(from, to);
const { data: files, error: filesError, count } = await query;
if (filesError) {
console.error('Error querying files:', filesError);
return res.status(500).json({ error: 'Failed to retrieve files' });
}
const total = count || 0;
const totalPages = Math.ceil(total / pageSize);
// Get all book_hashes from the paginated results
const bookHashes = Array.from(
new Set((files || []).map((f) => f.book_hash).filter((hash): hash is string => !!hash)),
);
// Fetch all files with the same book_hashes to ensure complete book groups
// IMPORTANT: We don't apply the search filter here. This ensures that ALL files
// for matched books are included (e.g., cover.png files), even if they don't
// match the search term. This is crucial for proper book grouping and selection.
let allRelatedFiles = files || [];
if (bookHashes.length > 0) {
const relatedQuery = supabase
.from('files')
.select('file_key, file_size, book_hash, created_at, updated_at')
.eq('user_id', user.id)
.is('deleted_at', null)
.in('book_hash', bookHashes);
const { data: relatedFiles, error: relatedError } = await relatedQuery;
if (!relatedError && relatedFiles) {
const fileMap = new Map(allRelatedFiles.map((f) => [f.file_key, f]));
relatedFiles.forEach((f) => fileMap.set(f.file_key, f));
allRelatedFiles = Array.from(fileMap.values());
}
}
const response: ListFilesResponse = {
files: allRelatedFiles,
total,
page,
pageSize,
totalPages,
};
return res.status(200).json(response);
} catch (error) {
console.error(error);
return res.status(500).json({ error: 'Something went wrong' });
}
}
@@ -0,0 +1,146 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { validateUserAndToken } from '@/utils/access';
import { deleteObject } from '@/utils/object';
interface BulkDeleteResult {
success: string[];
failed: Array<{ fileKey: string; error: string }>;
deletedCount: number;
failedCount: number;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await runMiddleware(req, res, corsAllMethods);
if (req.method !== 'DELETE') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { user, token } = await validateUserAndToken(req.headers['authorization']);
if (!user || !token) {
return res.status(403).json({ error: 'Not authenticated' });
}
const { fileKeys } = req.body;
if (!fileKeys || !Array.isArray(fileKeys)) {
return res.status(400).json({ error: 'Missing or invalid fileKeys array' });
}
if (fileKeys.length === 0) {
return res.status(400).json({ error: 'fileKeys array cannot be empty' });
}
if (fileKeys.length > 100) {
return res.status(400).json({ error: 'Cannot delete more than 100 files at once' });
}
if (!fileKeys.every((key) => typeof key === 'string')) {
return res.status(400).json({ error: 'All fileKeys must be strings' });
}
const supabase = createSupabaseAdminClient();
// Fetch all files that match the provided keys and belong to the user
const { data: fileRecords, error: fileError } = await supabase
.from('files')
.select('id, user_id, file_key')
.eq('user_id', user.id)
.in('file_key', fileKeys)
.is('deleted_at', null);
if (fileError) {
console.error('Error querying files:', fileError);
return res.status(500).json({ error: 'Failed to retrieve files for deletion' });
}
if (!fileRecords || fileRecords.length === 0) {
return res.status(404).json({ error: 'No matching files found' });
}
// Verify all files belong to the user
const unauthorizedFiles = fileRecords.filter((record) => record.user_id !== user.id);
if (unauthorizedFiles.length > 0) {
return res.status(403).json({ error: 'Unauthorized access to one or more files' });
}
// Process deletions
const results = await Promise.allSettled(
fileRecords.map(async (fileRecord) => {
try {
// Delete from storage
await deleteObject(fileRecord.file_key);
// Delete from database
const { error: deleteError } = await supabase
.from('files')
.delete()
.eq('id', fileRecord.id);
if (deleteError) {
throw new Error(`Database deletion failed: ${deleteError.message}`);
}
return { fileKey: fileRecord.file_key, success: true };
} catch (error) {
console.error(`Error deleting file ${fileRecord.file_key}:`, error);
return {
fileKey: fileRecord.file_key,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}),
);
const success: string[] = [];
const failed: Array<{ fileKey: string; error: string }> = [];
results.forEach((result) => {
if (result.status === 'fulfilled') {
if (result.value.success) {
success.push(result.value.fileKey);
} else {
failed.push({
fileKey: result.value.fileKey,
error: result.value.error || 'Unknown error',
});
}
} else {
failed.push({
fileKey: 'unknown',
error: result.reason?.message || 'Promise rejected',
});
}
});
// Handle files that weren't found in the database
const foundFileKeys = new Set(fileRecords.map((record) => record.file_key));
const notFoundKeys = fileKeys.filter((key) => !foundFileKeys.has(key));
notFoundKeys.forEach((key) => {
failed.push({
fileKey: key,
error: 'File not found or already deleted',
});
});
const response: BulkDeleteResult = {
success,
failed,
deletedCount: success.length,
failedCount: failed.length,
};
// Return 207 Multi-Status if there are partial failures
const statusCode =
failed.length > 0 && success.length > 0 ? 207 : failed.length > 0 ? 500 : 200;
return res.status(statusCode).json(response);
} catch (error) {
console.error(error);
return res.status(500).json({ error: 'Something went wrong' });
}
}
@@ -0,0 +1,109 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { validateUserAndToken, getStoragePlanData } from '@/utils/access';
interface StorageStats {
totalFiles: number;
totalSize: number;
usage: number;
quota: number;
usagePercentage: number;
byBookHash: Array<{
bookHash: string | null;
fileCount: number;
totalSize: number;
}>;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await runMiddleware(req, res, corsAllMethods);
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { user, token } = await validateUserAndToken(req.headers['authorization']);
if (!user || !token) {
return res.status(403).json({ error: 'Not authenticated' });
}
const supabase = createSupabaseAdminClient();
// Get total file count and size
const { data: totalStats, error: totalError } = await supabase
.from('files')
.select('file_size')
.eq('user_id', user.id)
.is('deleted_at', null);
if (totalError) {
console.error('Error querying total stats:', totalError);
return res.status(500).json({ error: 'Failed to retrieve storage statistics' });
}
const totalFiles = totalStats?.length || 0;
const totalSize = totalStats?.reduce((sum, file) => sum + (file.file_size || 0), 0) || 0;
// Get storage plan data
const { usage, quota } = getStoragePlanData(token);
const usagePercentage = quota > 0 ? Math.round((usage / quota) * 100) : 0;
// Get stats grouped by book_hash
const { data: bookHashStats, error: bookHashError } = await supabase.rpc(
'get_storage_by_book_hash',
{ p_user_id: user.id },
);
// Fallback if RPC function doesn't exist - manual aggregation
let byBookHash: Array<{ bookHash: string | null; fileCount: number; totalSize: number }> = [];
if (bookHashError) {
console.warn('RPC function not available, using fallback aggregation:', bookHashError);
const { data: allFiles, error: filesError } = await supabase
.from('files')
.select('book_hash, file_size')
.eq('user_id', user.id)
.is('deleted_at', null);
if (!filesError && allFiles) {
const grouped = new Map<string | null, { count: number; size: number }>();
allFiles.forEach((file) => {
const key = file.book_hash;
const current = grouped.get(key) || { count: 0, size: 0 };
grouped.set(key, {
count: current.count + 1,
size: current.size + file.file_size,
});
});
byBookHash = Array.from(grouped.entries())
.map(([bookHash, stats]) => ({
bookHash,
fileCount: stats.count,
totalSize: stats.size,
}))
.sort((a, b) => b.totalSize - a.totalSize);
}
} else if (bookHashStats) {
byBookHash = bookHashStats;
}
const response: StorageStats = {
totalFiles,
totalSize,
usage,
quota,
usagePercentage,
byBookHash,
};
return res.status(200).json(response);
} catch (error) {
console.error(error);
return res.status(500).json({ error: 'Something went wrong' });
}
}