feat(opds): support downloading ebooks from OPDS catalogs (#2571)
This commit is contained in:
@@ -1,11 +1,25 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import next from 'eslint-config-next';
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||
import tseslint from 'eslint-config-next/typescript';
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y';
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...tseslint,
|
||||
...next,
|
||||
...nextVitals,
|
||||
{
|
||||
rules: jsxA11y.configs.recommended.rules,
|
||||
rules: {
|
||||
...jsxA11y.configs.recommended.rules,
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
globalIgnores([
|
||||
'node_modules/**',
|
||||
|
||||
@@ -130,6 +130,8 @@
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/tinycolor2": "^1.4.6",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
|
||||
@@ -691,5 +691,54 @@
|
||||
"Download from Cloud": "تحميل من السحابة",
|
||||
"Upload to Cloud": "رفع إلى السحابة",
|
||||
"Clear Custom Fonts": "مسح الخطوط المخصصة",
|
||||
"Columns": "الأعمدة"
|
||||
"Columns": "الأعمدة",
|
||||
"OPDS Catalogs": "كتالوجات OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "لا يتم دعم إضافة عناوين LAN في إصدار تطبيق الويب.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "كتالوج OPDS غير صالح. يرجى التحقق من عنوان URL.",
|
||||
"Browse and download books from online catalogs": "تصفح وتنزيل الكتب من الكتالوجات عبر الإنترنت",
|
||||
"My Catalogs": "كتالوجاتي",
|
||||
"Add Catalog": "إضافة كتالوج",
|
||||
"No catalogs yet": "لا توجد كتالوجات بعد",
|
||||
"Add your first OPDS catalog to start browsing books": "أضف كتالوج OPDS الأول لتبدأ في تصفح الكتب",
|
||||
"Add Your First Catalog": "أضف كتالوجك الأول",
|
||||
"Browse": "تصفح",
|
||||
"Popular Catalogs": "الكتالوجات الشائعة",
|
||||
"Add": "إضافة",
|
||||
"Add OPDS Catalog": "إضافة كتالوج OPDS",
|
||||
"Catalog Name": "اسم الكتالوج",
|
||||
"My Calibre Library": "مكتبة Calibre الخاصة بي",
|
||||
"OPDS URL": "رابط OPDS",
|
||||
"Username (optional)": "اسم المستخدم (اختياري)",
|
||||
"Password (optional)": "كلمة المرور (اختياري)",
|
||||
"Description (optional)": "الوصف (اختياري)",
|
||||
"A brief description of this catalog": "وصف موجز لهذا الكتالوج",
|
||||
"Validating...": "جارٍ التحقق...",
|
||||
"View All": "عرض الكل",
|
||||
"Forward": "إلى الأمام",
|
||||
"OPDS Catalog": "كتالوج OPDS",
|
||||
"Home": "الصفحة الرئيسية",
|
||||
"Library": "المكتبة",
|
||||
"{{count}} items_zero": "{{count}} عناصر",
|
||||
"{{count}} items_one": "{{count}} عنصر",
|
||||
"{{count}} items_two": "{{count}} عنصران",
|
||||
"{{count}} items_few": "{{count}} عناصر",
|
||||
"{{count}} items_many": "{{count}} عنصرًا",
|
||||
"{{count}} items_other": "{{count}} من العناصر",
|
||||
"Download completed": "اكتمل التنزيل",
|
||||
"Download failed": "فشل التنزيل",
|
||||
"Open Access": "الوصول المفتوح",
|
||||
"Borrow": "استعارة",
|
||||
"Buy": "شراء",
|
||||
"Subscribe": "الاشتراك",
|
||||
"Sample": "عينة",
|
||||
"Download": "تنزيل",
|
||||
"Open & Read": "فتح وقراءة",
|
||||
"Tags": "العلامات",
|
||||
"Tag": "علامة",
|
||||
"First": "الأول",
|
||||
"Previous": "السابق",
|
||||
"Next": "التالي",
|
||||
"Last": "الأخير",
|
||||
"Cannot Load Page": "تعذر تحميل الصفحة",
|
||||
"An error occurred": "حدث خطأ ما"
|
||||
}
|
||||
|
||||
@@ -675,5 +675,50 @@
|
||||
"Download from Cloud": "ক্লাউড থেকে ডাউনলোড করুন",
|
||||
"Upload to Cloud": "ক্লাউডে আপলোড করুন",
|
||||
"Clear Custom Fonts": "কাস্টম ফন্টস মুছুন",
|
||||
"Columns": "কলামস"
|
||||
"Columns": "কলামস",
|
||||
"OPDS Catalogs": "OPDS ক্যাটালগস",
|
||||
"Adding LAN addresses is not supported in the web app version.": "ওয়েব অ্যাপ সংস্করণে LAN ঠিকানা যোগ করা সমর্থিত নয়।",
|
||||
"Invalid OPDS catalog. Please check the URL.": "অবৈধ OPDS ক্যাটালগ। দয়া করে URL চেক করুন।",
|
||||
"Browse and download books from online catalogs": "অনলাইন ক্যাটালগ থেকে বই ব্রাউজ এবং ডাউনলোড করুন",
|
||||
"My Catalogs": "আমার ক্যাটালগস",
|
||||
"Add Catalog": "ক্যাটালগ যোগ করুন",
|
||||
"No catalogs yet": "এখনও কোন ক্যাটালগ নেই",
|
||||
"Add your first OPDS catalog to start browsing books": "আপনার প্রথম OPDS ক্যাটালগ যোগ করুন বই ব্রাউজ শুরু করতে",
|
||||
"Add Your First Catalog": "আপনার প্রথম ক্যাটালগ যোগ করুন",
|
||||
"Browse": "ব্রাউজ করুন",
|
||||
"Popular Catalogs": "জনপ্রিয় ক্যাটালগস",
|
||||
"Add": "যোগ করুন",
|
||||
"Add OPDS Catalog": "OPDS ক্যাটালগ যোগ করুন",
|
||||
"Catalog Name": "ক্যাটালগের নাম",
|
||||
"My Calibre Library": "আমার ক্যালিব্র লাইব্রেরি",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "ব্যবহারকারীর নাম (ঐচ্ছিক)",
|
||||
"Password (optional)": "পাসওয়ার্ড (ঐচ্ছিক)",
|
||||
"Description (optional)": "বর্ণনা (ঐচ্ছিক)",
|
||||
"A brief description of this catalog": "এই ক্যাটালগের সংক্ষিপ্ত বিবরণ",
|
||||
"Validating...": "যাচাই করা হচ্ছে...",
|
||||
"View All": "সব দেখুন",
|
||||
"Forward": "ফরোয়ার্ড",
|
||||
"OPDS Catalog": "OPDS ক্যাটালগ",
|
||||
"Home": "হোম",
|
||||
"Library": "লাইব্রেরি",
|
||||
"{{count}} items_one": "{{count}} আইটেম",
|
||||
"{{count}} items_other": "{{count}} আইটেম",
|
||||
"Download completed": "ডাউনলোড সম্পন্ন হয়েছে",
|
||||
"Download failed": "ডাউনলোড ব্যর্থ হয়েছে",
|
||||
"Open Access": "ওপেন অ্যাক্সেস",
|
||||
"Borrow": "ঋণ নিন",
|
||||
"Buy": "কিনুন",
|
||||
"Subscribe": "সাবস্ক্রাইব করুন",
|
||||
"Sample": "নমুনা",
|
||||
"Download": "ডাউনলোড",
|
||||
"Open & Read": "খুলুন ও পড়ুন",
|
||||
"Tags": "ট্যাগসমূহ",
|
||||
"Tag": "ট্যাগ",
|
||||
"First": "প্রথম",
|
||||
"Previous": "পূর্ববর্তী",
|
||||
"Next": "পরবর্তী",
|
||||
"Last": "শেষ",
|
||||
"Cannot Load Page": "পৃষ্ঠা লোড করা যায়নি",
|
||||
"An error occurred": "একটি ত্রুটি ঘটেছে"
|
||||
}
|
||||
|
||||
@@ -671,5 +671,49 @@
|
||||
"Download from Cloud": "སྤྲིན་ནས་ཕབ་སྟེ་འབེབས་",
|
||||
"Upload to Cloud": "སྤྲིན་ལ་ཡར་སྤེལ་",
|
||||
"Clear Custom Fonts": "རང་བཞིན་ཡིག་གཟུགས་སུབ་པ།",
|
||||
"Columns": "མཚན་ཉིད།"
|
||||
"Columns": "མཚན་ཉིད།",
|
||||
"OPDS Catalogs": "OPDS གནས་ཚུལ།",
|
||||
"Adding LAN addresses is not supported in the web app version.": "གནས་ཚུལ་དང་ལུས་སྐོར་གྱི་གྲོང་ཁྱེར་གྱི་གློག་འཕྲིན་ལས་མཐུན་པ་མེད།",
|
||||
"Invalid OPDS catalog. Please check the URL.": "མི་འབྱུང་བའི OPDS གནས་ཚུལ། ཨེ་ཨོ་ཨེར་ལུས་སྐོར་བཟོ་བྱས།",
|
||||
"Browse and download books from online catalogs": "དེབ་གནས་བཅས་པའི་གྲོང་ཁྱེར་ལས་དེབ་འདེམས་དང་ཕབ་སྟེ་འབེབས།",
|
||||
"My Catalogs": "ངའི་དཀར་ཆག",
|
||||
"Add Catalog": "དཀར་ཆག་ཁ་སྣོན་",
|
||||
"No catalogs yet": "དཀར་ཆག་མེད་པས།",
|
||||
"Add your first OPDS catalog to start browsing books": "དེབ་ལྟ་བཤེར་འགོ་བཙུགས་ནས་ དང་པོའི་ OPDS དཀར་ཆག་ཁ་སྣོན་གནང་།",
|
||||
"Add Your First Catalog": "དཀར་ཆག་དང་པོ་ཁ་སྣོན་",
|
||||
"Browse": "ལྟ་བཤེར་",
|
||||
"Popular Catalogs": "མི་མང་མཐོང་བའི་དཀར་ཆག",
|
||||
"Add": "ཁ་སྣོན་",
|
||||
"Add OPDS Catalog": "OPDS དཀར་ཆག་ཁ་སྣོན་",
|
||||
"Catalog Name": "དཀར་ཆག་མིང་",
|
||||
"My Calibre Library": "ངའི Calibre དེབ་མཛོད་",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "མིང་སྒྲོམ་ (འདེམས་རུང་)",
|
||||
"Password (optional)": "གསང་ཚིག (འདེམས་རུང་)",
|
||||
"Description (optional)": "འགྲེལ་བཤད་ (འདེམས་རུང་)",
|
||||
"A brief description of this catalog": "དཀར་ཆག་འདིའི་གསལ་བཤད་ཐུང་ཐུང་",
|
||||
"Validating...": "བདེན་སྦྱོར་བཞིན་...",
|
||||
"View All": "ཡོངས་ལྟ་བ་",
|
||||
"Forward": "མདུན་དུ་",
|
||||
"OPDS Catalog": "OPDS དཀར་ཆག",
|
||||
"Home": "གཙོ་ངོས་",
|
||||
"Library": "དེབ་མཛོད་",
|
||||
"{{count}} items_other": "{{count}} རྣམ་གྲངས་",
|
||||
"Download completed": "ཕབ་ལེན་རྫོགས་སོང་།",
|
||||
"Download failed": "ཕབ་ལེན་ཕམ་པ་",
|
||||
"Open Access": "ཁ་ཕྱབ་ལྟ་བཤེར་",
|
||||
"Borrow": "ལེན་པ་",
|
||||
"Buy": "ཉོ་",
|
||||
"Subscribe": "མཁོ་མངགས་",
|
||||
"Sample": "དཔེ་དབང་",
|
||||
"Download": "ཕབ་ལེན་",
|
||||
"Open & Read": "ཁ་ཕྱེས་ནས་ཀློག་",
|
||||
"Tags": "མཚོན་འགྲེལ་",
|
||||
"Tag": "མཚོན་འགྲེལ་",
|
||||
"First": "དང་པོ།",
|
||||
"Previous": "སྔོན་མ།",
|
||||
"Next": "རྗེས་མ།",
|
||||
"Last": "མཐའ་མ།",
|
||||
"Cannot Load Page": "ཤོག་ངོས་འགུལ་སྐྱོང་བྱེད་ཐུབ་མེད།",
|
||||
"An error occurred": "ནོར་འཁྲུལ་ཞིག་བྱུང་སོང་།"
|
||||
}
|
||||
|
||||
@@ -675,5 +675,50 @@
|
||||
"Download from Cloud": "Aus der Cloud herunterladen",
|
||||
"Upload to Cloud": "In die Cloud hochladen",
|
||||
"Clear Custom Fonts": "Benutzerdefinierte Schriftarten löschen",
|
||||
"Columns": "Spalten"
|
||||
"Columns": "Spalten",
|
||||
"OPDS Catalogs": "OPDS-Kataloge",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Das Hinzufügen von LAN-Adressen wird in der Web-App nicht unterstützt.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Ungültiger OPDS-Katalog. Bitte überprüfe die URL.",
|
||||
"Browse and download books from online catalogs": "Bücher aus Online-Katalogen durchsuchen und herunterladen",
|
||||
"My Catalogs": "Meine Kataloge",
|
||||
"Add Catalog": "Katalog hinzufügen",
|
||||
"No catalogs yet": "Noch keine Kataloge",
|
||||
"Add your first OPDS catalog to start browsing books": "Füge deinen ersten OPDS-Katalog hinzu, um Bücher zu durchsuchen.",
|
||||
"Add Your First Catalog": "Ersten Katalog hinzufügen",
|
||||
"Browse": "Durchsuchen",
|
||||
"Popular Catalogs": "Beliebte Kataloge",
|
||||
"Add": "Hinzufügen",
|
||||
"Add OPDS Catalog": "OPDS-Katalog hinzufügen",
|
||||
"Catalog Name": "Katalogname",
|
||||
"My Calibre Library": "Meine Calibre-Bibliothek",
|
||||
"OPDS URL": "OPDS-URL",
|
||||
"Username (optional)": "Benutzername (optional)",
|
||||
"Password (optional)": "Passwort (optional)",
|
||||
"Description (optional)": "Beschreibung (optional)",
|
||||
"A brief description of this catalog": "Kurzbeschreibung dieses Katalogs",
|
||||
"Validating...": "Wird überprüft...",
|
||||
"View All": "Alle anzeigen",
|
||||
"Forward": "Weiter",
|
||||
"OPDS Catalog": "OPDS-Katalog",
|
||||
"Home": "Start",
|
||||
"Library": "Bibliothek",
|
||||
"{{count}} items_one": "{{count}} Element",
|
||||
"{{count}} items_other": "{{count}} Elemente",
|
||||
"Download completed": "Download abgeschlossen",
|
||||
"Download failed": "Download fehlgeschlagen",
|
||||
"Open Access": "Offener Zugriff",
|
||||
"Borrow": "Ausleihen",
|
||||
"Buy": "Kaufen",
|
||||
"Subscribe": "Abonnieren",
|
||||
"Sample": "Leseprobe",
|
||||
"Download": "Herunterladen",
|
||||
"Open & Read": "Öffnen & Lesen",
|
||||
"Tags": "Tags",
|
||||
"Tag": "Tag",
|
||||
"First": "Erste",
|
||||
"Previous": "Vorherige",
|
||||
"Next": "Nächste",
|
||||
"Last": "Letzte",
|
||||
"Cannot Load Page": "Seite kann nicht geladen werden",
|
||||
"An error occurred": "Ein Fehler ist aufgetreten"
|
||||
}
|
||||
|
||||
@@ -675,5 +675,50 @@
|
||||
"Download from Cloud": "Λήψη από το Cloud",
|
||||
"Upload to Cloud": "Ανέβασμα στο Cloud",
|
||||
"Clear Custom Fonts": "Καθαρισμός προσαρμοσμένων γραμματοσειρών",
|
||||
"Columns": "Στήλες"
|
||||
"Columns": "Στήλες",
|
||||
"OPDS Catalogs": "Κατάλογοι OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Η προσθήκη διευθύνσεων LAN δεν υποστηρίζεται στην έκδοση web της εφαρμογής.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Μη έγκυρος κατάλογος OPDS. Ελέγξτε το URL.",
|
||||
"Browse and download books from online catalogs": "Περιηγηθείτε και κατεβάστε βιβλία από online καταλόγους",
|
||||
"My Catalogs": "Οι Κατάλογοί μου",
|
||||
"Add Catalog": "Προσθήκη Καταλόγου",
|
||||
"No catalogs yet": "Δεν υπάρχουν ακόμα κατάλογοι",
|
||||
"Add your first OPDS catalog to start browsing books": "Προσθέστε τον πρώτο σας κατάλογο OPDS για να αρχίσετε την περιήγηση.",
|
||||
"Add Your First Catalog": "Προσθήκη Πρώτου Καταλόγου",
|
||||
"Browse": "Περιήγηση",
|
||||
"Popular Catalogs": "Δημοφιλείς Κατάλογοι",
|
||||
"Add": "Προσθήκη",
|
||||
"Add OPDS Catalog": "Προσθήκη Καταλόγου OPDS",
|
||||
"Catalog Name": "Όνομα Καταλόγου",
|
||||
"My Calibre Library": "Η Βιβλιοθήκη μου στο Calibre",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "Όνομα χρήστη (προαιρετικό)",
|
||||
"Password (optional)": "Κωδικός πρόσβασης (προαιρετικό)",
|
||||
"Description (optional)": "Περιγραφή (προαιρετική)",
|
||||
"A brief description of this catalog": "Μια σύντομη περιγραφή του καταλόγου",
|
||||
"Validating...": "Γίνεται έλεγχος...",
|
||||
"View All": "Προβολή όλων",
|
||||
"Forward": "Μπροστά",
|
||||
"OPDS Catalog": "Κατάλογος OPDS",
|
||||
"Home": "Αρχική",
|
||||
"Library": "Βιβλιοθήκη",
|
||||
"{{count}} items_one": "{{count}} στοιχείο",
|
||||
"{{count}} items_other": "{{count}} στοιχεία",
|
||||
"Download completed": "Η λήψη ολοκληρώθηκε",
|
||||
"Download failed": "Η λήψη απέτυχε",
|
||||
"Open Access": "Ανοιχτή πρόσβαση",
|
||||
"Borrow": "Δανεισμός",
|
||||
"Buy": "Αγορά",
|
||||
"Subscribe": "Συνδρομή",
|
||||
"Sample": "Δείγμα",
|
||||
"Download": "Λήψη",
|
||||
"Open & Read": "Άνοιγμα & Ανάγνωση",
|
||||
"Tags": "Ετικέτες",
|
||||
"Tag": "Ετικέτα",
|
||||
"First": "Πρώτο",
|
||||
"Previous": "Προηγούμενο",
|
||||
"Next": "Επόμενο",
|
||||
"Last": "Τελευταίο",
|
||||
"Cannot Load Page": "Δεν είναι δυνατή η φόρτωση της σελίδας",
|
||||
"An error occurred": "Προέκυψε σφάλμα"
|
||||
}
|
||||
|
||||
@@ -679,5 +679,51 @@
|
||||
"Download from Cloud": "Descargar desde la nube",
|
||||
"Upload to Cloud": "Subir a la nube",
|
||||
"Clear Custom Fonts": "Limpiar fuentes personalizadas",
|
||||
"Columns": "Columnas"
|
||||
"Columns": "Columnas",
|
||||
"OPDS Catalogs": "Catálogos OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "La versión web no permite añadir direcciones LAN.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Catálogo OPDS no válido. Por favor, comprueba la URL.",
|
||||
"Browse and download books from online catalogs": "Explora y descarga libros de catálogos en línea",
|
||||
"My Catalogs": "Mis catálogos",
|
||||
"Add Catalog": "Añadir catálogo",
|
||||
"No catalogs yet": "Aún no hay catálogos",
|
||||
"Add your first OPDS catalog to start browsing books": "Añade tu primer catálogo OPDS para empezar a explorar libros.",
|
||||
"Add Your First Catalog": "Añadir primer catálogo",
|
||||
"Browse": "Explorar",
|
||||
"Popular Catalogs": "Catálogos populares",
|
||||
"Add": "Añadir",
|
||||
"Add OPDS Catalog": "Añadir catálogo OPDS",
|
||||
"Catalog Name": "Nombre del catálogo",
|
||||
"My Calibre Library": "Mi biblioteca Calibre",
|
||||
"OPDS URL": "URL OPDS",
|
||||
"Username (optional)": "Usuario (opcional)",
|
||||
"Password (optional)": "Contraseña (opcional)",
|
||||
"Description (optional)": "Descripción (opcional)",
|
||||
"A brief description of this catalog": "Una breve descripción de este catálogo",
|
||||
"Validating...": "Validando...",
|
||||
"View All": "Ver todo",
|
||||
"Forward": "Adelante",
|
||||
"OPDS Catalog": "Catálogo OPDS",
|
||||
"Home": "Inicio",
|
||||
"Library": "Biblioteca",
|
||||
"{{count}} items_one": "{{count}} elemento",
|
||||
"{{count}} items_many": "{{count}} elementos",
|
||||
"{{count}} items_other": "{{count}} elementos",
|
||||
"Download completed": "Descarga completa",
|
||||
"Download failed": "Error en la descarga",
|
||||
"Open Access": "Acceso abierto",
|
||||
"Borrow": "Prestar",
|
||||
"Buy": "Comprar",
|
||||
"Subscribe": "Suscribirse",
|
||||
"Sample": "Muestra",
|
||||
"Download": "Descargar",
|
||||
"Open & Read": "Abrir y leer",
|
||||
"Tags": "Etiquetas",
|
||||
"Tag": "Etiqueta",
|
||||
"First": "Primero",
|
||||
"Previous": "Anterior",
|
||||
"Next": "Siguiente",
|
||||
"Last": "Último",
|
||||
"Cannot Load Page": "No se puede cargar la página",
|
||||
"An error occurred": "Ocurrió un error"
|
||||
}
|
||||
|
||||
@@ -675,5 +675,50 @@
|
||||
"Download from Cloud": "دانلود از فضای ابری",
|
||||
"Upload to Cloud": "آپلود به فضای ابری",
|
||||
"Clear Custom Fonts": "پاککردن فونتهای سفارشی",
|
||||
"Columns": "ستونها"
|
||||
"Columns": "ستونها",
|
||||
"OPDS Catalogs": "فهرستهای OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "افزودن آدرسهای LAN در نسخه وب پشتیبانی نمیشود.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "فهرست OPDS نامعتبر است. لطفاً آدرس را بررسی کنید.",
|
||||
"Browse and download books from online catalogs": "مرور و دانلود کتابها از فهرستهای آنلاین",
|
||||
"My Catalogs": "فهرستهای من",
|
||||
"Add Catalog": "افزودن فهرست",
|
||||
"No catalogs yet": "هنوز فهرستی وجود ندارد",
|
||||
"Add your first OPDS catalog to start browsing books": "برای شروع مرور کتابها، اولین فهرست OPDS خود را اضافه کنید.",
|
||||
"Add Your First Catalog": "افزودن اولین فهرست",
|
||||
"Browse": "مرور",
|
||||
"Popular Catalogs": "فهرستهای محبوب",
|
||||
"Add": "افزودن",
|
||||
"Add OPDS Catalog": "افزودن فهرست OPDS",
|
||||
"Catalog Name": "نام فهرست",
|
||||
"My Calibre Library": "کتابخانه Calibre من",
|
||||
"OPDS URL": "آدرس OPDS",
|
||||
"Username (optional)": "نام کاربری (اختیاری)",
|
||||
"Password (optional)": "رمز عبور (اختیاری)",
|
||||
"Description (optional)": "توضیحات (اختیاری)",
|
||||
"A brief description of this catalog": "توضیح کوتاهی درباره این فهرست",
|
||||
"Validating...": "در حال بررسی...",
|
||||
"View All": "مشاهده همه",
|
||||
"Forward": "بعدی",
|
||||
"OPDS Catalog": "فهرست OPDS",
|
||||
"Home": "خانه",
|
||||
"Library": "کتابخانه",
|
||||
"{{count}} items_one": "{{count}} مورد",
|
||||
"{{count}} items_other": "{{count}} مورد",
|
||||
"Download completed": "دانلود کامل شد",
|
||||
"Download failed": "دانلود ناموفق بود",
|
||||
"Open Access": "دسترسی آزاد",
|
||||
"Borrow": "امانت گرفتن",
|
||||
"Buy": "خرید",
|
||||
"Subscribe": "اشتراک",
|
||||
"Sample": "نمونه",
|
||||
"Download": "دانلود",
|
||||
"Open & Read": "باز کردن و خواندن",
|
||||
"Tags": "برچسبها",
|
||||
"Tag": "برچسب",
|
||||
"First": "اولین",
|
||||
"Previous": "قبلی",
|
||||
"Next": "بعدی",
|
||||
"Last": "آخرین",
|
||||
"Cannot Load Page": "بارگذاری صفحه امکانپذیر نیست",
|
||||
"An error occurred": "خطایی رخ داد"
|
||||
}
|
||||
|
||||
@@ -679,5 +679,51 @@
|
||||
"Download from Cloud": "Télécharger depuis le Cloud",
|
||||
"Upload to Cloud": "Téléverser vers le Cloud",
|
||||
"Clear Custom Fonts": "Effacer les Polices Personnalisées",
|
||||
"Columns": "Colonnes"
|
||||
"Columns": "Colonnes",
|
||||
"OPDS Catalogs": "Catalogues OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "L’ajout d’adresses LAN n’est pas pris en charge dans la version web.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Catalogue OPDS invalide. Veuillez vérifier l’URL.",
|
||||
"Browse and download books from online catalogs": "Parcourez et téléchargez des livres depuis des catalogues en ligne",
|
||||
"My Catalogs": "Mes catalogues",
|
||||
"Add Catalog": "Ajouter un catalogue",
|
||||
"No catalogs yet": "Aucun catalogue pour le moment",
|
||||
"Add your first OPDS catalog to start browsing books": "Ajoutez votre premier catalogue OPDS pour commencer à parcourir des livres.",
|
||||
"Add Your First Catalog": "Ajouter votre premier catalogue",
|
||||
"Browse": "Parcourir",
|
||||
"Popular Catalogs": "Catalogues populaires",
|
||||
"Add": "Ajouter",
|
||||
"Add OPDS Catalog": "Ajouter un catalogue OPDS",
|
||||
"Catalog Name": "Nom du catalogue",
|
||||
"My Calibre Library": "Ma bibliothèque Calibre",
|
||||
"OPDS URL": "URL OPDS",
|
||||
"Username (optional)": "Nom d’utilisateur (optionnel)",
|
||||
"Password (optional)": "Mot de passe (optionnel)",
|
||||
"Description (optional)": "Description (optionnelle)",
|
||||
"A brief description of this catalog": "Brève description de ce catalogue",
|
||||
"Validating...": "Validation...",
|
||||
"View All": "Tout afficher",
|
||||
"Forward": "Suivant",
|
||||
"OPDS Catalog": "Catalogue OPDS",
|
||||
"Home": "Accueil",
|
||||
"Library": "Bibliothèque",
|
||||
"{{count}} items_one": "{{count}} élément",
|
||||
"{{count}} items_many": "{{count}} éléments",
|
||||
"{{count}} items_other": "{{count}} éléments",
|
||||
"Download completed": "Téléchargement terminé",
|
||||
"Download failed": "Échec du téléchargement",
|
||||
"Open Access": "Accès libre",
|
||||
"Borrow": "Emprunter",
|
||||
"Buy": "Acheter",
|
||||
"Subscribe": "S’abonner",
|
||||
"Sample": "Extrait",
|
||||
"Download": "Télécharger",
|
||||
"Open & Read": "Ouvrir et lire",
|
||||
"Tags": "Tags",
|
||||
"Tag": "Tag",
|
||||
"First": "Premier",
|
||||
"Previous": "Précédent",
|
||||
"Next": "Suivant",
|
||||
"Last": "Dernier",
|
||||
"Cannot Load Page": "Impossible de charger la page",
|
||||
"An error occurred": "Une erreur est survenue"
|
||||
}
|
||||
|
||||
@@ -675,5 +675,50 @@
|
||||
"Download from Cloud": "क्लाउड से डाउनलोड करें",
|
||||
"Upload to Cloud": "क्लाउड पर अपलोड करें",
|
||||
"Clear Custom Fonts": "कस्टम फ़ॉन्ट साफ़ करें",
|
||||
"Columns": "कॉलम"
|
||||
"Columns": "कॉलम",
|
||||
"OPDS Catalogs": "OPDS कैटलॉग",
|
||||
"Adding LAN addresses is not supported in the web app version.": "वेब ऐप संस्करण में LAN पता जोड़ना समर्थित नहीं है।",
|
||||
"Invalid OPDS catalog. Please check the URL.": "अमान्य OPDS कैटलॉग। कृपया URL जांचें।",
|
||||
"Browse and download books from online catalogs": "ऑनलाइन कैटलॉग से किताबें ब्राउज़ और डाउनलोड करें",
|
||||
"My Catalogs": "मेरे कैटलॉग",
|
||||
"Add Catalog": "कैटलॉग जोड़ें",
|
||||
"No catalogs yet": "अभी कोई कैटलॉग नहीं",
|
||||
"Add your first OPDS catalog to start browsing books": "किताबें ब्राउज़ करने के लिए अपना पहला OPDS कैटलॉग जोड़ें",
|
||||
"Add Your First Catalog": "अपना पहला कैटलॉग जोड़ें",
|
||||
"Browse": "ब्राउज़",
|
||||
"Popular Catalogs": "लोकप्रिय कैटलॉग",
|
||||
"Add": "जोड़ें",
|
||||
"Add OPDS Catalog": "OPDS कैटलॉग जोड़ें",
|
||||
"Catalog Name": "कैटलॉग नाम",
|
||||
"My Calibre Library": "मेरी Calibre लाइब्रेरी",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "यूज़रनेम (वैकल्पिक)",
|
||||
"Password (optional)": "पासवर्ड (वैकल्पिक)",
|
||||
"Description (optional)": "विवरण (वैकल्पिक)",
|
||||
"A brief description of this catalog": "इस कैटलॉग का संक्षिप्त विवरण",
|
||||
"Validating...": "मान्य किया जा रहा है...",
|
||||
"View All": "सभी देखें",
|
||||
"Forward": "आगे",
|
||||
"OPDS Catalog": "OPDS कैटलॉग",
|
||||
"Home": "होम",
|
||||
"Library": "लाइब्रेरी",
|
||||
"{{count}} items_one": "{{count}} आइटम",
|
||||
"{{count}} items_other": "{{count}} आइटम",
|
||||
"Download completed": "डाउनलोड पूरा हुआ",
|
||||
"Download failed": "डाउनलोड विफल हुआ",
|
||||
"Open Access": "ओपन एक्सेस",
|
||||
"Borrow": "उधार लें",
|
||||
"Buy": "खरीदें",
|
||||
"Subscribe": "सदस्यता लें",
|
||||
"Sample": "नमूना",
|
||||
"Download": "डाउनलोड",
|
||||
"Open & Read": "खोलें और पढ़ें",
|
||||
"Tags": "टैग",
|
||||
"Tag": "टैग",
|
||||
"First": "पहला",
|
||||
"Previous": "पिछला",
|
||||
"Next": "अगला",
|
||||
"Last": "अंतिम",
|
||||
"Cannot Load Page": "पृष्ठ लोड नहीं किया जा सका",
|
||||
"An error occurred": "एक त्रुटि हुई"
|
||||
}
|
||||
|
||||
@@ -671,5 +671,49 @@
|
||||
"Download from Cloud": "Unduh dari Cloud",
|
||||
"Upload to Cloud": "Unggah ke Cloud",
|
||||
"Clear Custom Fonts": "Bersihkan Font Kustom",
|
||||
"Columns": "Kolom"
|
||||
"Columns": "Kolom",
|
||||
"OPDS Catalogs": "Katalog OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Menambahkan alamat LAN tidak didukung di versi web.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Katalog OPDS tidak valid. Silakan periksa URL-nya.",
|
||||
"Browse and download books from online catalogs": "Jelajahi dan unduh buku dari katalog online",
|
||||
"My Catalogs": "Katalog Saya",
|
||||
"Add Catalog": "Tambah Katalog",
|
||||
"No catalogs yet": "Belum ada katalog",
|
||||
"Add your first OPDS catalog to start browsing books": "Tambahkan katalog OPDS pertama Anda untuk mulai menjelajahi buku",
|
||||
"Add Your First Catalog": "Tambah Katalog Pertama Anda",
|
||||
"Browse": "Jelajahi",
|
||||
"Popular Catalogs": "Katalog Populer",
|
||||
"Add": "Tambah",
|
||||
"Add OPDS Catalog": "Tambah Katalog OPDS",
|
||||
"Catalog Name": "Nama Katalog",
|
||||
"My Calibre Library": "Perpustakaan Calibre Saya",
|
||||
"OPDS URL": "URL OPDS",
|
||||
"Username (optional)": "Username (opsional)",
|
||||
"Password (optional)": "Password (opsional)",
|
||||
"Description (optional)": "Deskripsi (opsional)",
|
||||
"A brief description of this catalog": "Deskripsi singkat untuk katalog ini",
|
||||
"Validating...": "Memvalidasi...",
|
||||
"View All": "Lihat Semua",
|
||||
"Forward": "Maju",
|
||||
"OPDS Catalog": "Katalog OPDS",
|
||||
"Home": "Beranda",
|
||||
"Library": "Perpustakaan",
|
||||
"{{count}} items_other": "{{count}} item",
|
||||
"Download completed": "Unduhan selesai",
|
||||
"Download failed": "Unduhan gagal",
|
||||
"Open Access": "Akses Terbuka",
|
||||
"Borrow": "Pinjam",
|
||||
"Buy": "Beli",
|
||||
"Subscribe": "Berlangganan",
|
||||
"Sample": "Contoh",
|
||||
"Download": "Unduh",
|
||||
"Open & Read": "Buka & Baca",
|
||||
"Tags": "Tag",
|
||||
"Tag": "Tag",
|
||||
"First": "Pertama",
|
||||
"Previous": "Sebelumnya",
|
||||
"Next": "Berikutnya",
|
||||
"Last": "Terakhir",
|
||||
"Cannot Load Page": "Tidak dapat memuat halaman",
|
||||
"An error occurred": "Terjadi kesalahan"
|
||||
}
|
||||
|
||||
@@ -679,5 +679,51 @@
|
||||
"Download from Cloud": "Scarica dal Cloud",
|
||||
"Upload to Cloud": "Carica sul Cloud",
|
||||
"Clear Custom Fonts": "Pulisci Font Personalizzati",
|
||||
"Columns": "Colonne"
|
||||
"Columns": "Colonne",
|
||||
"OPDS Catalogs": "Cataloghi OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "L'aggiunta di indirizzi LAN non è supportata nella versione web.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Catalogo OPDS non valido. Controlla l'URL.",
|
||||
"Browse and download books from online catalogs": "Sfoglia e scarica libri dai cataloghi online",
|
||||
"My Catalogs": "I miei cataloghi",
|
||||
"Add Catalog": "Aggiungi catalogo",
|
||||
"No catalogs yet": "Nessun catalogo",
|
||||
"Add your first OPDS catalog to start browsing books": "Aggiungi il tuo primo catalogo OPDS per iniziare a sfogliare i libri",
|
||||
"Add Your First Catalog": "Aggiungi il tuo primo catalogo",
|
||||
"Browse": "Sfoglia",
|
||||
"Popular Catalogs": "Cataloghi popolari",
|
||||
"Add": "Aggiungi",
|
||||
"Add OPDS Catalog": "Aggiungi catalogo OPDS",
|
||||
"Catalog Name": "Nome del catalogo",
|
||||
"My Calibre Library": "La mia libreria Calibre",
|
||||
"OPDS URL": "URL OPDS",
|
||||
"Username (optional)": "Username (opzionale)",
|
||||
"Password (optional)": "Password (opzionale)",
|
||||
"Description (optional)": "Descrizione (opzionale)",
|
||||
"A brief description of this catalog": "Breve descrizione del catalogo",
|
||||
"Validating...": "Convalida in corso...",
|
||||
"View All": "Vedi tutto",
|
||||
"Forward": "Avanti",
|
||||
"OPDS Catalog": "Catalogo OPDS",
|
||||
"Home": "Home",
|
||||
"Library": "Libreria",
|
||||
"{{count}} items_one": "{{count}} elemento",
|
||||
"{{count}} items_many": "{{count}} elementi",
|
||||
"{{count}} items_other": "{{count}} elementi",
|
||||
"Download completed": "Download completato",
|
||||
"Download failed": "Download non riuscito",
|
||||
"Open Access": "Accesso libero",
|
||||
"Borrow": "Prendi in prestito",
|
||||
"Buy": "Acquista",
|
||||
"Subscribe": "Abbonati",
|
||||
"Sample": "Anteprima",
|
||||
"Download": "Scarica",
|
||||
"Open & Read": "Apri e leggi",
|
||||
"Tags": "Tag",
|
||||
"Tag": "Tag",
|
||||
"First": "Primo",
|
||||
"Previous": "Precedente",
|
||||
"Next": "Successivo",
|
||||
"Last": "Ultimo",
|
||||
"Cannot Load Page": "Impossibile caricare la pagina",
|
||||
"An error occurred": "Si è verificato un errore"
|
||||
}
|
||||
|
||||
@@ -671,5 +671,49 @@
|
||||
"Download from Cloud": "クラウドからダウンロード",
|
||||
"Upload to Cloud": "クラウドにアップロード",
|
||||
"Clear Custom Fonts": "カスタムフォントをクリア",
|
||||
"Columns": "列"
|
||||
"Columns": "列",
|
||||
"OPDS Catalogs": "OPDSカタログ",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Webアプリ版ではLANアドレスの追加はサポートされていません。",
|
||||
"Invalid OPDS catalog. Please check the URL.": "無効なOPDSカタログです。URLを確認してください。",
|
||||
"Browse and download books from online catalogs": "オンラインカタログから本を閲覧・ダウンロード",
|
||||
"My Catalogs": "マイカタログ",
|
||||
"Add Catalog": "カタログを追加",
|
||||
"No catalogs yet": "まだカタログはありません",
|
||||
"Add your first OPDS catalog to start browsing books": "本を閲覧するには、最初のOPDSカタログを追加してください。",
|
||||
"Add Your First Catalog": "最初のカタログを追加",
|
||||
"Browse": "閲覧",
|
||||
"Popular Catalogs": "人気カタログ",
|
||||
"Add": "追加",
|
||||
"Add OPDS Catalog": "OPDSカタログを追加",
|
||||
"Catalog Name": "カタログ名",
|
||||
"My Calibre Library": "私のCalibreライブラリ",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "ユーザー名(任意)",
|
||||
"Password (optional)": "パスワード(任意)",
|
||||
"Description (optional)": "説明(任意)",
|
||||
"A brief description of this catalog": "このカタログの簡単な説明",
|
||||
"Validating...": "検証中...",
|
||||
"View All": "すべて表示",
|
||||
"Forward": "進む",
|
||||
"OPDS Catalog": "OPDSカタログ",
|
||||
"Home": "ホーム",
|
||||
"Library": "ライブラリ",
|
||||
"{{count}} items_other": "{{count}} 件",
|
||||
"Download completed": "ダウンロード完了",
|
||||
"Download failed": "ダウンロード失敗",
|
||||
"Open Access": "オープンアクセス",
|
||||
"Borrow": "借りる",
|
||||
"Buy": "購入",
|
||||
"Subscribe": "購読",
|
||||
"Sample": "サンプル",
|
||||
"Download": "ダウンロード",
|
||||
"Open & Read": "開いて読む",
|
||||
"Tags": "タグ",
|
||||
"Tag": "タグ",
|
||||
"First": "最初",
|
||||
"Previous": "前へ",
|
||||
"Next": "次へ",
|
||||
"Last": "最後",
|
||||
"Cannot Load Page": "ページを読み込めません",
|
||||
"An error occurred": "エラーが発生しました"
|
||||
}
|
||||
|
||||
@@ -671,5 +671,49 @@
|
||||
"Download from Cloud": "클라우드에서 다운로드",
|
||||
"Upload to Cloud": "클라우드에 업로드",
|
||||
"Clear Custom Fonts": "사용자 정의 글꼴 지우기",
|
||||
"Columns": "열"
|
||||
"Columns": "열",
|
||||
"OPDS Catalogs": "OPDS 카탈로그",
|
||||
"Adding LAN addresses is not supported in the web app version.": "웹 앱 버전에서는 LAN 주소 추가를 지원하지 않습니다.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "잘못된 OPDS 카탈로그입니다. URL을 확인해주세요.",
|
||||
"Browse and download books from online catalogs": "온라인 카탈로그에서 책을 탐색하고 다운로드",
|
||||
"My Catalogs": "내 카탈로그",
|
||||
"Add Catalog": "카탈로그 추가",
|
||||
"No catalogs yet": "아직 카탈로그가 없습니다",
|
||||
"Add your first OPDS catalog to start browsing books": "책을 탐색하려면 첫 번째 OPDS 카탈로그를 추가하세요.",
|
||||
"Add Your First Catalog": "첫 번째 카탈로그 추가",
|
||||
"Browse": "탐색",
|
||||
"Popular Catalogs": "인기 카탈로그",
|
||||
"Add": "추가",
|
||||
"Add OPDS Catalog": "OPDS 카탈로그 추가",
|
||||
"Catalog Name": "카탈로그 이름",
|
||||
"My Calibre Library": "내 Calibre 라이브러리",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "사용자 이름 (선택 사항)",
|
||||
"Password (optional)": "비밀번호 (선택 사항)",
|
||||
"Description (optional)": "설명 (선택 사항)",
|
||||
"A brief description of this catalog": "이 카탈로그의 간단한 설명",
|
||||
"Validating...": "검증 중...",
|
||||
"View All": "모두 보기",
|
||||
"Forward": "다음",
|
||||
"OPDS Catalog": "OPDS 카탈로그",
|
||||
"Home": "홈",
|
||||
"Library": "라이브러리",
|
||||
"{{count}} items_other": "{{count}}개 항목",
|
||||
"Download completed": "다운로드 완료",
|
||||
"Download failed": "다운로드 실패",
|
||||
"Open Access": "오픈 액세스",
|
||||
"Borrow": "대출",
|
||||
"Buy": "구매",
|
||||
"Subscribe": "구독",
|
||||
"Sample": "샘플",
|
||||
"Download": "다운로드",
|
||||
"Open & Read": "열기 및 읽기",
|
||||
"Tags": "태그",
|
||||
"Tag": "태그",
|
||||
"First": "처음",
|
||||
"Previous": "이전",
|
||||
"Next": "다음",
|
||||
"Last": "마지막",
|
||||
"Cannot Load Page": "페이지를 불러올 수 없습니다",
|
||||
"An error occurred": "오류가 발생했습니다"
|
||||
}
|
||||
|
||||
@@ -671,5 +671,49 @@
|
||||
"Reveal in Finder": "Tunjukkan dalam Finder",
|
||||
"Reveal in File Explorer": "Tunjukkan dalam File Explorer",
|
||||
"Reveal in Folder": "Tunjukkan dalam Folder",
|
||||
"Columns": "Lajur"
|
||||
"Columns": "Lajur",
|
||||
"OPDS Catalogs": "Katalog OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Menambah alamat LAN tidak disokong dalam versi web.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Katalog OPDS tidak sah. Sila semak URL.",
|
||||
"Browse and download books from online catalogs": "Terokai dan muat turun buku dari katalog dalam talian",
|
||||
"My Catalogs": "Katalog Saya",
|
||||
"Add Catalog": "Tambah Katalog",
|
||||
"No catalogs yet": "Tiada katalog lagi",
|
||||
"Add your first OPDS catalog to start browsing books": "Tambah katalog OPDS pertama anda untuk mula meneroka buku",
|
||||
"Add Your First Catalog": "Tambah Katalog Pertama Anda",
|
||||
"Browse": "Terokai",
|
||||
"Popular Catalogs": "Katalog Popular",
|
||||
"Add": "Tambah",
|
||||
"Add OPDS Catalog": "Tambah Katalog OPDS",
|
||||
"Catalog Name": "Nama Katalog",
|
||||
"My Calibre Library": "Perpustakaan Calibre Saya",
|
||||
"OPDS URL": "URL OPDS",
|
||||
"Username (optional)": "Nama Pengguna (pilihan)",
|
||||
"Password (optional)": "Kata Laluan (pilihan)",
|
||||
"Description (optional)": "Penerangan (pilihan)",
|
||||
"A brief description of this catalog": "Penerangan ringkas katalog ini",
|
||||
"Validating...": "Mengesahkan...",
|
||||
"View All": "Lihat Semua",
|
||||
"Forward": "Maju",
|
||||
"OPDS Catalog": "Katalog OPDS",
|
||||
"Home": "Laman Utama",
|
||||
"Library": "Perpustakaan",
|
||||
"{{count}} items_other": "{{count}} item",
|
||||
"Download completed": "Muat turun selesai",
|
||||
"Download failed": "Muat turun gagal",
|
||||
"Open Access": "Akses Terbuka",
|
||||
"Borrow": "Pinjam",
|
||||
"Buy": "Beli",
|
||||
"Subscribe": "Langgan",
|
||||
"Sample": "Contoh",
|
||||
"Download": "Muat turun",
|
||||
"Open & Read": "Buka & Baca",
|
||||
"Tags": "Tag",
|
||||
"Tag": "Tag",
|
||||
"First": "Pertama",
|
||||
"Previous": "Sebelumnya",
|
||||
"Next": "Seterusnya",
|
||||
"Last": "Terakhir",
|
||||
"Cannot Load Page": "Tidak dapat memuatkan halaman",
|
||||
"An error occurred": "Ralat telah berlaku"
|
||||
}
|
||||
|
||||
@@ -675,5 +675,50 @@
|
||||
"Download from Cloud": "Downloaden van Cloud",
|
||||
"Upload to Cloud": "Uploaden naar Cloud",
|
||||
"Clear Custom Fonts": "Aangepaste Lettertypen Wissen",
|
||||
"Columns": "Kolommen"
|
||||
"Columns": "Kolommen",
|
||||
"OPDS Catalogs": "OPDS-catalogi",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Het toevoegen van LAN-adressen wordt niet ondersteund in de webversie.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Ongeldige OPDS-catalogus. Controleer de URL.",
|
||||
"Browse and download books from online catalogs": "Blader door en download boeken uit online catalogi",
|
||||
"My Catalogs": "Mijn catalogi",
|
||||
"Add Catalog": "Catalogus toevoegen",
|
||||
"No catalogs yet": "Nog geen catalogi",
|
||||
"Add your first OPDS catalog to start browsing books": "Voeg je eerste OPDS-catalogus toe om boeken te bekijken",
|
||||
"Add Your First Catalog": "Voeg je eerste catalogus toe",
|
||||
"Browse": "Bladeren",
|
||||
"Popular Catalogs": "Populaire catalogi",
|
||||
"Add": "Toevoegen",
|
||||
"Add OPDS Catalog": "OPDS-catalogus toevoegen",
|
||||
"Catalog Name": "Catalogusnaam",
|
||||
"My Calibre Library": "Mijn Calibre-bibliotheek",
|
||||
"OPDS URL": "OPDS-URL",
|
||||
"Username (optional)": "Gebruikersnaam (optioneel)",
|
||||
"Password (optional)": "Wachtwoord (optioneel)",
|
||||
"Description (optional)": "Beschrijving (optioneel)",
|
||||
"A brief description of this catalog": "Een korte beschrijving van deze catalogus",
|
||||
"Validating...": "Valideren...",
|
||||
"View All": "Alles bekijken",
|
||||
"Forward": "Verder",
|
||||
"OPDS Catalog": "OPDS-catalogus",
|
||||
"Home": "Startpagina",
|
||||
"Library": "Bibliotheek",
|
||||
"{{count}} items_one": "{{count}} item",
|
||||
"{{count}} items_other": "{{count}} items",
|
||||
"Download completed": "Download voltooid",
|
||||
"Download failed": "Download mislukt",
|
||||
"Open Access": "Open toegang",
|
||||
"Borrow": "Lenen",
|
||||
"Buy": "Kopen",
|
||||
"Subscribe": "Abonneren",
|
||||
"Sample": "Voorbeeld",
|
||||
"Download": "Downloaden",
|
||||
"Open & Read": "Openen & Lezen",
|
||||
"Tags": "Tags",
|
||||
"Tag": "Tag",
|
||||
"First": "Eerste",
|
||||
"Previous": "Vorige",
|
||||
"Next": "Volgende",
|
||||
"Last": "Laatste",
|
||||
"Cannot Load Page": "Pagina kan niet worden geladen",
|
||||
"An error occurred": "Er is een fout opgetreden"
|
||||
}
|
||||
|
||||
@@ -683,5 +683,52 @@
|
||||
"Download from Cloud": "Pobierz z chmury",
|
||||
"Upload to Cloud": "Prześlij do chmury",
|
||||
"Clear Custom Fonts": "Wyczyść niestandardowe czcionki",
|
||||
"Columns": "Kolumny"
|
||||
"Columns": "Kolumny",
|
||||
"OPDS Catalogs": "Katalogi OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Dodawanie adresów LAN nie jest obsługiwane w wersji webowej.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Nieprawidłowy katalog OPDS. Sprawdź URL.",
|
||||
"Browse and download books from online catalogs": "Przeglądaj i pobieraj książki z katalogów online",
|
||||
"My Catalogs": "Moje katalogi",
|
||||
"Add Catalog": "Dodaj katalog",
|
||||
"No catalogs yet": "Brak katalogów",
|
||||
"Add your first OPDS catalog to start browsing books": "Dodaj pierwszy katalog OPDS, aby rozpocząć przeglądanie książek",
|
||||
"Add Your First Catalog": "Dodaj pierwszy katalog",
|
||||
"Browse": "Przeglądaj",
|
||||
"Popular Catalogs": "Popularne katalogi",
|
||||
"Add": "Dodaj",
|
||||
"Add OPDS Catalog": "Dodaj katalog OPDS",
|
||||
"Catalog Name": "Nazwa katalogu",
|
||||
"My Calibre Library": "Moja biblioteka Calibre",
|
||||
"OPDS URL": "URL OPDS",
|
||||
"Username (optional)": "Nazwa użytkownika (opcjonalnie)",
|
||||
"Password (optional)": "Hasło (opcjonalnie)",
|
||||
"Description (optional)": "Opis (opcjonalnie)",
|
||||
"A brief description of this catalog": "Krótki opis katalogu",
|
||||
"Validating...": "Weryfikacja...",
|
||||
"View All": "Pokaż wszystkie",
|
||||
"Forward": "Dalej",
|
||||
"OPDS Catalog": "Katalog OPDS",
|
||||
"Home": "Strona główna",
|
||||
"Library": "Biblioteka",
|
||||
"{{count}} items_one": "{{count}} element",
|
||||
"{{count}} items_few": "{{count}} elementy",
|
||||
"{{count}} items_many": "{{count}} elementów",
|
||||
"{{count}} items_other": "{{count}} elementu",
|
||||
"Download completed": "Pobieranie zakończone",
|
||||
"Download failed": "Pobieranie nie powiodło się",
|
||||
"Open Access": "Dostęp otwarty",
|
||||
"Borrow": "Wypożycz",
|
||||
"Buy": "Kup",
|
||||
"Subscribe": "Subskrybuj",
|
||||
"Sample": "Próbka",
|
||||
"Download": "Pobierz",
|
||||
"Open & Read": "Otwórz i czytaj",
|
||||
"Tags": "Tagi",
|
||||
"Tag": "Tag",
|
||||
"First": "Pierwsza",
|
||||
"Previous": "Poprzednia",
|
||||
"Next": "Następna",
|
||||
"Last": "Ostatnia",
|
||||
"Cannot Load Page": "Nie można załadować strony",
|
||||
"An error occurred": "Wystąpił błąd"
|
||||
}
|
||||
|
||||
@@ -679,5 +679,51 @@
|
||||
"Download from Cloud": "Baixar da Nuvem",
|
||||
"Upload to Cloud": "Enviar para a Nuvem",
|
||||
"Clear Custom Fonts": "Limpar Fontes Personalizadas",
|
||||
"Columns": "Colunas"
|
||||
"Columns": "Colunas",
|
||||
"OPDS Catalogs": "Catálogos OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Adicionar endereços LAN não é suportado na versão web.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Catálogo OPDS inválido. Verifique o URL.",
|
||||
"Browse and download books from online catalogs": "Navegar e baixar livros de catálogos online",
|
||||
"My Catalogs": "Meus catálogos",
|
||||
"Add Catalog": "Adicionar catálogo",
|
||||
"No catalogs yet": "Nenhum catálogo ainda",
|
||||
"Add your first OPDS catalog to start browsing books": "Adicione seu primeiro catálogo OPDS para começar a navegar pelos livros",
|
||||
"Add Your First Catalog": "Adicione seu primeiro catálogo",
|
||||
"Browse": "Navegar",
|
||||
"Popular Catalogs": "Catálogos populares",
|
||||
"Add": "Adicionar",
|
||||
"Add OPDS Catalog": "Adicionar catálogo OPDS",
|
||||
"Catalog Name": "Nome do catálogo",
|
||||
"My Calibre Library": "Minha biblioteca Calibre",
|
||||
"OPDS URL": "URL do OPDS",
|
||||
"Username (optional)": "Nome de usuário (opcional)",
|
||||
"Password (optional)": "Senha (opcional)",
|
||||
"Description (optional)": "Descrição (opcional)",
|
||||
"A brief description of this catalog": "Uma breve descrição deste catálogo",
|
||||
"Validating...": "Validando...",
|
||||
"View All": "Ver todos",
|
||||
"Forward": "Avançar",
|
||||
"OPDS Catalog": "Catálogo OPDS",
|
||||
"Home": "Início",
|
||||
"Library": "Biblioteca",
|
||||
"{{count}} items_one": "{{count}} item",
|
||||
"{{count}} items_many": "{{count}} itens",
|
||||
"{{count}} items_other": "{{count}} item",
|
||||
"Download completed": "Download concluído",
|
||||
"Download failed": "Falha no download",
|
||||
"Open Access": "Acesso aberto",
|
||||
"Borrow": "Emprestar",
|
||||
"Buy": "Comprar",
|
||||
"Subscribe": "Assinar",
|
||||
"Sample": "Amostra",
|
||||
"Download": "Baixar",
|
||||
"Open & Read": "Abrir e ler",
|
||||
"Tags": "Etiquetas",
|
||||
"Tag": "Etiqueta",
|
||||
"First": "Pierwsza",
|
||||
"Previous": "Poprzednia",
|
||||
"Next": "Następna",
|
||||
"Last": "Ostatnia",
|
||||
"Cannot Load Page": "Nie można załadować strony",
|
||||
"An error occurred": "Wystąpił błąd"
|
||||
}
|
||||
|
||||
@@ -683,5 +683,52 @@
|
||||
"Download from Cloud": "Скачать из облака",
|
||||
"Upload to Cloud": "Загрузить в облако",
|
||||
"Clear Custom Fonts": "Очистить пользовательские шрифты",
|
||||
"Columns": "Колонки"
|
||||
"Columns": "Колонки",
|
||||
"OPDS Catalogs": "Каталоги OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Добавление LAN-адресов не поддерживается в веб-версии.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Недействительный каталог OPDS. Пожалуйста, проверьте URL.",
|
||||
"Browse and download books from online catalogs": "Просматривать и скачивать книги из онлайн-каталогов",
|
||||
"My Catalogs": "Мои каталоги",
|
||||
"Add Catalog": "Добавить каталог",
|
||||
"No catalogs yet": "Каталоги отсутствуют",
|
||||
"Add your first OPDS catalog to start browsing books": "Добавьте первый каталог OPDS, чтобы начать просмотр книг",
|
||||
"Add Your First Catalog": "Добавьте первый каталог",
|
||||
"Browse": "Просмотр",
|
||||
"Popular Catalogs": "Популярные каталоги",
|
||||
"Add": "Добавить",
|
||||
"Add OPDS Catalog": "Добавить каталог OPDS",
|
||||
"Catalog Name": "Название каталога",
|
||||
"My Calibre Library": "Моя библиотека Calibre",
|
||||
"OPDS URL": "URL OPDS",
|
||||
"Username (optional)": "Имя пользователя (необязательно)",
|
||||
"Password (optional)": "Пароль (необязательно)",
|
||||
"Description (optional)": "Описание (необязательно)",
|
||||
"A brief description of this catalog": "Краткое описание каталога",
|
||||
"Validating...": "Проверка...",
|
||||
"View All": "Посмотреть все",
|
||||
"Forward": "Вперёд",
|
||||
"OPDS Catalog": "Каталог OPDS",
|
||||
"Home": "Главная",
|
||||
"Library": "Библиотека",
|
||||
"{{count}} items_one": "{{count}} элемент",
|
||||
"{{count}} items_few": "{{count}} элемента",
|
||||
"{{count}} items_many": "{{count}} элементов",
|
||||
"{{count}} items_other": "{{count}} элемента",
|
||||
"Download completed": "Загрузка завершена",
|
||||
"Download failed": "Загрузка не удалась",
|
||||
"Open Access": "Открытый доступ",
|
||||
"Borrow": "Взять в аренду",
|
||||
"Buy": "Купить",
|
||||
"Subscribe": "Подписаться",
|
||||
"Sample": "Образец",
|
||||
"Download": "Скачать",
|
||||
"Open & Read": "Открыть и читать",
|
||||
"Tags": "Теги",
|
||||
"Tag": "Тег",
|
||||
"First": "Первая",
|
||||
"Previous": "Предыдущая",
|
||||
"Next": "Следующая",
|
||||
"Last": "Последняя",
|
||||
"Cannot Load Page": "Не удалось загрузить страницу",
|
||||
"An error occurred": "Произошла ошибка"
|
||||
}
|
||||
|
||||
@@ -675,5 +675,50 @@
|
||||
"Download from Cloud": "කලාඬයෙන් බාගත කරන්න",
|
||||
"Upload to Cloud": "කලාඬයට උඩුගත කරන්න",
|
||||
"Clear Custom Fonts": "අභිරුචි අකුරු පැහැදිලි කරන්න",
|
||||
"Columns": "තීරු"
|
||||
"Columns": "තීරු",
|
||||
"OPDS Catalogs": "OPDS දත්තසමුදා",
|
||||
"Adding LAN addresses is not supported in the web app version.": "වෙබ් යෙදුම් අනුවාදයේ LAN ලිපින එක් කිරීම සහය නොදක්වයි.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "අවලංගු OPDS දත්තසමුදායකි. කරුණාකර URL පරීක්ෂා කරන්න.",
|
||||
"Browse and download books from online catalogs": "ඔන්ලයින් දත්තසමුදා වලින් පොත් බ්රවුස් සහ බාගන්න",
|
||||
"My Catalogs": "මගේ දත්තසමුදා",
|
||||
"Add Catalog": "දත්තසමුදා එක් කරන්න",
|
||||
"No catalogs yet": "තවම දත්තසමුදා නැත",
|
||||
"Add your first OPDS catalog to start browsing books": "පොත් බ්රවුස් කිරීමට ඔබේ පළමු OPDS දත්තසමුදා එක් කරන්න",
|
||||
"Add Your First Catalog": "ඔබේ පළමු දත්තසමුදා එක් කරන්න",
|
||||
"Browse": "බ්රවුස් කරන්න",
|
||||
"Popular Catalogs": "ප්රසිද්ධ දත්තසමුදා",
|
||||
"Add": "එකතු කරන්න",
|
||||
"Add OPDS Catalog": "OPDS දත්තසමුදා එක් කරන්න",
|
||||
"Catalog Name": "දත්තසමුදා නාමය",
|
||||
"My Calibre Library": "මගේ Calibre පුස්තකාලය",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "පරිශීලක නාමය (අත්යාවශ්ය නොවේ)",
|
||||
"Password (optional)": "මුරපදය (අත්යාවශ්ය නොවේ)",
|
||||
"Description (optional)": "විස්තරය (අත්යාවශ්ය නොවේ)",
|
||||
"A brief description of this catalog": "මෙම දත්තසමුදායේ කෙටි විස්තරයක්",
|
||||
"Validating...": "තහවුරු කරමින්...",
|
||||
"View All": "සියල්ල බලන්න",
|
||||
"Forward": "ඉදිරියට",
|
||||
"OPDS Catalog": "OPDS දත්තසමුදා",
|
||||
"Home": "මුල් පිටුව",
|
||||
"Library": "පුස්තකාලය",
|
||||
"{{count}} items_one": "{{count}} අයිතමය",
|
||||
"{{count}} items_other": "{{count}} අයිතම",
|
||||
"Download completed": "බාගත කිරීම සම්පූර්ණයි",
|
||||
"Download failed": "බාගත කිරීම අසාර්ථකයි",
|
||||
"Open Access": "විවෘත ප්රවේශය",
|
||||
"Borrow": "උधාර ගන්න",
|
||||
"Buy": "ගන්න",
|
||||
"Subscribe": "දායක වන්න",
|
||||
"Sample": "නම්නය",
|
||||
"Download": "බාගන්න",
|
||||
"Open & Read": "විවෘත කර කියවන්න",
|
||||
"Tags": "ටැග්",
|
||||
"Tag": "ටැග්",
|
||||
"First": "පළමුවා",
|
||||
"Previous": "පෙර",
|
||||
"Next": "ඊළඟ",
|
||||
"Last": "අවසාන",
|
||||
"Cannot Load Page": "පිටුවට ප්රවේශ විය නොහැක",
|
||||
"An error occurred": "දෝෂයක් සිදු විය"
|
||||
}
|
||||
|
||||
@@ -675,5 +675,50 @@
|
||||
"Download from Cloud": "Ladda ner från molnet",
|
||||
"Upload to Cloud": "Ladda upp till molnet",
|
||||
"Clear Custom Fonts": "Rensa anpassade typsnitt",
|
||||
"Columns": "Kolumner"
|
||||
"Columns": "Kolumner",
|
||||
"OPDS Catalogs": "OPDS-kataloger",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Att lägga till LAN-adresser stöds inte i webbappen.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Ogiltig OPDS-katalog. Kontrollera URL:en.",
|
||||
"Browse and download books from online catalogs": "Bläddra och ladda ner böcker från onlinekataloger",
|
||||
"My Catalogs": "Mina kataloger",
|
||||
"Add Catalog": "Lägg till katalog",
|
||||
"No catalogs yet": "Inga kataloger än",
|
||||
"Add your first OPDS catalog to start browsing books": "Lägg till din första OPDS-katalog för att börja bläddra i böcker",
|
||||
"Add Your First Catalog": "Lägg till din första katalog",
|
||||
"Browse": "Bläddra",
|
||||
"Popular Catalogs": "Populära kataloger",
|
||||
"Add": "Lägg till",
|
||||
"Add OPDS Catalog": "Lägg till OPDS-katalog",
|
||||
"Catalog Name": "Katalognamn",
|
||||
"My Calibre Library": "Mitt Calibre-bibliotek",
|
||||
"OPDS URL": "OPDS-URL",
|
||||
"Username (optional)": "Användarnamn (valfritt)",
|
||||
"Password (optional)": "Lösenord (valfritt)",
|
||||
"Description (optional)": "Beskrivning (valfritt)",
|
||||
"A brief description of this catalog": "En kort beskrivning av denna katalog",
|
||||
"Validating...": "Verifierar...",
|
||||
"View All": "Visa alla",
|
||||
"Forward": "Framåt",
|
||||
"OPDS Catalog": "OPDS-katalog",
|
||||
"Home": "Start",
|
||||
"Library": "Bibliotek",
|
||||
"{{count}} items_one": "{{count}} objekt",
|
||||
"{{count}} items_other": "{{count}} objekt",
|
||||
"Download completed": "Nedladdning klar",
|
||||
"Download failed": "Nedladdning misslyckades",
|
||||
"Open Access": "Öppen åtkomst",
|
||||
"Borrow": "Låna",
|
||||
"Buy": "Köp",
|
||||
"Subscribe": "Prenumerera",
|
||||
"Sample": "Prov",
|
||||
"Download": "Ladda ner",
|
||||
"Open & Read": "Öppna & Läs",
|
||||
"Tags": "Taggar",
|
||||
"Tag": "Tagg",
|
||||
"First": "Första",
|
||||
"Previous": "Föregående",
|
||||
"Next": "Nästa",
|
||||
"Last": "Sista",
|
||||
"Cannot Load Page": "Kan inte ladda sidan",
|
||||
"An error occurred": "Ett fel uppstod"
|
||||
}
|
||||
|
||||
@@ -675,5 +675,50 @@
|
||||
"Download from Cloud": "மேகத்திலிருந்து பதிவிறக்கு செய்யவும்",
|
||||
"Upload to Cloud": "மேகத்திற்கு பதிவேற்றவும்",
|
||||
"Clear Custom Fonts": "தனிப்பயன் எழுத்துருக்களை அழிக்கவும்",
|
||||
"Columns": "நெடுவரிசைகள்"
|
||||
"Columns": "நெடுவரிசைகள்",
|
||||
"OPDS Catalogs": "OPDS பட்டியல்கள்",
|
||||
"Adding LAN addresses is not supported in the web app version.": "LAN முகவரிகளைச் சேர்ப்பது வலை பயன்பாட்டில் ஆதரிக்கப்படவில்லை.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "தவறான OPDS பட்டியல். URL ஐச் சரிபார்க்கவும்.",
|
||||
"Browse and download books from online catalogs": "ஆன்லைன் பட்டியல்களில் இருந்து புத்தகங்களை உலாவவும் மற்றும் பதிவிறக்கவும்",
|
||||
"My Catalogs": "என் பட்டியல்கள்",
|
||||
"Add Catalog": "பட்டியலைச் சேர்க்கவும்",
|
||||
"No catalogs yet": "இன்னும் எந்த பட்டியலும் இல்லை",
|
||||
"Add your first OPDS catalog to start browsing books": "புத்தகங்களை உலாவ தொடங்க உங்கள் முதல் OPDS பட்டியலைச் சேர்க்கவும்",
|
||||
"Add Your First Catalog": "உங்கள் முதல் பட்டியலைச் சேர்க்கவும்",
|
||||
"Browse": "உலாவவும்",
|
||||
"Popular Catalogs": "பிரபல பட்டியல்கள்",
|
||||
"Add": "சேர்க்கவும்",
|
||||
"Add OPDS Catalog": "OPDS பட்டியலைச் சேர்க்கவும்",
|
||||
"Catalog Name": "பட்டியல் பெயர்",
|
||||
"My Calibre Library": "என் Calibre நூலகம்",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "பயனர் பெயர் (விருப்பம்)",
|
||||
"Password (optional)": "கடவுச்சொல் (விருப்பம்)",
|
||||
"Description (optional)": "விவரிப்பு (விருப்பம்)",
|
||||
"A brief description of this catalog": "இந்த பட்டியலின் சுருக்கமான விளக்கம்",
|
||||
"Validating...": "சரிபார்க்கப்படுகிறது...",
|
||||
"View All": "அனைத்தையும் பார்க்கவும்",
|
||||
"Forward": "முன்னேற்று",
|
||||
"OPDS Catalog": "OPDS பட்டியல்",
|
||||
"Home": "முகப்பு",
|
||||
"Library": "நூலகம்",
|
||||
"{{count}} items_one": "{{count}} பொருள்",
|
||||
"{{count}} items_other": "{{count}} பொருட்கள்",
|
||||
"Download completed": "பதிவிறக்கம் முடிந்தது",
|
||||
"Download failed": "பதிவிறக்கம் தோல்வியடைந்தது",
|
||||
"Open Access": "திறந்த அணுகல்",
|
||||
"Borrow": "கடன் எடுக்கவும்",
|
||||
"Buy": "வாங்கவும்",
|
||||
"Subscribe": "சந்தா செய்யவும்",
|
||||
"Sample": "மாதிரிப் புத்தகம்",
|
||||
"Download": "பதிவிறக்கம் செய்யவும்",
|
||||
"Open & Read": "திறந்து வாசிக்கவும்",
|
||||
"Tags": "குறிச்சொற்கள்",
|
||||
"Tag": "குறிச்சொல்",
|
||||
"First": "முதல்",
|
||||
"Previous": "முந்தையது",
|
||||
"Next": "அடுத்தது",
|
||||
"Last": "இறுதி",
|
||||
"Cannot Load Page": "பக்கம் ஏற்ற முடியவில்லை",
|
||||
"An error occurred": "ஒரு பிழை ஏற்பட்டது"
|
||||
}
|
||||
|
||||
@@ -671,5 +671,49 @@
|
||||
"Download from Cloud": "ดาวน์โหลดจากคลาวด์",
|
||||
"Upload to Cloud": "อัปโหลดไปยังคลาวด์",
|
||||
"Clear Custom Fonts": "ล้างฟอนต์กำหนดเอง",
|
||||
"Columns": "คอลัมน์"
|
||||
"Columns": "คอลัมน์",
|
||||
"OPDS Catalogs": "แคตตาล็อก OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "ไม่รองรับการเพิ่มที่อยู่ LAN ในเวอร์ชันเว็บแอป",
|
||||
"Invalid OPDS catalog. Please check the URL.": "แคตตาล็อก OPDS ไม่ถูกต้อง กรุณาตรวจสอบ URL",
|
||||
"Browse and download books from online catalogs": "เรียกดูและดาวน์โหลดหนังสือจากแคตตาล็อกออนไลน์",
|
||||
"My Catalogs": "แคตตาล็อกของฉัน",
|
||||
"Add Catalog": "เพิ่มแคตตาล็อก",
|
||||
"No catalogs yet": "ยังไม่มีแคตตาล็อก",
|
||||
"Add your first OPDS catalog to start browsing books": "เพิ่มแคตตาล็อก OPDS แรกของคุณเพื่อเริ่มเรียกดูหนังสือ",
|
||||
"Add Your First Catalog": "เพิ่มแคตตาล็อกแรกของคุณ",
|
||||
"Browse": "เรียกดู",
|
||||
"Popular Catalogs": "แคตตาล็อกยอดนิยม",
|
||||
"Add": "เพิ่ม",
|
||||
"Add OPDS Catalog": "เพิ่มแคตตาล็อก OPDS",
|
||||
"Catalog Name": "ชื่อแคตตาล็อก",
|
||||
"My Calibre Library": "ห้องสมุด Calibre ของฉัน",
|
||||
"OPDS URL": "URL OPDS",
|
||||
"Username (optional)": "ชื่อผู้ใช้ (ไม่บังคับ)",
|
||||
"Password (optional)": "รหัสผ่าน (ไม่บังคับ)",
|
||||
"Description (optional)": "คำอธิบาย (ไม่บังคับ)",
|
||||
"A brief description of this catalog": "คำอธิบายสั้น ๆ ของแคตตาล็อกนี้",
|
||||
"Validating...": "กำลังตรวจสอบ...",
|
||||
"View All": "ดูทั้งหมด",
|
||||
"Forward": "ไปข้างหน้า",
|
||||
"OPDS Catalog": "แคตตาล็อก OPDS",
|
||||
"Home": "หน้าแรก",
|
||||
"Library": "ห้องสมุด",
|
||||
"{{count}} items_other": "{{count}} รายการ",
|
||||
"Download completed": "ดาวน์โหลดเสร็จสิ้น",
|
||||
"Download failed": "ดาวน์โหลดล้มเหลว",
|
||||
"Open Access": "เข้าถึงได้ทันที",
|
||||
"Borrow": "ยืม",
|
||||
"Buy": "ซื้อ",
|
||||
"Subscribe": "สมัครสมาชิก",
|
||||
"Sample": "ตัวอย่าง",
|
||||
"Download": "ดาวน์โหลด",
|
||||
"Open & Read": "เปิดและอ่าน",
|
||||
"Tags": "แท็ก",
|
||||
"Tag": "แท็ก",
|
||||
"First": "แรก",
|
||||
"Previous": "ก่อนหน้า",
|
||||
"Next": "ถัดไป",
|
||||
"Last": "สุดท้าย",
|
||||
"Cannot Load Page": "ไม่สามารถโหลดหน้าหน้าได้",
|
||||
"An error occurred": "เกิดข้อผิดพลาด"
|
||||
}
|
||||
|
||||
@@ -675,5 +675,50 @@
|
||||
"Download from Cloud": "Buluttan İndir",
|
||||
"Upload to Cloud": "Buluta Yükle",
|
||||
"Clear Custom Fonts": "Özel Yazı Tiplerini Temizle",
|
||||
"Columns": "Sütunlar"
|
||||
"Columns": "Sütunlar",
|
||||
"OPDS Catalogs": "OPDS Katalogları",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Web uygulaması sürümünde LAN adresi ekleme desteklenmiyor.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Geçersiz OPDS kataloğu. Lütfen URL'yi kontrol edin.",
|
||||
"Browse and download books from online catalogs": "Çevrimiçi kataloglardan kitapları görüntüleyin ve indirin",
|
||||
"My Catalogs": "Kataloglarım",
|
||||
"Add Catalog": "Katalog Ekle",
|
||||
"No catalogs yet": "Henüz katalog yok",
|
||||
"Add your first OPDS catalog to start browsing books": "Kitapları görüntülemeye başlamak için ilk OPDS kataloğunuzu ekleyin",
|
||||
"Add Your First Catalog": "İlk Kataloğunuzu Ekleyin",
|
||||
"Browse": "Gözat",
|
||||
"Popular Catalogs": "Popüler Kataloglar",
|
||||
"Add": "Ekle",
|
||||
"Add OPDS Catalog": "OPDS Kataloğu Ekle",
|
||||
"Catalog Name": "Katalog Adı",
|
||||
"My Calibre Library": "Calibre Kütüphanem",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "Kullanıcı Adı (opsiyonel)",
|
||||
"Password (optional)": "Şifre (opsiyonel)",
|
||||
"Description (optional)": "Açıklama (opsiyonel)",
|
||||
"A brief description of this catalog": "Bu katalog hakkında kısa açıklama",
|
||||
"Validating...": "Doğrulanıyor...",
|
||||
"View All": "Tümünü Görüntüle",
|
||||
"Forward": "İleri",
|
||||
"OPDS Catalog": "OPDS Kataloğu",
|
||||
"Home": "Ana Sayfa",
|
||||
"Library": "Kütüphane",
|
||||
"{{count}} items_one": "{{count}} öğe",
|
||||
"{{count}} items_other": "{{count}} öğe",
|
||||
"Download completed": "İndirme tamamlandı",
|
||||
"Download failed": "İndirme başarısız",
|
||||
"Open Access": "Açık Erişim",
|
||||
"Borrow": "Ödünç Al",
|
||||
"Buy": "Satın Al",
|
||||
"Subscribe": "Abone Ol",
|
||||
"Sample": "Örnek",
|
||||
"Download": "İndir",
|
||||
"Open & Read": "Aç ve Oku",
|
||||
"Tags": "Etiketler",
|
||||
"Tag": "Etiket",
|
||||
"First": "İlk",
|
||||
"Previous": "Önceki",
|
||||
"Next": "Sonraki",
|
||||
"Last": "Son",
|
||||
"Cannot Load Page": "Sayfa yüklenemiyor",
|
||||
"An error occurred": "Bir hata oluştu"
|
||||
}
|
||||
|
||||
@@ -683,5 +683,52 @@
|
||||
"Download from Cloud": "Завантажити з хмари",
|
||||
"Upload to Cloud": "Завантажити в хмару",
|
||||
"Clear Custom Fonts": "Очистити користувацькі шрифти",
|
||||
"Columns": "Стовпці"
|
||||
"Columns": "Стовпці",
|
||||
"OPDS Catalogs": "Каталоги OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Додавання LAN-адрес не підтримується у веб-версії.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Недійсний каталог OPDS. Будь ласка, перевірте URL.",
|
||||
"Browse and download books from online catalogs": "Переглядайте та завантажуйте книги з онлайн-каталогів",
|
||||
"My Catalogs": "Мої каталоги",
|
||||
"Add Catalog": "Додати каталог",
|
||||
"No catalogs yet": "Каталогів поки немає",
|
||||
"Add your first OPDS catalog to start browsing books": "Додайте перший каталог OPDS, щоб почати перегляд книг",
|
||||
"Add Your First Catalog": "Додайте свій перший каталог",
|
||||
"Browse": "Переглянути",
|
||||
"Popular Catalogs": "Популярні каталоги",
|
||||
"Add": "Додати",
|
||||
"Add OPDS Catalog": "Додати каталог OPDS",
|
||||
"Catalog Name": "Назва каталогу",
|
||||
"My Calibre Library": "Моя бібліотека Calibre",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "Ім’я користувача (необов’язково)",
|
||||
"Password (optional)": "Пароль (необов’язково)",
|
||||
"Description (optional)": "Опис (необов’язково)",
|
||||
"A brief description of this catalog": "Короткий опис цього каталогу",
|
||||
"Validating...": "Перевірка...",
|
||||
"View All": "Переглянути все",
|
||||
"Forward": "Вперед",
|
||||
"OPDS Catalog": "Каталог OPDS",
|
||||
"Home": "Головна",
|
||||
"Library": "Бібліотека",
|
||||
"{{count}} items_one": "{{count}} елемент",
|
||||
"{{count}} items_few": "{{count}} елементи",
|
||||
"{{count}} items_many": "{{count}} елементів",
|
||||
"{{count}} items_other": "{{count}} елементів",
|
||||
"Download completed": "Завантаження завершено",
|
||||
"Download failed": "Завантаження не вдалося",
|
||||
"Open Access": "Вільний доступ",
|
||||
"Borrow": "Позичити",
|
||||
"Buy": "Купити",
|
||||
"Subscribe": "Підписатися",
|
||||
"Sample": "Зразок",
|
||||
"Download": "Завантажити",
|
||||
"Open & Read": "Відкрити та читати",
|
||||
"Tags": "Теги",
|
||||
"Tag": "Тег",
|
||||
"First": "Перша",
|
||||
"Previous": "Попередня",
|
||||
"Next": "Наступна",
|
||||
"Last": "Остання",
|
||||
"Cannot Load Page": "Не вдалося завантажити сторінку",
|
||||
"An error occurred": "Сталася помилка"
|
||||
}
|
||||
|
||||
@@ -671,5 +671,49 @@
|
||||
"Download from Cloud": "Tải xuống từ đám mây",
|
||||
"Upload to Cloud": "Tải lên đám mây",
|
||||
"Clear Custom Fonts": "Xóa phông chữ tùy chỉnh",
|
||||
"Columns": "Cột"
|
||||
"Columns": "Cột",
|
||||
"OPDS Catalogs": "Danh mục OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Việc thêm địa chỉ LAN không được hỗ trợ trong phiên bản web.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Danh mục OPDS không hợp lệ. Vui lòng kiểm tra URL.",
|
||||
"Browse and download books from online catalogs": "Duyệt và tải sách từ các danh mục trực tuyến",
|
||||
"My Catalogs": "Danh mục của tôi",
|
||||
"Add Catalog": "Thêm danh mục",
|
||||
"No catalogs yet": "Chưa có danh mục nào",
|
||||
"Add your first OPDS catalog to start browsing books": "Thêm danh mục OPDS đầu tiên để bắt đầu duyệt sách",
|
||||
"Add Your First Catalog": "Thêm danh mục đầu tiên",
|
||||
"Browse": "Duyệt",
|
||||
"Popular Catalogs": "Danh mục phổ biến",
|
||||
"Add": "Thêm",
|
||||
"Add OPDS Catalog": "Thêm danh mục OPDS",
|
||||
"Catalog Name": "Tên danh mục",
|
||||
"My Calibre Library": "Thư viện Calibre của tôi",
|
||||
"OPDS URL": "URL OPDS",
|
||||
"Username (optional)": "Tên đăng nhập (tùy chọn)",
|
||||
"Password (optional)": "Mật khẩu (tùy chọn)",
|
||||
"Description (optional)": "Mô tả (tùy chọn)",
|
||||
"A brief description of this catalog": "Mô tả ngắn gọn về danh mục này",
|
||||
"Validating...": "Đang xác thực...",
|
||||
"View All": "Xem tất cả",
|
||||
"Forward": "Tiếp",
|
||||
"OPDS Catalog": "Danh mục OPDS",
|
||||
"Home": "Trang chủ",
|
||||
"Library": "Thư viện",
|
||||
"{{count}} items_other": "{{count}} mục",
|
||||
"Download completed": "Tải xuống hoàn tất",
|
||||
"Download failed": "Tải xuống thất bại",
|
||||
"Open Access": "Truy cập mở",
|
||||
"Borrow": "Mượn",
|
||||
"Buy": "Mua",
|
||||
"Subscribe": "Đăng ký",
|
||||
"Sample": "Mẫu",
|
||||
"Download": "Tải xuống",
|
||||
"Open & Read": "Mở & Đọc",
|
||||
"Tags": "Thẻ",
|
||||
"Tag": "Thẻ",
|
||||
"First": "Đầu tiên",
|
||||
"Previous": "Trước",
|
||||
"Next": "Tiếp theo",
|
||||
"Last": "Cuối cùng",
|
||||
"Cannot Load Page": "Không thể tải trang",
|
||||
"An error occurred": "Đã xảy ra lỗi"
|
||||
}
|
||||
|
||||
@@ -671,5 +671,49 @@
|
||||
"Download from Cloud": "从云端下载",
|
||||
"Upload to Cloud": "上传到云端",
|
||||
"Clear Custom Fonts": "清除自定义字体",
|
||||
"Columns": "列数"
|
||||
"Columns": "列数",
|
||||
"OPDS Catalogs": "OPDS 目录",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Web 版不支持添加局域网地址。",
|
||||
"Invalid OPDS catalog. Please check the URL.": "无效的 OPDS 目录。请检查 URL。",
|
||||
"Browse and download books from online catalogs": "浏览并下载在线目录中的书籍",
|
||||
"My Catalogs": "我的目录",
|
||||
"Add Catalog": "添加目录",
|
||||
"No catalogs yet": "尚无目录",
|
||||
"Add your first OPDS catalog to start browsing books": "添加您的第一个 OPDS 目录以开始浏览书籍",
|
||||
"Add Your First Catalog": "添加第一个目录",
|
||||
"Browse": "浏览",
|
||||
"Popular Catalogs": "热门目录",
|
||||
"Add": "添加",
|
||||
"Add OPDS Catalog": "添加 OPDS 目录",
|
||||
"Catalog Name": "目录名称",
|
||||
"My Calibre Library": "我的 Calibre 图书馆",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "用户名(可选)",
|
||||
"Password (optional)": "密码(可选)",
|
||||
"Description (optional)": "描述(可选)",
|
||||
"A brief description of this catalog": "该目录的简要描述",
|
||||
"Validating...": "验证中...",
|
||||
"View All": "查看全部",
|
||||
"Forward": "前进",
|
||||
"OPDS Catalog": "OPDS 目录",
|
||||
"Home": "首页",
|
||||
"Library": "图书馆",
|
||||
"{{count}} items_other": "{{count}} 项",
|
||||
"Download completed": "下载完成",
|
||||
"Download failed": "下载失败",
|
||||
"Open Access": "开放访问",
|
||||
"Borrow": "借阅",
|
||||
"Buy": "购买",
|
||||
"Subscribe": "订阅",
|
||||
"Sample": "样章",
|
||||
"Download": "下载",
|
||||
"Open & Read": "打开并阅读",
|
||||
"Tags": "标签",
|
||||
"Tag": "标签",
|
||||
"First": "首页",
|
||||
"Previous": "上一页",
|
||||
"Next": "下一页",
|
||||
"Last": "末页",
|
||||
"Cannot Load Page": "无法加载页面",
|
||||
"An error occurred": "发生错误"
|
||||
}
|
||||
|
||||
@@ -671,5 +671,49 @@
|
||||
"Download from Cloud": "從雲端下載",
|
||||
"Upload to Cloud": "上傳到雲端",
|
||||
"Clear Custom Fonts": "清除自訂字型",
|
||||
"Columns": "欄數"
|
||||
"Columns": "欄數",
|
||||
"OPDS Catalogs": "OPDS 目錄",
|
||||
"Adding LAN addresses is not supported in the web app version.": "網頁版不支援新增區域網路位址。",
|
||||
"Invalid OPDS catalog. Please check the URL.": "無效的 OPDS 目錄。請檢查 URL。",
|
||||
"Browse and download books from online catalogs": "瀏覽並下載線上目錄的書籍",
|
||||
"My Catalogs": "我的目錄",
|
||||
"Add Catalog": "新增目錄",
|
||||
"No catalogs yet": "尚無目錄",
|
||||
"Add your first OPDS catalog to start browsing books": "新增第一個 OPDS 目錄以開始瀏覽書籍",
|
||||
"Add Your First Catalog": "新增第一個目錄",
|
||||
"Browse": "瀏覽",
|
||||
"Popular Catalogs": "熱門目錄",
|
||||
"Add": "新增",
|
||||
"Add OPDS Catalog": "新增 OPDS 目錄",
|
||||
"Catalog Name": "目錄名稱",
|
||||
"My Calibre Library": "我的 Calibre 圖書館",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "使用者名稱(可選)",
|
||||
"Password (optional)": "密碼(可選)",
|
||||
"Description (optional)": "描述(可選)",
|
||||
"A brief description of this catalog": "此目錄的簡短描述",
|
||||
"Validating...": "驗證中...",
|
||||
"View All": "檢視全部",
|
||||
"Forward": "前往",
|
||||
"OPDS Catalog": "OPDS 目錄",
|
||||
"Home": "首頁",
|
||||
"Library": "圖書館",
|
||||
"{{count}} items_other": "{{count}} 項",
|
||||
"Download completed": "下載完成",
|
||||
"Download failed": "下載失敗",
|
||||
"Open Access": "開放存取",
|
||||
"Borrow": "借閱",
|
||||
"Buy": "購買",
|
||||
"Subscribe": "訂閱",
|
||||
"Sample": "試閱",
|
||||
"Download": "下載",
|
||||
"Open & Read": "開啟並閱讀",
|
||||
"Tags": "標籤",
|
||||
"Tag": "標籤",
|
||||
"First": "第一頁",
|
||||
"Previous": "上一頁",
|
||||
"Next": "下一頁",
|
||||
"Last": "最後一頁",
|
||||
"Cannot Load Page": "無法載入頁面",
|
||||
"An error occurred": "發生錯誤"
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ pub async fn download_file(
|
||||
file_path: &str,
|
||||
headers: HashMap<String, String>,
|
||||
body: Option<String>,
|
||||
single_threaded: Option<bool>,
|
||||
on_progress: Channel<ProgressPayload>,
|
||||
) -> Result<()> {
|
||||
use futures::stream::{self, StreamExt};
|
||||
@@ -116,32 +117,23 @@ pub async fn download_file(
|
||||
const PART_SIZE: u64 = 1024 * 1024;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let force_single = single_threaded.unwrap_or(false);
|
||||
|
||||
// Check if server supports range requests
|
||||
let range_resp = client.get(url).header("Range", "bytes=0-0").send().await?;
|
||||
let accept_ranges = range_resp
|
||||
.headers()
|
||||
.get("accept-ranges")
|
||||
.map(|v| v.to_str().unwrap_or(""))
|
||||
.unwrap_or("")
|
||||
.eq_ignore_ascii_case("bytes");
|
||||
let total = range_resp
|
||||
.headers()
|
||||
.get("content-range")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split('/').nth(1))
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
if !accept_ranges || total == 0 {
|
||||
// Fallback to original single-threaded logic
|
||||
async fn single_threaded_download(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
file_path: &str,
|
||||
headers: &HashMap<String, String>,
|
||||
body: &Option<String>,
|
||||
on_progress: Channel<ProgressPayload>,
|
||||
) -> Result<()> {
|
||||
let mut request = if let Some(body) = body {
|
||||
client.post(url).body(body)
|
||||
client.post(url).body(body.clone())
|
||||
} else {
|
||||
client.get(url)
|
||||
};
|
||||
|
||||
for (key, value) in headers.iter() {
|
||||
for (key, value) in headers {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
|
||||
@@ -168,7 +160,38 @@ pub async fn download_file(
|
||||
});
|
||||
}
|
||||
file.flush().await?;
|
||||
return Ok(());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
if force_single {
|
||||
return single_threaded_download(&client, url, file_path, &headers, &body, on_progress)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Check if server supports range requests
|
||||
let mut range_req = client.get(url).header("Range", "bytes=0-0");
|
||||
for (key, value) in headers.iter() {
|
||||
range_req = range_req.header(key, value);
|
||||
}
|
||||
let range_resp = range_req.send().await?;
|
||||
let accept_ranges = range_resp
|
||||
.headers()
|
||||
.get("accept-ranges")
|
||||
.map(|v| v.to_str().unwrap_or(""))
|
||||
.unwrap_or("")
|
||||
.eq_ignore_ascii_case("bytes");
|
||||
let total = range_resp
|
||||
.headers()
|
||||
.get("content-range")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split('/').nth(1))
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
if !accept_ranges || total == 0 {
|
||||
return single_threaded_download(&client, url, file_path, &headers, &body, on_progress)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Multi-part download with range access
|
||||
|
||||
@@ -45,6 +45,7 @@ export const setupSupabaseMocks = async (
|
||||
eq: vi.fn().mockResolvedValue(customResponses.update || { data: {}, error: null }),
|
||||
})),
|
||||
insert: vi.fn().mockResolvedValue(customResponses.insert || { data: {}, error: null }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
vi.mocked(createSupabaseAdminClient).mockReturnValue({
|
||||
@@ -101,5 +102,6 @@ export const setupSupabaseMocks = async (
|
||||
match: vi.fn().mockResolvedValue(customResponses.adminDelete || { data: {}, error: null }),
|
||||
})),
|
||||
})),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
|
||||
const url = request.nextUrl.searchParams.get('url');
|
||||
const auth = request.nextUrl.searchParams.get('auth');
|
||||
const stream = request.nextUrl.searchParams.get('stream');
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing URL parameter. Usage: /api/opds/proxy?url=YOUR_OPDS_URL' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[OPDS Proxy] ${method}: ${url}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
const headers: HeadersInit = {
|
||||
'User-Agent': 'Readest/1.0 (OPDS Browser)',
|
||||
Accept: 'application/atom+xml, application/xml, text/xml, application/json, */*',
|
||||
};
|
||||
|
||||
if (auth) {
|
||||
headers['Authorization'] = auth;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[OPDS Proxy] HTTP ${response.status} for ${url}`);
|
||||
if (method === 'HEAD') {
|
||||
console.log(`[OPDS Proxy] Response headers:`, response.headers);
|
||||
if (response.status === 401) {
|
||||
return new NextResponse(null, {
|
||||
status: 403,
|
||||
headers: {
|
||||
...Object.fromEntries(response.headers.entries()),
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.text();
|
||||
if (response.status === 401) {
|
||||
return new NextResponse(data, {
|
||||
status: 403,
|
||||
headers: {
|
||||
...Object.fromEntries(response.headers.entries()),
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type') || 'text/xml';
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
|
||||
if (method === 'HEAD') {
|
||||
console.log(`[OPDS Proxy] HEAD Success: ${url}`);
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': contentLength || '',
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (stream === 'true' || (contentLength && parseInt(contentLength) > 1024 * 1024)) {
|
||||
console.log(`[OPDS Proxy] Streaming: ${url}`);
|
||||
|
||||
return new NextResponse(response.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': contentLength || '',
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const data = await response.text();
|
||||
console.log(`[OPDS Proxy] Success: ${url} (${data.length} bytes)`);
|
||||
|
||||
return new NextResponse(data, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[OPDS Proxy] Error:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Request timeout - the OPDS server took too long to respond' },
|
||||
{ status: 504 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message,
|
||||
url: url,
|
||||
hint: 'Check if the OPDS URL is accessible and returns valid OPDS/Atom/JSON content',
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to fetch OPDS feed', url: url }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(request, 'GET');
|
||||
}
|
||||
|
||||
export async function HEAD(request: NextRequest) {
|
||||
return handleRequest(request, 'HEAD');
|
||||
}
|
||||
|
||||
export async function OPTIONS(_: NextRequest) {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -42,8 +42,8 @@ export default function UpdateEmailPage() {
|
||||
),
|
||||
);
|
||||
setEmail('');
|
||||
} catch (err: any) {
|
||||
setError(err.message || _('Failed to update email'));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : _('Failed to update email'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export const generateBookshelfItems = (
|
||||
booksGroup.books.push(book);
|
||||
booksGroup.updatedAt = Math.max(booksGroup.updatedAt, book.updatedAt);
|
||||
} else {
|
||||
let groupName = fullGroupName;
|
||||
const groupName = fullGroupName;
|
||||
acc.push({
|
||||
id: groupName === parentGroupName ? BOOK_UNGROUPED_ID : md5Fingerprint(groupName),
|
||||
name: groupName === parentGroupName ? BOOK_UNGROUPED_NAME : groupName,
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import clsx from 'clsx';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { IoFileTray } from 'react-icons/io5';
|
||||
import { MdRssFeed } from 'react-icons/md';
|
||||
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import Menu from '@/components/Menu';
|
||||
|
||||
interface ImportMenuProps {
|
||||
setIsDropdownOpen?: (open: boolean) => void;
|
||||
onImportBooks: () => void;
|
||||
onOpenCatalogManager: () => void;
|
||||
}
|
||||
|
||||
const ImportMenu: React.FC<ImportMenuProps> = ({ setIsDropdownOpen, onImportBooks }) => {
|
||||
const ImportMenu: React.FC<ImportMenuProps> = ({
|
||||
setIsDropdownOpen,
|
||||
onImportBooks,
|
||||
onOpenCatalogManager,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
|
||||
@@ -18,6 +26,11 @@ const ImportMenu: React.FC<ImportMenuProps> = ({ setIsDropdownOpen, onImportBook
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const handleOpenCatalogManager = () => {
|
||||
onOpenCatalogManager();
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className={clsx(
|
||||
@@ -26,7 +39,16 @@ const ImportMenu: React.FC<ImportMenuProps> = ({ setIsDropdownOpen, onImportBook
|
||||
)}
|
||||
onCancel={() => setIsDropdownOpen?.(false)}
|
||||
>
|
||||
<MenuItem label={_('From Local File')} onClick={handleImportBooks} />
|
||||
<MenuItem
|
||||
label={_('From Local File')}
|
||||
Icon={<IoFileTray className='h-5 w-5' />}
|
||||
onClick={handleImportBooks}
|
||||
/>
|
||||
<MenuItem
|
||||
label={_('OPDS Catalogs')}
|
||||
Icon={<MdRssFeed className='h-5 w-5' />}
|
||||
onClick={handleOpenCatalogManager}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { FaSearch } from 'react-icons/fa';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
@@ -13,8 +13,8 @@ import { useThemeStore } from '@/store/themeStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTrafficLight } from '@/hooks/useTrafficLight';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import useShortcuts from '@/hooks/useShortcuts';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
@@ -27,6 +27,7 @@ interface LibraryHeaderProps {
|
||||
isSelectMode: boolean;
|
||||
isSelectAll: boolean;
|
||||
onImportBooks: () => void;
|
||||
onOpenCatalogManager: () => void;
|
||||
onToggleSelectMode: () => void;
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
@@ -36,6 +37,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
isSelectMode,
|
||||
isSelectAll,
|
||||
onImportBooks,
|
||||
onOpenCatalogManager,
|
||||
onToggleSelectMode,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
@@ -47,13 +49,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
const { settings } = useSettingsStore();
|
||||
const { systemUIVisible, statusBarHeight } = useThemeStore();
|
||||
const { currentBookshelf } = useLibraryStore();
|
||||
const {
|
||||
isTrafficLightVisible,
|
||||
initializeTrafficLightStore,
|
||||
initializeTrafficLightListeners,
|
||||
setTrafficLightVisibility,
|
||||
cleanupTrafficLightListeners,
|
||||
} = useTrafficLightStore();
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
|
||||
|
||||
const viewSettings = settings.globalViewSettings;
|
||||
@@ -85,18 +81,6 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
debouncedUpdateQueryParam(newQuery);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
|
||||
initializeTrafficLightStore(appService);
|
||||
initializeTrafficLightListeners();
|
||||
setTrafficLightVisibility(true);
|
||||
return () => {
|
||||
cleanupTrafficLightListeners();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appService?.hasTrafficLight]);
|
||||
|
||||
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
|
||||
const currentBooksCount = currentBookshelf.reduce(
|
||||
(acc, item) => acc + ('books' in item ? item.books.length : 1),
|
||||
@@ -171,10 +155,13 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
'exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center',
|
||||
isMobile ? 'dropdown-end' : 'dropdown-center',
|
||||
)}
|
||||
buttonClassName='p-0 h-6 min-h-6 w-6 flex items-center justify-center'
|
||||
buttonClassName='p-0 h-6 min-h-6 w-6 flex items-center justify-center !bg-transparent'
|
||||
toggleButton={<PiPlus role='none' className='m-0.5 h-5 w-5' />}
|
||||
>
|
||||
<ImportMenu onImportBooks={onImportBooks} />
|
||||
<ImportMenu
|
||||
onImportBooks={onImportBooks}
|
||||
onOpenCatalogManager={onOpenCatalogManager}
|
||||
/>
|
||||
</Dropdown>
|
||||
{isMobile ? null : (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { CatalogManager } from '@/app/opds/CatelogManager';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
interface CatalogDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CatalogDialog({ onClose }: CatalogDialogProps) {
|
||||
const _ = useTranslation();
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={_('OPDS Catalog')}
|
||||
onClose={onClose}
|
||||
bgClassName={'sm:!bg-black/75'}
|
||||
boxClassName='sm:min-w-[520px] sm:w-3/4 sm:h-[85%] sm:!max-w-screen-sm'
|
||||
>
|
||||
<div className={clsx('bg-base-100 relative flex flex-col overflow-y-auto')}>
|
||||
<CatalogManager />
|
||||
</div>
|
||||
<form method='dialog' className='modal-backdrop'>
|
||||
<button onClick={onClose}>{_('Close')}</button>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import { BookMetadata } from '@/libs/document';
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
import { BookDetailModal } from '@/components/metadata';
|
||||
import { UpdaterWindow } from '@/components/UpdaterWindow';
|
||||
import { CatalogDialog } from './components/OPDSDialog';
|
||||
import { MigrateDataWindow } from './components/MigrateDataWindow';
|
||||
import { useDragDropImport } from './hooks/useDragDropImport';
|
||||
import { Toast } from '@/components/Toast';
|
||||
@@ -88,6 +89,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const { safeAreaInsets: insets, isRoundedWindow } = useThemeStore();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const { isSettingsDialogOpen, setSettingsDialogOpen } = useSettingsStore();
|
||||
const [showCatalogManager, setShowCatalogManager] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [libraryLoaded, setLibraryLoaded] = useState(false);
|
||||
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||
@@ -652,6 +654,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
onImportBooks={handleImportBooks}
|
||||
onOpenCatalogManager={() => setShowCatalogManager(true)}
|
||||
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
|
||||
onSelectAll={handleSelectAll}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
@@ -782,6 +785,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
<UpdaterWindow />
|
||||
<MigrateDataWindow />
|
||||
{isSettingsDialogOpen && <SettingsDialog bookKey={''} />}
|
||||
{showCatalogManager && <CatalogDialog onClose={() => setShowCatalogManager(false)} />}
|
||||
<Toast />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { IoAdd, IoTrash, IoOpenOutline, IoBook, IoEyeOff, IoEye } from 'react-icons/io5';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { saveSysSettings } from '@/helpers/settings';
|
||||
import { OPDSCatalog } from '@/types/opds';
|
||||
import { isLanAddress } from '@/utils/network';
|
||||
import { validateOPDSURL } from './utils/opdsUtils';
|
||||
import ModalPortal from '@/components/ModalPortal';
|
||||
|
||||
const POPULAR_CATALOGS: OPDSCatalog[] = [
|
||||
{
|
||||
id: 'gutenberg',
|
||||
name: 'Project Gutenberg',
|
||||
url: 'https://m.gutenberg.org/ebooks.opds/',
|
||||
description: "World's largest collection of free ebooks",
|
||||
icon: '🏛️',
|
||||
},
|
||||
{
|
||||
id: 'manybooks',
|
||||
name: 'ManyBooks',
|
||||
url: 'https://manybooks.net/opds/index.php',
|
||||
description: 'Over 50,000 free ebooks',
|
||||
icon: '📖',
|
||||
},
|
||||
];
|
||||
|
||||
async function validateOPDSCatalog(
|
||||
url: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
const result = await validateOPDSURL(url, username, password, isWebAppPlatform());
|
||||
return { valid: result.isValid, error: result.error };
|
||||
}
|
||||
|
||||
export function CatalogManager() {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { envConfig } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const [catalogs, setCatalogs] = useState<OPDSCatalog[]>(() => settings.opdsCatalogs || []);
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [newCatalog, setNewCatalog] = useState({
|
||||
name: '',
|
||||
url: '',
|
||||
description: '',
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [urlError, setUrlError] = useState('');
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
const saveCatalogs = (updatedCatalogs: OPDSCatalog[]) => {
|
||||
setCatalogs(updatedCatalogs);
|
||||
saveSysSettings(envConfig, 'opdsCatalogs', updatedCatalogs);
|
||||
};
|
||||
|
||||
const handleAddCatalog = async () => {
|
||||
if (!newCatalog.name || !newCatalog.url) return;
|
||||
|
||||
if (
|
||||
process.env['NODE_ENV'] === 'production' &&
|
||||
isWebAppPlatform() &&
|
||||
isLanAddress(newCatalog.url)
|
||||
) {
|
||||
setUrlError(_('Adding LAN addresses is not supported in the web app version.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsValidating(true);
|
||||
setUrlError('');
|
||||
|
||||
const validation = await validateOPDSCatalog(
|
||||
newCatalog.url,
|
||||
newCatalog.username || undefined,
|
||||
newCatalog.password || undefined,
|
||||
);
|
||||
|
||||
if (!validation.valid) {
|
||||
setUrlError(validation.error || _('Invalid OPDS catalog. Please check the URL.'));
|
||||
setIsValidating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const catalog: OPDSCatalog = {
|
||||
id: Date.now().toString(),
|
||||
name: newCatalog.name,
|
||||
url: newCatalog.url,
|
||||
description: newCatalog.description,
|
||||
username: newCatalog.username || undefined,
|
||||
password: newCatalog.password || undefined,
|
||||
};
|
||||
|
||||
saveCatalogs([catalog, ...catalogs]);
|
||||
setNewCatalog({ name: '', url: '', description: '', username: '', password: '' });
|
||||
setUrlError('');
|
||||
setIsValidating(false);
|
||||
setShowAddDialog(false);
|
||||
};
|
||||
|
||||
const handleAddPopularCatalog = (popularCatalog: OPDSCatalog) => {
|
||||
if (catalogs.some((c) => c.url === popularCatalog.url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveCatalogs([...catalogs, { ...popularCatalog }]);
|
||||
};
|
||||
|
||||
const handleRemoveCatalog = (id: string) => {
|
||||
saveCatalogs(catalogs.filter((c) => c.id !== id));
|
||||
};
|
||||
|
||||
const handleOpenCatalog = (catalog: OPDSCatalog) => {
|
||||
const params = new URLSearchParams({ url: catalog.url });
|
||||
if (catalog.username) params.set('username', catalog.username);
|
||||
if (catalog.password) params.set('password', catalog.password);
|
||||
router.push(`/opds?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setShowAddDialog(false);
|
||||
setNewCatalog({ name: '', url: '', description: '', username: '', password: '' });
|
||||
setUrlError('');
|
||||
setShowPassword(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='container max-w-2xl'>
|
||||
<div className='mb-8'>
|
||||
<h1 className='mb-2 text-lg font-bold'>{_('OPDS Catalogs')}</h1>
|
||||
<p className='text-base-content/70 text-sm'>
|
||||
{_('Browse and download books from online catalogs')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* My Catalogs */}
|
||||
<section className='mb-12 text-base'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='font-semibold'>{_('My Catalogs')}</h2>
|
||||
<button onClick={() => setShowAddDialog(true)} className='btn btn-primary btn-sm'>
|
||||
<IoAdd className='h-4 w-4' />
|
||||
{_('Add Catalog')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{catalogs.length === 0 ? (
|
||||
<div className='border-base-300 rounded-lg border-2 border-dashed p-12 text-center'>
|
||||
<IoBook className='text-base-content/30 mx-auto mb-4 h-12 w-12' />
|
||||
<h3 className='mb-2 font-semibold'>{_('No catalogs yet')}</h3>
|
||||
<p className='text-base-content/70 mb-4 text-sm'>
|
||||
{_('Add your first OPDS catalog to start browsing books')}
|
||||
</p>
|
||||
<button onClick={() => setShowAddDialog(true)} className='btn btn-primary btn-sm'>
|
||||
{_('Add Your First Catalog')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
{catalogs.map((catalog) => (
|
||||
<div
|
||||
key={catalog.id}
|
||||
className='card bg-base-100 border-base-300 h-full border shadow-sm transition-shadow hover:shadow-md'
|
||||
>
|
||||
<div className='card-body h-full justify-between p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='mb-1 flex items-center justify-between'>
|
||||
<h3 className='card-title truncate text-sm'>
|
||||
{catalog.icon && <span className='mr-2'>{catalog.icon}</span>}
|
||||
{catalog.name}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => handleRemoveCatalog(catalog.id)}
|
||||
className='btn btn-ghost btn-xs btn-square'
|
||||
title='Remove'
|
||||
>
|
||||
<IoTrash className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
{catalog.description && (
|
||||
<p className='text-base-content/70 mb-2 line-clamp-1 h-6 text-sm sm:line-clamp-2 sm:h-10'>
|
||||
{catalog.description}
|
||||
</p>
|
||||
)}
|
||||
<p className='text-base-content/50 truncate text-xs'>{catalog.url}</p>
|
||||
{catalog.username && (
|
||||
<p className='text-base-content/50 mt-1 text-xs'>
|
||||
{_('Username')}: {catalog.username}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='card-actions mt-4 justify-end'>
|
||||
<button
|
||||
onClick={() => handleOpenCatalog(catalog)}
|
||||
className='btn btn-sm btn-primary'
|
||||
>
|
||||
<IoOpenOutline className='h-4 w-4' />
|
||||
{_('Browse')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Popular Catalogs */}
|
||||
<section className='text-base'>
|
||||
<h2 className='mb-4 font-semibold'>{_('Popular Catalogs')}</h2>
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
{POPULAR_CATALOGS.map((catalog) => {
|
||||
const isAdded = catalogs.some((c) => c.url === catalog.url);
|
||||
return (
|
||||
<div
|
||||
key={catalog.id}
|
||||
className='card bg-base-100 border-base-300 border shadow-sm transition-shadow hover:shadow-md'
|
||||
>
|
||||
<div className='card-body p-4'>
|
||||
<h3 className='card-title mb-1 text-sm'>
|
||||
{catalog.icon && <span className='mr-2'>{catalog.icon}</span>}
|
||||
{catalog.name}
|
||||
</h3>
|
||||
{catalog.description && (
|
||||
<p className='text-base-content/70 line-clamp-2 text-sm'>
|
||||
{catalog.description}
|
||||
</p>
|
||||
)}
|
||||
<div className='card-actions mt-4 justify-end gap-2'>
|
||||
{!isAdded && (
|
||||
<button
|
||||
onClick={() => handleAddPopularCatalog(catalog)}
|
||||
className='btn btn-sm'
|
||||
>
|
||||
<IoAdd className='h-4 w-4' />
|
||||
{_('Add')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleOpenCatalog(catalog)}
|
||||
className='btn btn-sm btn-primary'
|
||||
>
|
||||
<IoOpenOutline className='h-4 w-4' />
|
||||
{_('Browse')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Add Catalog Dialog */}
|
||||
{showAddDialog && (
|
||||
<ModalPortal>
|
||||
<dialog className='modal modal-open'>
|
||||
<div className='modal-box'>
|
||||
<h3 className='mb-4 text-lg font-bold'>{_('Add OPDS Catalog')}</h3>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddCatalog();
|
||||
}}
|
||||
className='space-y-4'
|
||||
>
|
||||
<div className='form-control'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{_('Catalog Name')} *</span>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
value={newCatalog.name}
|
||||
onChange={(e) => setNewCatalog({ ...newCatalog, name: e.target.value })}
|
||||
placeholder={_('My Calibre Library')}
|
||||
className='input input-bordered placeholder:text-sm'
|
||||
disabled={isValidating}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='form-control'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{_('OPDS URL')} *</span>
|
||||
</div>
|
||||
<input
|
||||
type='url'
|
||||
value={newCatalog.url}
|
||||
onChange={(e) => setNewCatalog({ ...newCatalog, url: e.target.value })}
|
||||
placeholder='https://example.com/opds'
|
||||
className='input input-bordered placeholder:text-sm'
|
||||
disabled={isValidating}
|
||||
required
|
||||
/>
|
||||
{urlError && (
|
||||
<div className='label'>
|
||||
<span className='label-text-alt text-error'>{urlError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='form-control'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{_('Username (optional)')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
value={newCatalog.username}
|
||||
onChange={(e) => setNewCatalog({ ...newCatalog, username: e.target.value })}
|
||||
placeholder={_('Username')}
|
||||
className='input input-bordered placeholder:text-sm'
|
||||
disabled={isValidating}
|
||||
autoComplete='username'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='form-control'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{_('Password (optional)')}</span>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={newCatalog.password}
|
||||
onChange={(e) => setNewCatalog({ ...newCatalog, password: e.target.value })}
|
||||
placeholder={_('Password')}
|
||||
className='input input-bordered w-full pr-10 placeholder:text-sm'
|
||||
disabled={isValidating}
|
||||
autoComplete='current-password'
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='btn btn-ghost btn-sm btn-square absolute right-1 top-1/2 -translate-y-1/2'
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<IoEyeOff className='h-4 w-4' />
|
||||
) : (
|
||||
<IoEye className='h-4 w-4' />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='form-control'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{_('Description (optional)')}</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={newCatalog.description}
|
||||
onChange={(e) => setNewCatalog({ ...newCatalog, description: e.target.value })}
|
||||
placeholder={_('A brief description of this catalog')}
|
||||
className='textarea textarea-bordered text-sm placeholder:text-sm'
|
||||
rows={2}
|
||||
disabled={isValidating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='modal-action'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleCloseDialog}
|
||||
className='btn'
|
||||
disabled={isValidating}
|
||||
>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
<button type='submit' className='btn btn-primary' disabled={isValidating}>
|
||||
{isValidating ? (
|
||||
<>
|
||||
<span className='loading loading-spinner loading-sm'></span>
|
||||
{_('Validating...')}
|
||||
</>
|
||||
) : (
|
||||
_('Add Catalog')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method='dialog' className='modal-backdrop'>
|
||||
<button onClick={() => setShowAddDialog(false)}>{_('Close')}</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</ModalPortal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { IoChevronBack, IoChevronForward, IoFilter } from 'react-icons/io5';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { OPDSFeed, OPDSLink } from '@/types/opds';
|
||||
import { PublicationCard } from './PublicationCard';
|
||||
import { NavigationCard } from './NavigationCard';
|
||||
import { groupByArray } from './utils/opdsUtils';
|
||||
|
||||
interface FeedViewProps {
|
||||
feed: OPDSFeed;
|
||||
baseURL: string;
|
||||
resolveURL: (url: string, base: string) => string;
|
||||
onNavigate: (url: string) => void;
|
||||
onPublicationSelect: (groupIndex: number, itemIndex: number) => void;
|
||||
onGenerateCachedImageUrl: (url: string) => Promise<string>;
|
||||
isOPDSCatalog: (type?: string) => boolean;
|
||||
}
|
||||
|
||||
const gridClassName = 'grid grid-cols-3 gap-4 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6';
|
||||
const navigationClassName =
|
||||
'grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 max-[450px]:grid-cols-1';
|
||||
|
||||
export function FeedView({
|
||||
feed,
|
||||
baseURL,
|
||||
resolveURL,
|
||||
onNavigate,
|
||||
onPublicationSelect,
|
||||
onGenerateCachedImageUrl,
|
||||
}: FeedViewProps) {
|
||||
const _ = useTranslation();
|
||||
const linksByRel = useMemo(() => groupByArray(feed.links, (link) => link.rel), [feed.links]);
|
||||
|
||||
const pagination = useMemo(() => {
|
||||
return ['first', 'previous', 'next', 'last'].map((rel) => linksByRel.get(rel));
|
||||
}, [linksByRel]);
|
||||
|
||||
const hasFacets = feed.facets && feed.facets.length > 0;
|
||||
|
||||
const handlePaginationClick = (links?: OPDSLink[]) => {
|
||||
if (links && links.length > 0) {
|
||||
const url = resolveURL(links[0]?.href || '', baseURL);
|
||||
onNavigate(url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigationClick = (href: string) => {
|
||||
const url = resolveURL(href, baseURL);
|
||||
onNavigate(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='container mx-auto max-w-7xl px-4 py-6'>
|
||||
{/* Header */}
|
||||
<div className='opds-header mb-6'>
|
||||
{feed.metadata?.title && <h1 className='mb-2 text-xl font-bold'>{feed.metadata.title}</h1>}
|
||||
{feed.metadata?.subtitle && (
|
||||
<p className='text-base-content/70 text-sm'>{feed.metadata.subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex gap-6'>
|
||||
{/* Facets Sidebar */}
|
||||
{hasFacets && (
|
||||
<aside className='hidden w-64 flex-shrink-0 lg:block'>
|
||||
<div className='sticky top-6'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<IoFilter className='h-5 w-5' />
|
||||
<h2 className='text-lg font-semibold'>Filters</h2>
|
||||
</div>
|
||||
<div className='space-y-6'>
|
||||
{feed.facets?.map((facet, index: number) => (
|
||||
<section key={index}>
|
||||
{facet.metadata?.title && (
|
||||
<h3 className='text-base-content/70 mb-2 text-sm font-medium'>
|
||||
{facet.metadata.title}
|
||||
</h3>
|
||||
)}
|
||||
<ul className='space-y-1'>
|
||||
{facet.links.map((link, linkIndex: number) => {
|
||||
const isActive = link.rel === 'self' || link.rel?.includes('self');
|
||||
const href = resolveURL(link.href, baseURL);
|
||||
return (
|
||||
<li key={linkIndex}>
|
||||
<button
|
||||
onClick={() => handleNavigationClick(href)}
|
||||
className={`w-full rounded px-3 py-1.5 text-left text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary text-primary-content font-medium'
|
||||
: 'hover:bg-base-200'
|
||||
}`}
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='truncate'>{link.title || 'Untitled'}</span>
|
||||
{link.properties?.numberOfItems && (
|
||||
<span className='ml-2 text-xs opacity-70'>
|
||||
{link.properties.numberOfItems}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className='min-w-0 flex-1'>
|
||||
{/* Navigation Items */}
|
||||
{feed.navigation && feed.navigation.length > 0 && (
|
||||
<section className='opds-navigation mb-8'>
|
||||
<div className={navigationClassName}>
|
||||
{feed.navigation.map((item, index: number) => (
|
||||
<NavigationCard
|
||||
key={index}
|
||||
item={item}
|
||||
baseURL={baseURL}
|
||||
onClick={handleNavigationClick}
|
||||
resolveURL={resolveURL}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Publications */}
|
||||
{feed.publications && feed.publications.length > 0 && (
|
||||
<section className='opds-publications mb-8'>
|
||||
<div className={gridClassName}>
|
||||
{feed.publications.map((pub, index: number) => (
|
||||
<PublicationCard
|
||||
key={index}
|
||||
publication={pub}
|
||||
baseURL={baseURL}
|
||||
onClick={() => onPublicationSelect(-1, index)}
|
||||
resolveURL={resolveURL}
|
||||
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Groups */}
|
||||
{feed.groups?.map((group, groupIndex: number) => (
|
||||
<section key={groupIndex} className='mb-12'>
|
||||
{group.metadata && (
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-2xl font-bold'>{group.metadata.title}</h2>
|
||||
{group.links && group.links.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const link = group.links[0];
|
||||
const url = resolveURL(link?.href || '', baseURL);
|
||||
handleNavigationClick(url);
|
||||
}}
|
||||
className='btn btn-sm btn-ghost'
|
||||
>
|
||||
{_('View All')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{group.navigation && (
|
||||
<div className={`opds-navigation ${gridClassName}`}>
|
||||
{group.navigation.map((item, itemIndex: number) => (
|
||||
<NavigationCard
|
||||
key={itemIndex}
|
||||
item={item}
|
||||
baseURL={baseURL}
|
||||
onClick={handleNavigationClick}
|
||||
resolveURL={resolveURL}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{group.publications && (
|
||||
<div className={`opds-publications ${gridClassName}`}>
|
||||
{group.publications.map((pub, itemIndex: number) => (
|
||||
<PublicationCard
|
||||
key={itemIndex}
|
||||
publication={pub}
|
||||
baseURL={baseURL}
|
||||
onClick={() => onPublicationSelect(groupIndex, itemIndex)}
|
||||
resolveURL={resolveURL}
|
||||
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.some((links) => links && links.length > 0) && (
|
||||
<nav className='mt-8 flex justify-center gap-2'>
|
||||
<button
|
||||
onClick={() => handlePaginationClick(pagination[0])}
|
||||
disabled={!pagination[0]}
|
||||
className='btn btn-sm'
|
||||
title={_('First')}
|
||||
>
|
||||
<IoChevronBack className='h-4 w-4' />
|
||||
<IoChevronBack className='-ml-3 h-4 w-4' />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePaginationClick(pagination[1])}
|
||||
disabled={!pagination[1]}
|
||||
className='btn btn-sm'
|
||||
title={_('Previous')}
|
||||
>
|
||||
<IoChevronBack className='h-4 w-4' />
|
||||
{_('Previous')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePaginationClick(pagination[2])}
|
||||
disabled={!pagination[2]}
|
||||
className='btn btn-sm'
|
||||
title={_('Next')}
|
||||
>
|
||||
{_('Next')}
|
||||
<IoChevronForward className='h-4 w-4' />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePaginationClick(pagination[3])}
|
||||
disabled={!pagination[3]}
|
||||
className='btn btn-sm'
|
||||
title={_('Last')}
|
||||
>
|
||||
<IoChevronForward className='h-4 w-4' />
|
||||
<IoChevronForward className='-ml-3 h-4 w-4' />
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { GiBookshelf } from 'react-icons/gi';
|
||||
import { IoChevronBack, IoChevronForward, IoHome } from 'react-icons/io5';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTrafficLight } from '@/hooks/useTrafficLight';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
|
||||
interface NavigationProps {
|
||||
currentURL: string;
|
||||
startURL?: string;
|
||||
onNavigate: (url: string) => void;
|
||||
onBack?: () => void;
|
||||
onForward?: () => void;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
}
|
||||
|
||||
export function Navigation({
|
||||
startURL,
|
||||
onNavigate,
|
||||
onBack,
|
||||
onForward,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
}: NavigationProps) {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { appService } = useEnv();
|
||||
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
|
||||
const handleGoHome = useCallback(() => {
|
||||
if (startURL) {
|
||||
onNavigate(startURL);
|
||||
}
|
||||
}, [startURL, onNavigate]);
|
||||
|
||||
const handleGoLibrary = useCallback(() => {
|
||||
navigateToLibrary(router, '', {}, true);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={clsx(
|
||||
'navbar min-h-0 px-2',
|
||||
'flex h-[48px] w-full items-center',
|
||||
appService?.isMobile ? '' : 'border-base-300 bg-base-200 border-b',
|
||||
)}
|
||||
>
|
||||
<div className={clsx('navbar-start gap-1', isTrafficLightVisible && '!pl-16')}>
|
||||
{onBack && (
|
||||
<button
|
||||
className='btn btn-ghost btn-sm px-3 disabled:bg-transparent'
|
||||
onClick={onBack}
|
||||
disabled={!canGoBack}
|
||||
title={_('Back')}
|
||||
>
|
||||
<IoChevronBack className='h-6 w-6' />
|
||||
</button>
|
||||
)}
|
||||
{onForward && (
|
||||
<button
|
||||
className='btn btn-ghost btn-sm disabled:bg-transparent'
|
||||
onClick={onForward}
|
||||
disabled={!canGoForward}
|
||||
title={_('Forward')}
|
||||
>
|
||||
<IoChevronForward className='h-6 w-6' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='navbar-center'>
|
||||
<h1 className='max-w-md truncate text-base font-semibold'>{_('OPDS Catalog')}</h1>
|
||||
</div>
|
||||
|
||||
<div className='navbar-end gap-2'>
|
||||
<button className='btn btn-ghost btn-sm' onClick={handleGoHome} title={_('Home')}>
|
||||
<IoHome className='h-5 w-5' />
|
||||
</button>
|
||||
<button className='btn btn-ghost btn-sm' onClick={handleGoLibrary} title={_('Library')}>
|
||||
<GiBookshelf className='h-5 w-5' />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { IoChevronForward } from 'react-icons/io5';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { OPDSNavigationItem, SYMBOL } from '@/types/opds';
|
||||
|
||||
interface NavigationCardProps {
|
||||
item: OPDSNavigationItem;
|
||||
baseURL: string;
|
||||
onClick: (href: string) => void;
|
||||
resolveURL: (url: string, base: string) => string;
|
||||
}
|
||||
|
||||
export function NavigationCard({ item, baseURL, onClick, resolveURL }: NavigationCardProps) {
|
||||
const _ = useTranslation();
|
||||
const href = resolveURL(item.href || '', baseURL);
|
||||
const summary = item[SYMBOL.SUMMARY];
|
||||
|
||||
return (
|
||||
<div
|
||||
role='none'
|
||||
onClick={() => onClick(href)}
|
||||
className='card bg-base-100 border-base-300 hover:bg-base-200/30 cursor-pointer rounded-lg border shadow-sm transition-shadow'
|
||||
>
|
||||
<div className='card-body flex justify-center p-4'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h3 className='line-clamp-1 text-sm font-semibold'>{item.title || _('Untitled')}</h3>
|
||||
{item.properties?.numberOfItems && (
|
||||
<p className='text-base-content/60 text-xs'>
|
||||
{_('{{count}} items', { count: item.properties.numberOfItems })}
|
||||
</p>
|
||||
)}
|
||||
{summary && <p className='text-base-content/70 mt-1 line-clamp-1 text-xs'>{summary}</p>}
|
||||
</div>
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<IoChevronForward className='text-base-content/40 h-5 w-5 flex-shrink-0' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { groupByArray } from './utils/opdsUtils';
|
||||
import { CachedImage } from '@/components/CachedImage';
|
||||
import { OPDSPublication, REL } from '@/types/opds';
|
||||
|
||||
interface PublicationCardProps {
|
||||
publication: OPDSPublication;
|
||||
baseURL: string;
|
||||
onClick: () => void;
|
||||
resolveURL: (url: string, base: string) => string;
|
||||
onGenerateCachedImageUrl: (url: string) => Promise<string>;
|
||||
}
|
||||
|
||||
export function PublicationCard({
|
||||
publication,
|
||||
baseURL,
|
||||
onClick,
|
||||
resolveURL,
|
||||
onGenerateCachedImageUrl,
|
||||
}: PublicationCardProps) {
|
||||
const linksByRel = useMemo(
|
||||
() => groupByArray(publication.links, (link) => link.rel),
|
||||
[publication.links],
|
||||
);
|
||||
|
||||
const thumbnailImage = useMemo(() => {
|
||||
const thumbnails = publication.images?.filter((img) =>
|
||||
REL.THUMBNAIL.some((rel: string) => img.rel?.includes(rel)),
|
||||
);
|
||||
return thumbnails?.[0] || publication.images?.[0];
|
||||
}, [publication.images]);
|
||||
|
||||
const coverImage = useMemo(() => {
|
||||
const covers = publication.images?.filter((img) =>
|
||||
REL.COVER.some((rel: string) => img.rel?.includes(rel)),
|
||||
);
|
||||
return covers?.[0];
|
||||
}, [publication.images]);
|
||||
|
||||
const imageLink = coverImage || thumbnailImage;
|
||||
const imageUrl = imageLink ? resolveURL(imageLink.href, baseURL) : null;
|
||||
|
||||
const authors = useMemo(() => {
|
||||
return publication.metadata?.author
|
||||
?.map((author) => (typeof author === 'string' ? author : author.name))
|
||||
.join(', ');
|
||||
}, [publication.metadata?.author]);
|
||||
|
||||
const price = useMemo(() => {
|
||||
const priceLink = publication.links?.find((link) => link.properties?.price);
|
||||
if (priceLink?.properties?.price) {
|
||||
const { currency, value } = priceLink.properties.price;
|
||||
return `${currency} ${value}`;
|
||||
}
|
||||
if (linksByRel.has(REL.ACQ + '/open-access')) {
|
||||
return 'Open Access';
|
||||
}
|
||||
return null;
|
||||
}, [publication.links, linksByRel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role='none'
|
||||
onClick={onClick}
|
||||
className='card bg-base-100 cursor-pointer transition-shadow'
|
||||
>
|
||||
<figure className='bg-base-200 relative aspect-[28/41] rounded-none shadow-md'>
|
||||
<CachedImage
|
||||
src={imageUrl}
|
||||
alt={publication.metadata?.title || 'Book cover'}
|
||||
fill
|
||||
className='object-cover'
|
||||
sizes='(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw'
|
||||
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
|
||||
/>
|
||||
</figure>
|
||||
<div className='py-3'>
|
||||
<h3 className='card-title line-clamp-1 text-sm'>
|
||||
{publication.metadata?.title || 'Untitled'}
|
||||
</h3>
|
||||
{authors && <p className='text-base-content/70 line-clamp-1 text-xs'>{authors}</p>}
|
||||
{price && (
|
||||
<div className='card-actions mt-2 justify-end'>
|
||||
<div className='badge badge-outline badge-sm'>{price}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { IoPricetag } from 'react-icons/io5';
|
||||
import { Book } from '@/types/book';
|
||||
import { groupByArray } from './utils/opdsUtils';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { formatDate, formatLanguage } from '@/utils/book';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { navigateToReader } from '@/utils/nav';
|
||||
import { CachedImage } from '@/components/CachedImage';
|
||||
import { OPDSLink, OPDSPublication, REL, SYMBOL } from '@/types/opds';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
|
||||
interface PublicationViewProps {
|
||||
publication: OPDSPublication;
|
||||
baseURL: string;
|
||||
resolveURL: (url: string, base: string) => string;
|
||||
onDownload: (
|
||||
href: string,
|
||||
type?: string,
|
||||
onProgress?: (progress: { progress: number; total: number }) => void,
|
||||
) => Promise<Book | null | undefined>;
|
||||
onGenerateCachedImageUrl: (url: string) => Promise<string>;
|
||||
}
|
||||
|
||||
export function PublicationView({
|
||||
publication,
|
||||
baseURL,
|
||||
resolveURL,
|
||||
onDownload,
|
||||
onGenerateCachedImageUrl,
|
||||
}: PublicationViewProps) {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloaded, setDownloaded] = useState(false);
|
||||
const [downloadedBook, setDownloadedBook] = useState<Book | null>(null);
|
||||
const [progress, setProgress] = useState<number | null>(null);
|
||||
|
||||
const linksByRel = useMemo(
|
||||
() => groupByArray(publication.links, (link) => link.rel),
|
||||
[publication.links],
|
||||
);
|
||||
|
||||
const coverImage = useMemo(() => {
|
||||
const covers = publication.images?.filter((img) =>
|
||||
REL.COVER.some((rel: string) => img.rel?.includes(rel)),
|
||||
);
|
||||
return covers?.[0] || publication.images?.[0];
|
||||
}, [publication.images]);
|
||||
|
||||
const imageUrl = coverImage ? resolveURL(coverImage.href, baseURL) : null;
|
||||
|
||||
const authors = useMemo(() => {
|
||||
return publication.metadata?.author?.map((author) =>
|
||||
typeof author === 'string' ? author : author.name,
|
||||
);
|
||||
}, [publication.metadata?.author]);
|
||||
|
||||
const acquisitionLinks = useMemo(() => {
|
||||
const links: Array<{ rel: string; links: OPDSLink[] }> = [];
|
||||
for (const [rel, linkList] of Array.from(linksByRel.entries())) {
|
||||
if (rel?.startsWith(REL.ACQ)) {
|
||||
links.push({ rel, links: linkList });
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}, [linksByRel]);
|
||||
|
||||
const handleActionButton = async (href: string, type?: string) => {
|
||||
if (downloadedBook) {
|
||||
navigateToReader(router, [downloadedBook.hash]);
|
||||
return;
|
||||
}
|
||||
|
||||
setDownloading(true);
|
||||
setDownloaded(false);
|
||||
setProgress(null);
|
||||
|
||||
try {
|
||||
const book = await onDownload(href, type, (prog) => {
|
||||
if (prog.total > 0) {
|
||||
const percentage = Math.floor((prog.progress / prog.total) * 100);
|
||||
setProgress(percentage);
|
||||
}
|
||||
});
|
||||
if (book) {
|
||||
setDownloadedBook(book);
|
||||
}
|
||||
eventDispatcher.dispatch('toast', { type: 'success', message: _('Download completed') });
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Download failed') + `:\n${href}`,
|
||||
});
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getAcquisitionLabel = (rel: string): string => {
|
||||
if (rel === REL.ACQ + '/open-access') return _('Open Access');
|
||||
if (rel === REL.ACQ + '/borrow') return _('Borrow');
|
||||
if (rel === REL.ACQ + '/buy') return _('Buy');
|
||||
if (rel === REL.ACQ + '/subscribe') return _('Subscribe');
|
||||
if (rel === REL.ACQ + '/sample') return _('Sample');
|
||||
return _('Download');
|
||||
};
|
||||
|
||||
const content = publication.metadata?.[SYMBOL.CONTENT];
|
||||
const description = publication.metadata?.description;
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col px-6 py-6'>
|
||||
<div className='mb-6 flex w-full flex-row items-start gap-6 max-[320px]:flex-col'>
|
||||
<div className='h-44 flex-shrink-0 sm:h-56 md:h-64'>
|
||||
<div className='bg-base-200 relative aspect-[28/41] h-full overflow-hidden rounded-none shadow-lg'>
|
||||
<CachedImage
|
||||
src={imageUrl}
|
||||
alt={publication.metadata?.title || 'Book cover'}
|
||||
fill
|
||||
className='object-cover'
|
||||
sizes='(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw'
|
||||
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex h-44 min-w-0 flex-col justify-between max-[320px]:h-32 sm:h-56 md:h-64'>
|
||||
<div className='flex flex-col'>
|
||||
{publication.metadata?.subtitle && (
|
||||
<p className='text-base-content/60 mb-1 text-sm'>{publication.metadata.subtitle}</p>
|
||||
)}
|
||||
<h1 className='mb-2 text-base font-bold'>
|
||||
{publication.metadata?.title || 'Untitled'}
|
||||
</h1>
|
||||
{authors && authors.length > 0 && (
|
||||
<p className='text-base-content/70 text-sm'>{authors.join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{acquisitionLinks.length > 0 && (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{acquisitionLinks.map(({ rel, links }) => (
|
||||
<div key={rel} className='flex gap-1'>
|
||||
{links.length === 1 ? (
|
||||
<button
|
||||
onClick={() => handleActionButton(links[0]!.href, links[0]!.type)}
|
||||
disabled={downloading}
|
||||
className={clsx(
|
||||
'btn btn-primary min-w-20 rounded-3xl',
|
||||
downloaded && 'btn-success',
|
||||
)}
|
||||
>
|
||||
{downloadedBook ? _('Open & Read') : getAcquisitionLabel(rel)}
|
||||
</button>
|
||||
) : (
|
||||
<Dropdown
|
||||
label={_('Download')}
|
||||
className='dropdown-bottom flex justify-center'
|
||||
buttonClassName='btn btn-ghost p-0 hover:bg-transparent'
|
||||
disabled={downloading}
|
||||
toggleButton={
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
`btn btn-primary min-w-20 rounded-3xl ${downloading ? 'btn-disabled' : ''}`,
|
||||
downloaded && 'btn-success',
|
||||
)}
|
||||
>
|
||||
{downloadedBook ? _('Open') : getAcquisitionLabel(rel)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'delete-menu dropdown-content dropdown-center no-triangle',
|
||||
'border-base-300 !bg-base-200 z-20 mt-2 max-w-[80vw] shadow-2xl',
|
||||
)}
|
||||
>
|
||||
{links.map((link, idx: number) => (
|
||||
<MenuItem
|
||||
key={idx}
|
||||
noIcon
|
||||
transient
|
||||
label={link.title || link.type || ''}
|
||||
onClick={() => handleActionButton(link.href, link.type)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className='flex h-12 w-12 items-center justify-center'>
|
||||
{downloading && progress && progress > 0 && (
|
||||
<div
|
||||
className='radial-progress flex items-center justify-center text-xs'
|
||||
style={{ '--value': progress, '--size': '2.5rem' } as React.CSSProperties}
|
||||
aria-valuenow={progress || 0}
|
||||
role='progressbar'
|
||||
>
|
||||
{progress}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='max-w-xl items-start space-y-6'>
|
||||
{/* Description */}
|
||||
{(content || description) && (
|
||||
<div className='prose prose-sm max-w-none'>
|
||||
{content ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
content.type === 'html' || content.type === 'xhtml'
|
||||
? content.value
|
||||
: content.value,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata Table */}
|
||||
<div>
|
||||
<style>
|
||||
{`
|
||||
.table :where(th, td) {
|
||||
padding: 10px 0px;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<table className='table text-sm'>
|
||||
<tbody>
|
||||
{publication.metadata?.publisher && (
|
||||
<tr>
|
||||
<th className='w-32'>{_('Publisher')}</th>
|
||||
<td>
|
||||
{typeof publication.metadata.publisher === 'string'
|
||||
? publication.metadata.publisher
|
||||
: Array.isArray(publication.metadata.publisher)
|
||||
? publication.metadata.publisher
|
||||
.map((p) => (typeof p === 'string' ? p : p.name))
|
||||
.join(', ')
|
||||
: publication.metadata.publisher.name}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{publication.metadata?.published && (
|
||||
<tr>
|
||||
<th>{_('Published')}</th>
|
||||
<td>{formatDate(publication.metadata.published, true)}</td>
|
||||
</tr>
|
||||
)}
|
||||
{publication.metadata?.language && (
|
||||
<tr>
|
||||
<th>{_('Language')}</th>
|
||||
<td>
|
||||
{Array.isArray(publication.metadata.language)
|
||||
? publication.metadata.language
|
||||
.map((lang: string) => formatLanguage(lang))
|
||||
.join(', ')
|
||||
: formatLanguage(publication.metadata.language)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{publication.metadata?.identifier && (
|
||||
<tr>
|
||||
<th>{_('Identifier')}</th>
|
||||
<td>
|
||||
<code className='text-xs'>{publication.metadata.identifier}</code>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Tags/Subjects */}
|
||||
{publication.metadata?.subject && publication.metadata.subject.length > 0 && (
|
||||
<div>
|
||||
<h2 className='mb-3 text-sm font-semibold'>{_('Tags')}</h2>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{publication.metadata.subject.map((subject, index: number) => {
|
||||
const tag =
|
||||
typeof subject === 'string' ? subject : subject.name || subject.code || _('Tag');
|
||||
return (
|
||||
<div key={index} className='badge badge-outline max-w-full gap-1'>
|
||||
<IoPricetag className='h-3 min-h-3 w-3 min-w-3' />
|
||||
<div className='truncate' title={tag}>
|
||||
{tag}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { IoSearch } from 'react-icons/io5';
|
||||
import { OPDSSearch } from '@/types/opds';
|
||||
|
||||
interface SearchViewProps {
|
||||
search: OPDSSearch;
|
||||
baseURL: string;
|
||||
onNavigate: (url: string) => void;
|
||||
resolveURL: (url: string, base: string) => string;
|
||||
}
|
||||
|
||||
export function SearchView({ search, baseURL, onNavigate, resolveURL }: SearchViewProps) {
|
||||
const [formData, setFormData] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
search.params?.forEach((param) => {
|
||||
initial[param.name] = param.value || '';
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const map = new Map<string | null, Map<string | null, string>>();
|
||||
|
||||
for (const param of search.params || []) {
|
||||
const value = formData[param.name] || '';
|
||||
const ns = param.ns ?? null;
|
||||
|
||||
if (map.has(ns)) {
|
||||
map.get(ns)!.set(param.name, value);
|
||||
} else {
|
||||
map.set(ns, new Map([[param.name, value]]));
|
||||
}
|
||||
}
|
||||
|
||||
const searchURL = search.search(map);
|
||||
const resolvedURL = resolveURL(searchURL, baseURL);
|
||||
onNavigate(resolvedURL);
|
||||
};
|
||||
|
||||
const handleInputChange = (name: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const getParamLabel = (name: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
searchTerms: 'Search',
|
||||
query: 'Query',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
publisher: 'Publisher',
|
||||
language: 'Language',
|
||||
subject: 'Subject',
|
||||
};
|
||||
return labels[name] || name;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='container mx-auto max-w-2xl px-4 py-12'>
|
||||
<div className='mb-8 text-center'>
|
||||
<h1 className='mb-2 text-3xl font-bold'>{search.metadata?.title || 'Search'}</h1>
|
||||
{search.metadata?.description && (
|
||||
<p className='text-base-content/70'>{search.metadata.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||
{(search.params || []).map((param) => (
|
||||
<div key={`${param.ns || 'default'}-${param.name}`} className='form-control'>
|
||||
<label className='label'>
|
||||
<span className='label-text font-medium'>
|
||||
{getParamLabel(param.name)}
|
||||
{param.required && <span className='text-error ml-1'>*</span>}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type={param.name === 'searchTerms' || param.name === 'query' ? 'search' : 'text'}
|
||||
value={formData[param.name] || ''}
|
||||
onChange={(e) => handleInputChange(param.name, e.target.value)}
|
||||
required={param.required}
|
||||
placeholder={`Enter ${getParamLabel(param.name).toLowerCase()}`}
|
||||
className='input input-bordered w-full'
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={
|
||||
param.name === 'searchTerms' ||
|
||||
param.name === 'query' ||
|
||||
search.params[0]?.name === param.name
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className='pt-4'>
|
||||
<button type='submit' className='btn btn-primary w-full'>
|
||||
<IoSearch className='h-5 w-5' />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { isOPDSCatalog, getPublication, getFeed, getOpenSearch } from 'foliate-js/opds.js';
|
||||
import { md5 } from 'js-md5';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { FeedView } from './FeedView';
|
||||
import { PublicationView } from './PublicationView';
|
||||
import { SearchView } from './SearchView';
|
||||
import { Navigation } from './Navigation';
|
||||
import { getBaseFilename } from '@/utils/path';
|
||||
import { downloadFile } from '@/libs/storage';
|
||||
import { Toast } from '@/components/Toast';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useLibrary } from '@/hooks/useLibrary';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getFileExtFromMimeType } from '@/libs/document';
|
||||
import { OPDSFeed, OPDSPublication, OPDSSearch } from '@/types/opds';
|
||||
import { MIME, parseMediaType, resolveURL } from './utils/opdsUtils';
|
||||
import { getProxiedURL, fetchWithAuth, probeAuth, needsProxy } from './utils/opdsReq';
|
||||
import clsx from 'clsx';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
|
||||
type ViewMode = 'feed' | 'publication' | 'search' | 'loading' | 'error';
|
||||
|
||||
interface OPDSState {
|
||||
feed?: OPDSFeed;
|
||||
publication?: OPDSPublication;
|
||||
search?: OPDSSearch;
|
||||
baseURL: string;
|
||||
currentURL: string;
|
||||
startURL?: string;
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
url: string;
|
||||
state: OPDSState;
|
||||
viewMode: ViewMode;
|
||||
selectedPublication: { groupIndex: number; itemIndex: number } | null;
|
||||
}
|
||||
|
||||
export default function BrowserPage() {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { appService } = useEnv();
|
||||
const { libraryLoaded } = useLibrary();
|
||||
const { safeAreaInsets, isRoundedWindow } = useThemeStore();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('loading');
|
||||
const [state, setState] = useState<OPDSState>({
|
||||
baseURL: '',
|
||||
currentURL: '',
|
||||
});
|
||||
const [selectedPublication, setSelectedPublication] = useState<{
|
||||
groupIndex: number;
|
||||
itemIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const usernameRef = useRef(searchParams?.get('username'));
|
||||
const passwordRef = useRef(searchParams?.get('password'));
|
||||
const loadingOPDSRef = useRef(false);
|
||||
const startURLRef = useRef<string | undefined>(undefined);
|
||||
const historyIndexRef = useRef(-1);
|
||||
const isNavigatingHistoryRef = useRef(false);
|
||||
|
||||
useTheme({ systemUIVisible: false });
|
||||
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => {
|
||||
startURLRef.current = state.startURL;
|
||||
}, [state.startURL]);
|
||||
|
||||
useEffect(() => {
|
||||
historyIndexRef.current = historyIndex;
|
||||
}, [historyIndex]);
|
||||
|
||||
const addToHistory = useCallback(
|
||||
(
|
||||
url: string,
|
||||
newState: OPDSState,
|
||||
viewMode: ViewMode,
|
||||
selectedPub: { groupIndex: number; itemIndex: number } | null = null,
|
||||
) => {
|
||||
const newEntry: HistoryEntry = {
|
||||
url,
|
||||
state: newState,
|
||||
viewMode,
|
||||
selectedPublication: selectedPub,
|
||||
};
|
||||
setHistory((prev) => [...prev.slice(0, historyIndexRef.current + 1), newEntry]);
|
||||
setHistoryIndex((prev) => prev + 1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadOPDS = useCallback(
|
||||
async (url: string, skipHistory = false) => {
|
||||
if (loadingOPDSRef.current) return;
|
||||
loadingOPDSRef.current = true;
|
||||
|
||||
setViewMode('loading');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const useProxy = isWebAppPlatform();
|
||||
const username = usernameRef.current || '';
|
||||
const password = passwordRef.current || '';
|
||||
const res = await fetchWithAuth(url, username, password, useProxy);
|
||||
|
||||
if (!res.ok) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: `Failed to load OPDS feed: ${res.status} ${res.statusText}`,
|
||||
timeout: 5000,
|
||||
type: 'error',
|
||||
});
|
||||
setTimeout(() => {
|
||||
router.back();
|
||||
}, 5000);
|
||||
throw new Error(`Failed to load OPDS feed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const currentStartURL = startURLRef.current || url;
|
||||
const responseURL = res.url;
|
||||
const text = await res.text();
|
||||
|
||||
if (text.startsWith('<')) {
|
||||
const doc = new DOMParser().parseFromString(text, MIME.XML as DOMParserSupportedType);
|
||||
const {
|
||||
documentElement: { localName },
|
||||
} = doc;
|
||||
|
||||
if (localName === 'feed') {
|
||||
const feed = getFeed(doc) as OPDSFeed;
|
||||
const newState = {
|
||||
feed,
|
||||
baseURL: responseURL,
|
||||
currentURL: url,
|
||||
startURL: currentStartURL || responseURL,
|
||||
};
|
||||
setState(newState);
|
||||
setViewMode('feed');
|
||||
setSelectedPublication(null);
|
||||
if (!skipHistory) {
|
||||
addToHistory(url, newState, 'feed', null);
|
||||
}
|
||||
} else if (localName === 'entry') {
|
||||
const publication = getPublication(doc.documentElement);
|
||||
const newState = {
|
||||
publication,
|
||||
baseURL: responseURL,
|
||||
currentURL: url,
|
||||
startURL: currentStartURL || responseURL,
|
||||
};
|
||||
setState(newState);
|
||||
setViewMode('publication');
|
||||
setSelectedPublication(null);
|
||||
|
||||
if (!skipHistory) {
|
||||
addToHistory(url, newState, 'publication', null);
|
||||
}
|
||||
} else if (localName === 'OpenSearchDescription') {
|
||||
const search = getOpenSearch(doc);
|
||||
const newState = {
|
||||
search,
|
||||
baseURL: responseURL,
|
||||
currentURL: url,
|
||||
startURL: currentStartURL || responseURL,
|
||||
};
|
||||
setState(newState);
|
||||
setViewMode('search');
|
||||
setSelectedPublication(null);
|
||||
|
||||
if (!skipHistory) {
|
||||
addToHistory(url, newState, 'search', null);
|
||||
}
|
||||
} else {
|
||||
const contentType = res.headers.get('Content-Type') ?? MIME.HTML;
|
||||
const type = parseMediaType(contentType)?.mediaType ?? MIME.HTML;
|
||||
const htmlDoc = new DOMParser().parseFromString(text, type as DOMParserSupportedType);
|
||||
|
||||
if (!htmlDoc.head) {
|
||||
router.back();
|
||||
throw new Error(`Failed to load OPDS feed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const link = Array.from(htmlDoc.head.querySelectorAll('link')).find((link) =>
|
||||
isOPDSCatalog(link.getAttribute('type') ?? ''),
|
||||
);
|
||||
|
||||
if (!link) {
|
||||
router.back();
|
||||
throw new Error('Document has no link to OPDS feeds');
|
||||
}
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
if (href) {
|
||||
const resolvedURL = resolveURL(href, responseURL);
|
||||
loadOPDS(resolvedURL);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const feed = JSON.parse(text);
|
||||
const newState = {
|
||||
feed,
|
||||
baseURL: responseURL,
|
||||
currentURL: url,
|
||||
startURL: currentStartURL || responseURL,
|
||||
};
|
||||
setState(newState);
|
||||
setViewMode('feed');
|
||||
setSelectedPublication(null);
|
||||
|
||||
if (!skipHistory) {
|
||||
addToHistory(url, newState, 'feed', null);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(e as Error);
|
||||
setViewMode('error');
|
||||
} finally {
|
||||
loadingOPDSRef.current = false;
|
||||
}
|
||||
},
|
||||
[router, addToHistory],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const url = searchParams?.get('url');
|
||||
const username = searchParams?.get('username');
|
||||
const password = searchParams?.get('password');
|
||||
if (url && !isNavigatingHistoryRef.current) {
|
||||
if (username || password) {
|
||||
usernameRef.current = username;
|
||||
passwordRef.current = password;
|
||||
} else {
|
||||
usernameRef.current = null;
|
||||
passwordRef.current = null;
|
||||
}
|
||||
loadOPDS(url);
|
||||
} else if (isNavigatingHistoryRef.current) {
|
||||
isNavigatingHistoryRef.current = false;
|
||||
} else {
|
||||
setViewMode('error');
|
||||
setError(new Error('No OPDS URL provided'));
|
||||
}
|
||||
}, [searchParams, loadOPDS]);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(url: string) => {
|
||||
const newURL = new URL(window.location.href);
|
||||
newURL.searchParams.set('url', url);
|
||||
window.history.pushState({}, '', newURL.toString());
|
||||
loadOPDS(url);
|
||||
},
|
||||
[loadOPDS],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (
|
||||
href: string,
|
||||
type?: string,
|
||||
onProgress?: (progress: { progress: number; total: number }) => void,
|
||||
) => {
|
||||
if (!appService || !libraryLoaded) return;
|
||||
try {
|
||||
const url = resolveURL(href, state.baseURL);
|
||||
const parsed = parseMediaType(type);
|
||||
if (parsed?.mediaType === MIME.HTML) {
|
||||
if (isWebAppPlatform()) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
await openUrl(url);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
const ext = parsed?.mediaType ? getFileExtFromMimeType(parsed.mediaType) : '';
|
||||
const basename = getBaseFilename(url);
|
||||
const filename = ext ? `${basename}.${ext}` : basename;
|
||||
const dstFilePath = await appService?.resolveFilePath(filename, 'Cache');
|
||||
if (dstFilePath) {
|
||||
const username = usernameRef.current || '';
|
||||
const password = passwordRef.current || '';
|
||||
const useProxy = needsProxy(url);
|
||||
let downloadUrl = useProxy ? getProxiedURL(url, '', true) : url;
|
||||
const headers: Record<string, string> = {};
|
||||
if (username || password) {
|
||||
const authHeader = await probeAuth(url, username, password, useProxy);
|
||||
if (authHeader) {
|
||||
headers['Authorization'] = authHeader;
|
||||
downloadUrl = useProxy ? getProxiedURL(url, authHeader, true) : url;
|
||||
}
|
||||
}
|
||||
|
||||
await downloadFile({
|
||||
appService,
|
||||
dst: dstFilePath,
|
||||
cfp: '',
|
||||
url: downloadUrl,
|
||||
headers,
|
||||
singleThreaded: true,
|
||||
onProgress,
|
||||
});
|
||||
const { library, setLibrary } = useLibraryStore.getState();
|
||||
const book = await appService.importBook(dstFilePath, library);
|
||||
setLibrary(library);
|
||||
appService.saveLibraryBooks(library);
|
||||
return book;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Download error:', e);
|
||||
throw e;
|
||||
}
|
||||
return;
|
||||
},
|
||||
[state.baseURL, appService, libraryLoaded],
|
||||
);
|
||||
|
||||
const handleGenerateCachedImageUrl = useCallback(
|
||||
async (url: string) => {
|
||||
if (!appService) return url;
|
||||
const username = usernameRef.current || '';
|
||||
const password = passwordRef.current || '';
|
||||
if (!username && !password) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const cachedKey = `img_${md5(url)}.png`;
|
||||
const cachePrefix = await appService.resolveFilePath('', 'Cache');
|
||||
const cachedPath = `${cachePrefix}/${cachedKey}`;
|
||||
if (await appService.exists(cachedPath, 'None')) {
|
||||
return await appService.getImageURL(cachedPath);
|
||||
} else {
|
||||
const useProxy = needsProxy(url);
|
||||
let downloadUrl = useProxy ? getProxiedURL(url, '', true) : url;
|
||||
const headers: Record<string, string> = {};
|
||||
if (username || password) {
|
||||
const authHeader = await probeAuth(url, username, password, useProxy);
|
||||
if (authHeader) {
|
||||
headers['Authorization'] = authHeader;
|
||||
downloadUrl = useProxy ? getProxiedURL(url, authHeader, true) : url;
|
||||
}
|
||||
}
|
||||
await downloadFile({
|
||||
appService,
|
||||
dst: cachedPath,
|
||||
cfp: '',
|
||||
url: downloadUrl,
|
||||
singleThreaded: true,
|
||||
headers,
|
||||
});
|
||||
return await appService.getImageURL(cachedPath);
|
||||
}
|
||||
},
|
||||
[appService],
|
||||
);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (historyIndex > 0) {
|
||||
const newIndex = historyIndex - 1;
|
||||
const entry = history[newIndex];
|
||||
if (!entry) return;
|
||||
|
||||
isNavigatingHistoryRef.current = true;
|
||||
setHistoryIndex(newIndex);
|
||||
setState(entry.state);
|
||||
setViewMode(entry.viewMode);
|
||||
setSelectedPublication(entry.selectedPublication);
|
||||
|
||||
const newURL = new URL(window.location.href);
|
||||
newURL.searchParams.set('url', entry.url);
|
||||
window.history.replaceState({}, '', newURL.toString());
|
||||
}
|
||||
}, [history, historyIndex]);
|
||||
|
||||
const handleForward = useCallback(() => {
|
||||
if (historyIndex < history.length - 1) {
|
||||
const newIndex = historyIndex + 1;
|
||||
const entry = history[newIndex];
|
||||
if (!entry) return;
|
||||
|
||||
isNavigatingHistoryRef.current = true;
|
||||
setHistoryIndex(newIndex);
|
||||
setState(entry.state);
|
||||
setViewMode(entry.viewMode);
|
||||
setSelectedPublication(entry.selectedPublication);
|
||||
|
||||
const newURL = new URL(window.location.href);
|
||||
newURL.searchParams.set('url', entry.url);
|
||||
window.history.replaceState({}, '', newURL.toString());
|
||||
}
|
||||
}, [history, historyIndex]);
|
||||
|
||||
const handlePublicationSelect = useCallback((groupIndex: number, itemIndex: number) => {
|
||||
setSelectedPublication({ groupIndex, itemIndex });
|
||||
setViewMode('publication');
|
||||
|
||||
// Add this publication view to history
|
||||
setHistory((prev) => {
|
||||
const currentEntry = prev[historyIndexRef.current];
|
||||
if (!currentEntry) return prev;
|
||||
|
||||
const newEntry: HistoryEntry = {
|
||||
url: currentEntry.url,
|
||||
state: currentEntry.state,
|
||||
viewMode: 'publication',
|
||||
selectedPublication: { groupIndex, itemIndex },
|
||||
};
|
||||
|
||||
return [...prev.slice(0, historyIndexRef.current + 1), newEntry];
|
||||
});
|
||||
setHistoryIndex((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
const canGoBack = historyIndex > 0;
|
||||
const canGoForward = historyIndex < history.length - 1;
|
||||
|
||||
const publication =
|
||||
selectedPublication && state.feed
|
||||
? state.feed.groups?.[selectedPublication.groupIndex]?.publications?.[
|
||||
selectedPublication.itemIndex
|
||||
] || state.feed.publications?.[selectedPublication.itemIndex]
|
||||
: state.publication;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-base-100 flex h-screen select-none flex-col',
|
||||
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='relative top-0 z-40 w-full'
|
||||
style={{
|
||||
paddingTop: `${safeAreaInsets?.top || 0}px`,
|
||||
}}
|
||||
>
|
||||
<Navigation
|
||||
currentURL={state.currentURL}
|
||||
startURL={state.startURL}
|
||||
onNavigate={handleNavigate}
|
||||
onBack={handleBack}
|
||||
onForward={handleForward}
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
/>
|
||||
</div>
|
||||
<main className='flex-1 overflow-auto'>
|
||||
{viewMode === 'loading' && (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<div className='text-center'>
|
||||
<div className='loading loading-spinner loading-lg mb-4'></div>
|
||||
<h1 className='text-base font-semibold'>{_('Loading...')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'error' && (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<div className='max-w-md text-center'>
|
||||
<h1 className='text-error mb-4 text-xl font-bold'>{_('Cannot Load Page')}</h1>
|
||||
<p className='text-base-content/70 mb-4'>
|
||||
{error?.message || _('An error occurred')}
|
||||
</p>
|
||||
<button className='btn btn-primary' onClick={() => window.location.reload()}>
|
||||
{_('Reload Page')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'feed' && state.feed && (
|
||||
<FeedView
|
||||
feed={state.feed}
|
||||
baseURL={state.baseURL}
|
||||
onNavigate={handleNavigate}
|
||||
onPublicationSelect={handlePublicationSelect}
|
||||
resolveURL={resolveURL}
|
||||
onGenerateCachedImageUrl={handleGenerateCachedImageUrl}
|
||||
isOPDSCatalog={isOPDSCatalog}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'publication' && publication && (
|
||||
<PublicationView
|
||||
publication={publication}
|
||||
baseURL={state.baseURL}
|
||||
onDownload={handleDownload}
|
||||
resolveURL={resolveURL}
|
||||
onGenerateCachedImageUrl={handleGenerateCachedImageUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'search' && state.search && (
|
||||
<SearchView
|
||||
search={state.search}
|
||||
baseURL={state.baseURL}
|
||||
onNavigate={handleNavigate}
|
||||
resolveURL={resolveURL}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
<Toast />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { md5 } from 'js-md5';
|
||||
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
||||
|
||||
/**
|
||||
* Extract username and password from URL credentials
|
||||
*/
|
||||
const extractCredentialsFromURL = (
|
||||
url: string,
|
||||
): { url: string; username?: string; password?: string } => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const username = decodeURIComponent(urlObj.username) || undefined;
|
||||
const password = decodeURIComponent(urlObj.password) || undefined;
|
||||
|
||||
if (username || password) {
|
||||
urlObj.username = '';
|
||||
urlObj.password = '';
|
||||
return {
|
||||
url: urlObj.toString(),
|
||||
username,
|
||||
password,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse URL:', e);
|
||||
}
|
||||
|
||||
return { url };
|
||||
};
|
||||
|
||||
export const needsProxy = (url: string): boolean => {
|
||||
return isWebAppPlatform() && url.startsWith('http');
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate proxied URL for OPDS requests
|
||||
*/
|
||||
export const getProxiedURL = (url: string, auth: string = '', stream = false): string => {
|
||||
if (url.startsWith('http')) {
|
||||
const { url: cleanUrl } = extractCredentialsFromURL(url);
|
||||
const params = new URLSearchParams();
|
||||
params.append('url', cleanUrl);
|
||||
params.append('stream', `${stream}`);
|
||||
if (auth) {
|
||||
params.append('auth', auth);
|
||||
}
|
||||
const proxyUrl = `/api/opds/proxy?${params.toString()}`;
|
||||
return proxyUrl;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse Digest authentication challenge from WWW-Authenticate header
|
||||
*/
|
||||
const parseDigestChallenge = (challenge: string): Record<string, string> => {
|
||||
const params: Record<string, string> = {};
|
||||
const regex = /(\w+)=["']?([^"',]+)["']?/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(challenge)) !== null) {
|
||||
params[match[1]!] = match[2]!;
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate Digest authentication response hash
|
||||
*/
|
||||
const generateDigestResponse = (
|
||||
username: string,
|
||||
password: string,
|
||||
params: Record<string, string>,
|
||||
method: string,
|
||||
uri: string,
|
||||
nc: string,
|
||||
cnonce: string,
|
||||
) => {
|
||||
const realm = params['realm'];
|
||||
const nonce = params['nonce'];
|
||||
const qop = params['qop'];
|
||||
const algorithm = params['algorithm'];
|
||||
|
||||
let ha1 = md5(`${username}:${realm}:${password}`);
|
||||
|
||||
if (algorithm && algorithm.toLowerCase() === 'md5-sess') {
|
||||
ha1 = md5(`${ha1}:${nonce}:${cnonce}`);
|
||||
}
|
||||
|
||||
const ha2 = md5(`${method}:${uri}`);
|
||||
|
||||
let response: string;
|
||||
|
||||
if (qop) {
|
||||
response = md5(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`);
|
||||
} else {
|
||||
response = md5(`${ha1}:${nonce}:${ha2}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create Digest Authorization header
|
||||
*/
|
||||
export const createDigestAuth = async (
|
||||
username: string,
|
||||
password: string,
|
||||
wwwAuthenticate: string,
|
||||
method: string,
|
||||
uri: string,
|
||||
): Promise<string> => {
|
||||
const params = parseDigestChallenge(wwwAuthenticate);
|
||||
const cnonce = Math.random().toString(36).slice(2);
|
||||
const nc = '00000001';
|
||||
const response = await generateDigestResponse(
|
||||
username,
|
||||
password,
|
||||
params,
|
||||
method,
|
||||
uri,
|
||||
nc,
|
||||
cnonce,
|
||||
);
|
||||
|
||||
const parts = [
|
||||
`username="${username}"`,
|
||||
`realm="${params['realm']}"`,
|
||||
`nonce="${params['nonce']}"`,
|
||||
`uri="${uri}"`,
|
||||
`response="${response}"`,
|
||||
];
|
||||
|
||||
if (params['algorithm']) {
|
||||
parts.push(`algorithm="${params['algorithm']}"`);
|
||||
}
|
||||
|
||||
if (params['opaque']) {
|
||||
parts.push(`opaque="${params['opaque']}"`);
|
||||
}
|
||||
|
||||
if (params['qop']) {
|
||||
parts.push(`qop="auth"`);
|
||||
parts.push(`nc=${nc}`);
|
||||
parts.push(`cnonce="${cnonce}"`);
|
||||
}
|
||||
|
||||
return `Digest ${parts.join(', ')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create Basic Authorization header
|
||||
*/
|
||||
export const createBasicAuth = (username: string, password: string): string => {
|
||||
const credentials = btoa(`${username}:${password}`);
|
||||
return `Basic ${credentials}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Probe URL for authentication requirements using HEAD request
|
||||
* Returns auth header if authentication is needed, null otherwise
|
||||
*/
|
||||
export const probeAuth = async (
|
||||
url: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
useProxy = false,
|
||||
): Promise<string | null> => {
|
||||
const {
|
||||
url: cleanUrl,
|
||||
username: urlUsername,
|
||||
password: urlPassword,
|
||||
} = extractCredentialsFromURL(url);
|
||||
|
||||
const finalUsername = username || urlUsername;
|
||||
const finalPassword = password || urlPassword;
|
||||
|
||||
// No credentials provided, can't generate auth header
|
||||
if (!finalUsername || !finalPassword) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fetchURL = useProxy ? getProxiedURL(cleanUrl) : cleanUrl;
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/atom+xml, application/xml, text/xml',
|
||||
};
|
||||
|
||||
// Probe with HEAD request
|
||||
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
|
||||
const res = await fetch(fetchURL, {
|
||||
method: 'HEAD',
|
||||
headers,
|
||||
});
|
||||
|
||||
// Check if authentication is required
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const wwwAuthenticate = res.headers.get('WWW-Authenticate');
|
||||
if (wwwAuthenticate) {
|
||||
if (wwwAuthenticate.toLowerCase().startsWith('digest')) {
|
||||
const urlObj = new URL(cleanUrl);
|
||||
return await createDigestAuth(
|
||||
finalUsername,
|
||||
finalPassword,
|
||||
wwwAuthenticate,
|
||||
'GET',
|
||||
urlObj.pathname + urlObj.search,
|
||||
);
|
||||
} else if (wwwAuthenticate.toLowerCase().startsWith('basic')) {
|
||||
return createBasicAuth(finalUsername, finalPassword);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Perform authenticated HTTP request with retry logic for Digest/Basic auth
|
||||
*/
|
||||
export const fetchWithAuth = async (
|
||||
url: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
useProxy = false,
|
||||
options: RequestInit = {},
|
||||
): Promise<Response> => {
|
||||
const {
|
||||
url: cleanUrl,
|
||||
username: urlUsername,
|
||||
password: urlPassword,
|
||||
} = extractCredentialsFromURL(url);
|
||||
|
||||
const finalUsername = username || urlUsername;
|
||||
const finalPassword = password || urlPassword;
|
||||
|
||||
const fetchURL = useProxy ? getProxiedURL(cleanUrl) : cleanUrl;
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/atom+xml, application/xml, text/xml',
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
|
||||
let res = await fetch(fetchURL, {
|
||||
...options,
|
||||
method: options.method || 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
// Handle authentication if needed
|
||||
if (!res.ok && (res.status === 401 || res.status === 403) && finalUsername && finalPassword) {
|
||||
const wwwAuthenticate = res.headers.get('WWW-Authenticate');
|
||||
if (wwwAuthenticate) {
|
||||
let authHeader: string | null = null;
|
||||
|
||||
if (wwwAuthenticate.toLowerCase().startsWith('digest')) {
|
||||
const urlObj = new URL(cleanUrl);
|
||||
authHeader = await createDigestAuth(
|
||||
finalUsername,
|
||||
finalPassword,
|
||||
wwwAuthenticate,
|
||||
options.method || 'GET',
|
||||
urlObj.pathname + urlObj.search,
|
||||
);
|
||||
} else if (wwwAuthenticate.toLowerCase().startsWith('basic')) {
|
||||
authHeader = createBasicAuth(finalUsername, finalPassword);
|
||||
}
|
||||
|
||||
if (authHeader) {
|
||||
const finalUrl = useProxy ? `${fetchURL}&auth=${encodeURIComponent(authHeader)}` : fetchURL;
|
||||
res = await fetch(finalUrl, {
|
||||
...options,
|
||||
method: options.method || 'GET',
|
||||
headers: useProxy ? headers : { ...headers, Authorization: authHeader },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
import { isOPDSCatalog } from 'foliate-js/opds.js';
|
||||
import { fetchWithAuth } from './opdsReq';
|
||||
|
||||
export const groupByArray = <T, K>(arr: T[] | undefined, f: (el: T) => K | K[]): Map<K, T[]> => {
|
||||
const map = new Map<K, T[]>();
|
||||
if (arr) {
|
||||
for (const el of arr) {
|
||||
const keys = f(el);
|
||||
for (const key of [keys].flat()) {
|
||||
const group = map.get(key as K);
|
||||
if (group) group.push(el);
|
||||
else map.set(key as K, [el]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
export const MIME = {
|
||||
XML: 'application/xml',
|
||||
ATOM: 'application/atom+xml',
|
||||
XHTML: 'application/xhtml+xml',
|
||||
HTML: 'text/html',
|
||||
EPUB: 'application/epub+zip',
|
||||
PDF: 'application/pdf',
|
||||
OPENSEARCH: 'application/opensearchdescription+xml',
|
||||
};
|
||||
|
||||
export const enum VALIDATION_ERROR {
|
||||
INVALID_URL = 'Invalid URL format',
|
||||
LOAD_FAILED = 'Failed to load OPDS feed',
|
||||
NOT_OPDS = 'Invalid OPDS feed URL',
|
||||
NO_OPDS_LINK = 'Document has no link to OPDS feeds',
|
||||
NO_HREF = 'OPDS link has no href attribute',
|
||||
INVALID_HTML = 'Invalid HTML document',
|
||||
INVALID_CONTENT = 'Content is neither valid XML nor JSON',
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
isValid: boolean;
|
||||
error?: VALIDATION_ERROR | string;
|
||||
data?: {
|
||||
type: 'feed' | 'entry' | 'opensearch' | 'html';
|
||||
doc: Document;
|
||||
text: string;
|
||||
responseURL: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const parseMediaType = (str?: string) => {
|
||||
if (!str) return null;
|
||||
const [mediaType, ...ps] = str.split(/ *; */);
|
||||
if (!mediaType) return null;
|
||||
|
||||
return {
|
||||
mediaType: mediaType.toLowerCase(),
|
||||
parameters: Object.fromEntries(
|
||||
ps
|
||||
.map((p) => {
|
||||
const [name, val] = p.split('=');
|
||||
if (!name) return null;
|
||||
return [name.toLowerCase(), val?.replace(/(^"|"$)/g, '')];
|
||||
})
|
||||
.filter((entry): entry is [string, string] => entry !== null),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveURL = (url: string, relativeTo: string): string => {
|
||||
if (!url) return '';
|
||||
if (relativeTo.includes('/api/opds/proxy?url=')) {
|
||||
const params = new URLSearchParams(relativeTo.split('?')[1]);
|
||||
const proxiedURL = params.get('url') || '';
|
||||
return resolveURL(url, proxiedURL);
|
||||
}
|
||||
try {
|
||||
if (relativeTo.includes(':')) return new URL(url, relativeTo).toString();
|
||||
const root = 'https://invalid.invalid/';
|
||||
const obj = new URL(url, root + relativeTo);
|
||||
obj.search = '';
|
||||
return decodeURI(obj.href.replace(root, ''));
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
export const validateOPDSURL = async (
|
||||
url: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
useProxy = false,
|
||||
): Promise<ValidationResult> => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
const res = await fetchWithAuth(url, username, password, useProxy, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Authentication required. Please check your username and password.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Failed to load OPDS feed: ${res.status} ${res.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const responseURL = res.url;
|
||||
const text = await res.text();
|
||||
|
||||
// Check if it's XML-based OPDS
|
||||
if (text.startsWith('<')) {
|
||||
const doc = new DOMParser().parseFromString(text, MIME.XML as DOMParserSupportedType);
|
||||
const {
|
||||
documentElement: { localName },
|
||||
} = doc;
|
||||
|
||||
if (localName === 'feed') {
|
||||
return {
|
||||
isValid: true,
|
||||
data: { type: 'feed', doc, text, responseURL },
|
||||
};
|
||||
} else if (localName === 'entry') {
|
||||
return {
|
||||
isValid: true,
|
||||
data: { type: 'entry', doc, text, responseURL },
|
||||
};
|
||||
} else if (localName === 'OpenSearchDescription') {
|
||||
return {
|
||||
isValid: true,
|
||||
data: { type: 'opensearch', doc, text, responseURL },
|
||||
};
|
||||
} else {
|
||||
// Check for HTML with OPDS link
|
||||
const contentType = res.headers.get('Content-Type') ?? MIME.HTML;
|
||||
const type = parseMediaType(contentType)?.mediaType ?? MIME.HTML;
|
||||
const htmlDoc = new DOMParser().parseFromString(text, type as DOMParserSupportedType);
|
||||
|
||||
if (!htmlDoc.head) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: VALIDATION_ERROR.NOT_OPDS,
|
||||
};
|
||||
}
|
||||
|
||||
const link = Array.from(htmlDoc.head.querySelectorAll('link')).find((link) =>
|
||||
isOPDSCatalog(link.getAttribute('type') ?? ''),
|
||||
);
|
||||
|
||||
if (!link) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: VALIDATION_ERROR.NOT_OPDS,
|
||||
};
|
||||
}
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
if (!href) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'OPDS link has no href attribute',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
data: { type: 'html', doc: htmlDoc, text, responseURL },
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Check if it's JSON-based OPDS
|
||||
try {
|
||||
const feed = JSON.parse(text);
|
||||
// Basic validation for OPDS JSON feed
|
||||
if (!feed.metadata && !feed.links && !feed.publications && !feed.navigation) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: VALIDATION_ERROR.NOT_OPDS,
|
||||
};
|
||||
}
|
||||
return {
|
||||
isValid: true,
|
||||
data: {
|
||||
type: 'feed',
|
||||
doc: new Document(),
|
||||
text,
|
||||
responseURL,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
isValid: false,
|
||||
error: VALIDATION_ERROR.NOT_OPDS,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('OPDS validation error:', e);
|
||||
return {
|
||||
isValid: false,
|
||||
error: e instanceof Error ? e.message : VALIDATION_ERROR.NOT_OPDS,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import { useTrafficLight } from '@/hooks/useTrafficLight';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
@@ -39,14 +40,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
isTrafficLightVisible,
|
||||
trafficLightInFullscreen,
|
||||
setTrafficLightVisibility,
|
||||
initializeTrafficLightStore,
|
||||
initializeTrafficLightListeners,
|
||||
cleanupTrafficLightListeners,
|
||||
} = useTrafficLightStore();
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const { trafficLightInFullscreen, setTrafficLightVisibility } = useTrafficLightStore();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const { bookKeys, hoveredBookKey, setHoveredBookKey } = useReaderStore();
|
||||
const { systemUIVisible, statusBarHeight } = useThemeStore();
|
||||
@@ -60,17 +55,6 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
if (!isOpen) setHoveredBookKey('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
|
||||
initializeTrafficLightStore(appService);
|
||||
initializeTrafficLightListeners();
|
||||
return () => {
|
||||
cleanupTrafficLightListeners();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
if (isSideBarVisible) return;
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { useEffect, Suspense, useRef, useState } from 'react';
|
||||
import { useEffect, Suspense } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useLibrary } from '@/hooks/useLibrary';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useNotebookStore } from '@/store/notebookStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -51,18 +51,16 @@ Z-Index Layering Guide:
|
||||
|
||||
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { setLibrary } = useLibraryStore();
|
||||
const { appService } = useEnv();
|
||||
const { hoveredBookKey, getView } = useReaderStore();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
const { isSideBarVisible, getIsSideBarVisible, setSideBarVisible } = useSidebarStore();
|
||||
const { isNotebookVisible, getIsNotebookVisible, setNotebookVisible } = useNotebookStore();
|
||||
const { isDarkMode, systemUIAlwaysHidden, isRoundedWindow } = useThemeStore();
|
||||
const { showSystemUI, dismissSystemUI } = useThemeStore();
|
||||
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
|
||||
const [libraryLoaded, setLibraryLoaded] = useState(false);
|
||||
const isInitiating = useRef(false);
|
||||
const { libraryLoaded } = useLibrary();
|
||||
|
||||
useTheme({ systemUIVisible: settings.alwaysShowStatusBar, appThemeColor: 'base-100' });
|
||||
useScreenWakeLock(settings.screenWakeLock);
|
||||
@@ -115,21 +113,6 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appService?.isAndroidApp, sideBarBookKey, isSideBarVisible, isNotebookVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitiating.current) return;
|
||||
isInitiating.current = true;
|
||||
const initLibrary = async () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
const settings = await appService.loadSettings();
|
||||
setSettings(settings);
|
||||
setLibrary(await appService.loadLibraryBooks());
|
||||
setLibraryLoaded(true);
|
||||
};
|
||||
|
||||
initLibrary();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.isMobileApp) return;
|
||||
const systemUIVisible = !!hoveredBookKey || settings.alwaysShowStatusBar;
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { GiBookshelf } from 'react-icons/gi';
|
||||
import { FiSearch } from 'react-icons/fi';
|
||||
import { MdOutlineMenu, MdOutlinePushPin, MdPushPin } from 'react-icons/md';
|
||||
import { MdArrowBackIosNew } from 'react-icons/md';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTrafficLight } from '@/hooks/useTrafficLight';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import BookMenu from './BookMenu';
|
||||
|
||||
@@ -20,30 +19,11 @@ const SidebarHeader: React.FC<{
|
||||
onToggleSearchBar: () => void;
|
||||
}> = ({ isPinned, isSearchBarVisible, onGoToLibrary, onClose, onTogglePin, onToggleSearchBar }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const {
|
||||
isTrafficLightVisible,
|
||||
initializeTrafficLightStore,
|
||||
initializeTrafficLightListeners,
|
||||
setTrafficLightVisibility,
|
||||
cleanupTrafficLightListeners,
|
||||
} = useTrafficLightStore();
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const iconSize14 = useResponsiveSize(14);
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
const iconSize22 = useResponsiveSize(22);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
|
||||
initializeTrafficLightStore(appService);
|
||||
initializeTrafficLightListeners();
|
||||
setTrafficLightVisibility(true);
|
||||
return () => {
|
||||
cleanupTrafficLightListeners();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appService?.hasTrafficLight]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface CachedImageProps {
|
||||
src: string | null;
|
||||
alt: string;
|
||||
fill?: boolean;
|
||||
className?: string;
|
||||
sizes?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
onGenerateCachedImageUrl: (url: string) => Promise<string>;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CachedImage({
|
||||
src,
|
||||
alt,
|
||||
fill,
|
||||
className,
|
||||
sizes,
|
||||
width,
|
||||
height,
|
||||
onGenerateCachedImageUrl,
|
||||
fallback,
|
||||
}: CachedImageProps) {
|
||||
const [cachedUrl, setCachedUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!src) {
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadImage = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const url = await onGenerateCachedImageUrl(src);
|
||||
|
||||
if (!cancelled) {
|
||||
setCachedUrl(url);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to load image'));
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadImage();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src, onGenerateCachedImageUrl]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className='bg-base-200 h-full w-full animate-pulse' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !cachedUrl) {
|
||||
if (fallback) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex h-full w-full items-center justify-center ${className || ''}`}>
|
||||
<div className='text-base-content/30'>
|
||||
<svg className='h-16 w-16' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fill) {
|
||||
return <Image src={cachedUrl} alt={alt} fill className={className} sizes={sizes} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={cachedUrl}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
sizes={sizes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ interface DropdownProps {
|
||||
menuClassName?: string;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
disabled?: boolean;
|
||||
onToggle?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -58,6 +59,7 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
buttonClassName,
|
||||
toggleButton,
|
||||
children,
|
||||
disabled,
|
||||
onToggle,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -66,6 +68,7 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const setIsDropdownOpen = (open: boolean) => {
|
||||
if (disabled) return;
|
||||
setIsOpen(open);
|
||||
onToggle?.(open);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
|
||||
export const useLibrary = () => {
|
||||
const { envConfig } = useEnv();
|
||||
const { setLibrary } = useLibraryStore();
|
||||
const { setSettings } = useSettingsStore();
|
||||
const [libraryLoaded, setLibraryLoaded] = useState(false);
|
||||
const isInitiating = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitiating.current) return;
|
||||
isInitiating.current = true;
|
||||
const initLibrary = async () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
const settings = await appService.loadSettings();
|
||||
setSettings(settings);
|
||||
setLibrary(await appService.loadLibraryBooks());
|
||||
setLibraryLoaded(true);
|
||||
};
|
||||
|
||||
initLibrary();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return { libraryLoaded };
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
|
||||
export const useTrafficLight = () => {
|
||||
const { appService } = useEnv();
|
||||
|
||||
const {
|
||||
isTrafficLightVisible,
|
||||
initializeTrafficLightStore,
|
||||
initializeTrafficLightListeners,
|
||||
setTrafficLightVisibility,
|
||||
cleanupTrafficLightListeners,
|
||||
} = useTrafficLightStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
|
||||
initializeTrafficLightStore(appService);
|
||||
initializeTrafficLightListeners();
|
||||
setTrafficLightVisibility(true);
|
||||
return () => {
|
||||
cleanupTrafficLightListeners();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appService?.hasTrafficLight]);
|
||||
|
||||
return { isTrafficLightVisible };
|
||||
};
|
||||
@@ -81,6 +81,17 @@ export const EXTS: Record<BookFormat, string> = {
|
||||
FBZ: 'fbz',
|
||||
};
|
||||
|
||||
export const MIMETYPES: Record<BookFormat, string[]> = {
|
||||
EPUB: ['application/epub+zip'],
|
||||
PDF: ['application/pdf'],
|
||||
MOBI: ['application/x-mobipocket-ebook'],
|
||||
AZW: ['application/vnd.amazon.ebook'],
|
||||
AZW3: ['application/vnd.amazon.mobi8-ebook', 'application/x-mobi8-ebook'],
|
||||
CBZ: ['application/vnd.comicbook+zip', 'application/zip'],
|
||||
FB2: ['application/x-fictionbook+xml', 'text/xml', 'application/xml'],
|
||||
FBZ: ['application/x-zip-compressed-fb2', 'application/zip'],
|
||||
};
|
||||
|
||||
export class DocumentLoader {
|
||||
private file: File;
|
||||
|
||||
@@ -231,3 +242,13 @@ export const getDirection = (doc: Document) => {
|
||||
const rtl = doc.body.dir === 'rtl' || direction === 'rtl' || doc.documentElement.dir === 'rtl';
|
||||
return { vertical, rtl };
|
||||
};
|
||||
|
||||
export const getFileExtFromMimeType = (mimeType: string): string => {
|
||||
for (const format in MIMETYPES) {
|
||||
const list = MIMETYPES[format as BookFormat];
|
||||
if (list.includes(mimeType)) {
|
||||
return EXTS[format as BookFormat];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
@@ -106,6 +106,8 @@ type DownloadFileParams = {
|
||||
dst: string;
|
||||
cfp: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
singleThreaded?: boolean;
|
||||
onProgress?: ProgressHandler;
|
||||
};
|
||||
|
||||
@@ -114,16 +116,17 @@ export const downloadFile = async ({
|
||||
dst,
|
||||
cfp,
|
||||
url,
|
||||
headers,
|
||||
singleThreaded,
|
||||
onProgress,
|
||||
}: DownloadFileParams) => {
|
||||
try {
|
||||
const userId = await getUserID();
|
||||
if (!userId) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
let downloadUrl = url;
|
||||
if (!downloadUrl) {
|
||||
const userId = await getUserID();
|
||||
if (!userId) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
const fileKey = `${userId}/${cfp}`;
|
||||
const response = await fetchWithAuth(
|
||||
`${API_ENDPOINTS.download}?fileKey=${encodeURIComponent(fileKey)}`,
|
||||
@@ -141,14 +144,14 @@ export const downloadFile = async ({
|
||||
}
|
||||
|
||||
if (isWebAppPlatform()) {
|
||||
const file = await webDownload(downloadUrl, onProgress);
|
||||
const file = await webDownload(downloadUrl, onProgress, headers);
|
||||
await appService.writeFile(dst, 'None', await file.arrayBuffer());
|
||||
} else {
|
||||
await tauriDownload(downloadUrl, dst, onProgress);
|
||||
await tauriDownload(downloadUrl, dst, onProgress, headers, undefined, singleThreaded);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`File '${dst}' download failed:`, error);
|
||||
throw new Error('File download failed');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// Create lookup map
|
||||
const serverRecordsMap = new Map<string, any>();
|
||||
const serverRecordsMap = new Map<string, BookDataRecord>();
|
||||
(serverRecords || []).forEach((record) => {
|
||||
const key = primaryKeys.map((pk) => record[pk]).join('|');
|
||||
serverRecordsMap.set(key, record);
|
||||
|
||||
@@ -157,6 +157,14 @@ export abstract class BaseAppService implements AppService {
|
||||
return await this.fs.readDir(path, base);
|
||||
}
|
||||
|
||||
async exists(path: string, base: BaseDir): Promise<boolean> {
|
||||
return await this.fs.exists(path, base);
|
||||
}
|
||||
|
||||
async getImageURL(path: string): Promise<string> {
|
||||
return await this.fs.getImageURL(path);
|
||||
}
|
||||
|
||||
getCoverImageUrl = (book: Book): string => {
|
||||
return this.fs.getURL(`${this.localBooksDir}/${getCoverFilename(book)}`);
|
||||
};
|
||||
@@ -170,11 +178,11 @@ export abstract class BaseAppService implements AppService {
|
||||
const cachePrefix = await this.fs.getPrefix('Cache');
|
||||
const cachedPath = `${cachePrefix}/${cachedKey}`;
|
||||
if (await this.fs.exists(cachedPath, 'None')) {
|
||||
return this.fs.getURL(cachedPath);
|
||||
return await this.fs.getImageURL(cachedPath);
|
||||
} else {
|
||||
const file = await this.fs.openFile(pathOrUrl, 'None');
|
||||
await this.fs.writeFile(cachedKey, 'Cache', await file.arrayBuffer());
|
||||
return this.fs.getURL(cachedPath);
|
||||
return await this.fs.getImageURL(cachedPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,6 +767,7 @@ export abstract class BaseAppService implements AppService {
|
||||
}
|
||||
|
||||
async saveLibraryBooks(books: Book[]): Promise<void> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const libraryBooks = books.map(({ coverImageUrl, ...rest }) => rest);
|
||||
await this.safeSaveJSON(getLibraryFilename(), 'Books', libraryBooks);
|
||||
}
|
||||
|
||||
@@ -186,6 +186,9 @@ export const nativeFileSystem: FileSystem = {
|
||||
const content = await this.readFile(path, base, 'binary');
|
||||
return URL.createObjectURL(new Blob([content]));
|
||||
},
|
||||
async getImageURL(path: string) {
|
||||
return this.getURL(path);
|
||||
},
|
||||
async openFile(path: string, base: BaseDir, name?: string) {
|
||||
const { fp, baseDir } = this.resolvePath(path, base);
|
||||
let fname = name || getFilename(fp);
|
||||
@@ -280,7 +283,7 @@ export const nativeFileSystem: FileSystem = {
|
||||
async removeFile(path: string, base: BaseDir) {
|
||||
const { fp, baseDir } = this.resolvePath(path, base);
|
||||
|
||||
return remove(fp, baseDir ? { baseDir } : undefined);
|
||||
await remove(fp, baseDir ? { baseDir } : undefined);
|
||||
},
|
||||
async createDir(path: string, base: BaseDir, recursive = false) {
|
||||
const { fp, baseDir } = this.resolvePath(path, base);
|
||||
|
||||
@@ -8,9 +8,9 @@ export const sanitizerTransformer: Transformer = {
|
||||
const allowScript = ctx.viewSettings.allowScript;
|
||||
if (allowScript) return ctx.content;
|
||||
|
||||
let result = ctx.content;
|
||||
const result = ctx.content;
|
||||
|
||||
let sanitized = DOMPurify.sanitize(result, {
|
||||
const sanitized = DOMPurify.sanitize(result, {
|
||||
WHOLE_DOCUMENT: true,
|
||||
FORBID_TAGS: ['script'],
|
||||
ALLOWED_URI_REGEXP:
|
||||
|
||||
@@ -57,7 +57,7 @@ export class EdgeTTSClient implements TTSClient {
|
||||
if (preferredVoice) return preferredVoice.id;
|
||||
|
||||
const availableVoices = (await this.getVoices(lang))[0]?.voices || [];
|
||||
let defaultVoice: TTSVoice | null = availableVoices[0] || null;
|
||||
const defaultVoice: TTSVoice | null = availableVoices[0] || null;
|
||||
if (defaultVoice?.id === 'en-US-AnaNeural') return 'en-US-AriaNeural'; // avoid using AnaNeural as default
|
||||
return defaultVoice?.id || this.#currentVoiceId || 'en-US-AriaNeural';
|
||||
};
|
||||
|
||||
@@ -71,6 +71,9 @@ const indexedDBFileSystem: FileSystem = {
|
||||
return path;
|
||||
}
|
||||
},
|
||||
async getImageURL(path: string) {
|
||||
return await this.getBlobURL(path, 'None');
|
||||
},
|
||||
async openFile(path: string, base: BaseDir, filename?: string) {
|
||||
if (isValidURL(path)) {
|
||||
return await new RemoteFile(path, filename).open();
|
||||
|
||||
@@ -31,6 +31,7 @@ interface FontStoreState {
|
||||
}
|
||||
|
||||
function toSettingsFont(font: CustomFont): CustomFont {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { blobUrl, loaded, error, ...settingsFont } = font;
|
||||
return settingsFont;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ interface TextureStoreState {
|
||||
}
|
||||
|
||||
function toSettingsTexture(texture: CustomTexture): CustomTexture {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { blobUrl, loaded, error, ...settingsTexture } = texture;
|
||||
return settingsTexture;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
export const REL = {
|
||||
ACQ: 'http://opds-spec.org/acquisition',
|
||||
FACET: 'http://opds-spec.org/facet',
|
||||
GROUP: 'http://opds-spec.org/group',
|
||||
COVER: ['http://opds-spec.org/image', 'http://opds-spec.org/cover'],
|
||||
THUMBNAIL: ['http://opds-spec.org/image/thumbnail', 'http://opds-spec.org/thumbnail'],
|
||||
};
|
||||
|
||||
const SUMMARY = Symbol('summary');
|
||||
const CONTENT = Symbol('content');
|
||||
|
||||
export const SYMBOL = {
|
||||
SUMMARY,
|
||||
CONTENT,
|
||||
} as const;
|
||||
|
||||
export interface OPDSCatalog {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface OPDSFeed {
|
||||
metadata: {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
links: OPDSLink[];
|
||||
navigation?: OPDSNavigationItem[];
|
||||
publications?: OPDSPublication[];
|
||||
groups?: OPDSGroup[];
|
||||
facets?: OPDSFacet[];
|
||||
}
|
||||
|
||||
export interface OPDSPublication {
|
||||
metadata: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
author?: OPDSPerson[];
|
||||
description?: string;
|
||||
contributor?: OPDSPerson[];
|
||||
publisher?: string | OPDSPerson;
|
||||
published?: string;
|
||||
language?: string;
|
||||
identifier?: string;
|
||||
subject?: OPDSSubject[];
|
||||
rights?: string;
|
||||
[SYMBOL.CONTENT]?: OPDSContent;
|
||||
};
|
||||
links: OPDSLink[];
|
||||
images: OPDSLink[];
|
||||
}
|
||||
|
||||
export interface OPDSSearch {
|
||||
metadata: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
search: (map: Map<string | null, Map<string | null, string>>) => string;
|
||||
params: OPDSSearchParam[];
|
||||
}
|
||||
|
||||
export interface OPDSLink {
|
||||
rel?: string;
|
||||
href: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
properties: {
|
||||
price?: {
|
||||
currency: string;
|
||||
value: string;
|
||||
} | null;
|
||||
indirectAcquisition?: Array<{ type: string }>;
|
||||
numberOfItems?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface OPDSPerson {
|
||||
name: string;
|
||||
links: Array<{ href: string }>;
|
||||
}
|
||||
|
||||
interface OPDSSubject {
|
||||
name?: string;
|
||||
code?: string;
|
||||
scheme?: string;
|
||||
}
|
||||
|
||||
interface OPDSContent {
|
||||
value: string;
|
||||
type: 'text' | 'html' | 'xhtml';
|
||||
}
|
||||
|
||||
export interface OPDSNavigationItem extends Partial<OPDSLink> {
|
||||
title?: string;
|
||||
[SYMBOL.SUMMARY]?: string;
|
||||
}
|
||||
|
||||
interface OPDSGroup {
|
||||
metadata: {
|
||||
title?: string;
|
||||
numberOfItems?: string;
|
||||
};
|
||||
links: Array<{ rel: string; href: string; type?: string }>;
|
||||
publications?: OPDSPublication[];
|
||||
navigation?: OPDSNavigationItem[];
|
||||
}
|
||||
|
||||
export interface OPDSFacet {
|
||||
metadata: {
|
||||
title?: string;
|
||||
};
|
||||
links: OPDSLink[];
|
||||
}
|
||||
|
||||
interface OPDSSearchParam {
|
||||
ns?: string | null;
|
||||
name: string;
|
||||
required?: boolean;
|
||||
value?: string;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { CustomTheme } from '@/styles/themes';
|
||||
import { CustomFont } from '@/styles/fonts';
|
||||
import { CustomTexture } from '@/styles/textures';
|
||||
import { HighlightColor, HighlightStyle, ViewSettings } from './book';
|
||||
import { OPDSCatalog } from './opds';
|
||||
|
||||
export type ThemeType = 'light' | 'dark' | 'auto';
|
||||
export type LibraryViewModeType = 'grid' | 'list';
|
||||
@@ -67,6 +68,7 @@ export interface SystemSettings {
|
||||
libraryColumns: number;
|
||||
customFonts: CustomFont[];
|
||||
customTextures: CustomTexture[];
|
||||
opdsCatalogs: OPDSCatalog[];
|
||||
|
||||
kosync: KOSyncSettings;
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface FileSystem {
|
||||
resolvePath(path: string, base: BaseDir): ResolvedPath;
|
||||
getURL(path: string): string;
|
||||
getBlobURL(path: string, base: BaseDir): Promise<string>;
|
||||
getImageURL(path: string): Promise<string>;
|
||||
openFile(path: string, base: BaseDir, filename?: string): Promise<File>;
|
||||
copyFile(srcPath: string, dstPath: string, base: BaseDir): Promise<void>;
|
||||
readFile(path: string, base: BaseDir, mode: 'text' | 'binary'): Promise<string | ArrayBuffer>;
|
||||
@@ -74,6 +75,8 @@ export interface AppService {
|
||||
createDir(path: string, base: BaseDir, recursive?: boolean): Promise<void>;
|
||||
deleteFile(path: string, base: BaseDir): Promise<void>;
|
||||
deleteDir(path: string, base: BaseDir, recursive?: boolean): Promise<void>;
|
||||
exists(path: string, base: BaseDir): Promise<boolean>;
|
||||
getImageURL(path: string): Promise<string>;
|
||||
|
||||
setCustomRootDir(customRootDir: string): Promise<void>;
|
||||
resolveFilePath(path: string, base: BaseDir): Promise<string>;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
// Helper method to wait for a middleware to execute before continuing
|
||||
// And to throw an error when an error happens in a middleware
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
export const runMiddleware = (req: NextApiRequest, res: NextApiResponse, fn: Function) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fn(req, res, (result: unknown) => {
|
||||
|
||||
@@ -23,8 +23,8 @@ export function diff(str1: string, str2: string) {
|
||||
j++;
|
||||
k++;
|
||||
} else {
|
||||
let delStart = i;
|
||||
let addStart = j;
|
||||
const delStart = i;
|
||||
const addStart = j;
|
||||
|
||||
while (i < lines1.length && (k >= lcs.length || !trimCompare(lines1[i], lcs[k]))) i++;
|
||||
while (j < lines2.length && (k >= lcs.length || !trimCompare(lines2[j], lcs[k]))) j++;
|
||||
|
||||
@@ -4,7 +4,7 @@ export const copyFiles = async (appService: AppService, srcDir: string, dstDir:
|
||||
let filesToCopy: FileItem[] = [];
|
||||
try {
|
||||
filesToCopy = await appService.readDirectory(srcDir, 'None');
|
||||
} catch (error) {
|
||||
} catch {
|
||||
throw new Error(`Dir ${srcDir} failed to read.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,14 @@ export const getFilename = (fileOrUri: string) => {
|
||||
|
||||
export const getBaseFilename = (filename: string) => {
|
||||
const normalizedPath = filename.replace(/\\/g, '/');
|
||||
const baseName = normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
|
||||
return baseName;
|
||||
const name = normalizedPath.split('/').pop() || '';
|
||||
|
||||
const parts = name.split('.');
|
||||
if (parts.length <= 1) {
|
||||
return name;
|
||||
}
|
||||
|
||||
return parts.slice(0, -1).join('.');
|
||||
};
|
||||
|
||||
export const getDirPath = (filePath: string) => {
|
||||
|
||||
@@ -2,6 +2,11 @@ import { invoke, Channel } from '@tauri-apps/api/core';
|
||||
|
||||
export type UploadMethod = 'POST' | 'PUT';
|
||||
|
||||
export const enum UploadFileError {
|
||||
Unauthorized = 'Unauthorized access',
|
||||
DownloadFailed = 'File download failed',
|
||||
}
|
||||
|
||||
export interface ProgressPayload {
|
||||
progress: number;
|
||||
total: number;
|
||||
@@ -40,9 +45,21 @@ export const webUpload = (file: File, uploadUrl: string, onProgress?: ProgressHa
|
||||
});
|
||||
};
|
||||
|
||||
export const webDownload = async (downloadUrl: string, onProgress?: ProgressHandler) => {
|
||||
const response = await fetch(downloadUrl);
|
||||
if (!response.ok) throw new Error('File download failed');
|
||||
export const webDownload = async (
|
||||
downloadUrl: string,
|
||||
onProgress?: ProgressHandler,
|
||||
headers?: Record<string, string>,
|
||||
) => {
|
||||
const response = await fetch(downloadUrl, {
|
||||
method: 'GET',
|
||||
headers: headers ? headers : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error(UploadFileError.Unauthorized);
|
||||
}
|
||||
throw new Error(UploadFileError.DownloadFailed);
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
if (!contentLength) throw new Error('Cannot track progress: Content-Length missing');
|
||||
@@ -102,8 +119,9 @@ export const tauriDownload = async (
|
||||
url: string,
|
||||
filePath: string,
|
||||
progressHandler?: ProgressHandler,
|
||||
headers?: Map<string, string>,
|
||||
headers?: Record<string, string>,
|
||||
body?: string,
|
||||
singleThreaded?: boolean,
|
||||
): Promise<void> => {
|
||||
const ids = new Uint32Array(1);
|
||||
window.crypto.getRandomValues(ids);
|
||||
@@ -121,5 +139,6 @@ export const tauriDownload = async (
|
||||
headers: headers ?? {},
|
||||
onProgress,
|
||||
body,
|
||||
singleThreaded,
|
||||
});
|
||||
};
|
||||
|
||||
Generated
+195
-16
@@ -270,6 +270,12 @@ importers:
|
||||
'@types/uuid':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: ^8.48.0
|
||||
version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
|
||||
'@typescript-eslint/parser':
|
||||
specifier: ^8.48.0
|
||||
version: 8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(vite@7.0.6(@types/node@22.15.31)(jiti@1.21.6)(terser@5.44.0)(yaml@2.8.1))
|
||||
@@ -293,7 +299,7 @@ importers:
|
||||
version: 9.28.0(jiti@1.21.6)
|
||||
eslint-config-next:
|
||||
specifier: 16.0.0
|
||||
version: 16.0.0(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
|
||||
version: 16.0.0(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
|
||||
eslint-plugin-jsx-a11y:
|
||||
specifier: ^6.10.2
|
||||
version: 6.10.2(eslint@9.28.0(jiti@1.21.6))
|
||||
@@ -3223,6 +3229,14 @@ packages:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.48.0':
|
||||
resolution: {integrity: sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': ^8.48.0
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/parser@8.46.2':
|
||||
resolution: {integrity: sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -3230,22 +3244,45 @@ packages:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/parser@8.48.0':
|
||||
resolution: {integrity: sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/project-service@8.46.2':
|
||||
resolution: {integrity: sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/project-service@8.48.0':
|
||||
resolution: {integrity: sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/scope-manager@8.46.2':
|
||||
resolution: {integrity: sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/scope-manager@8.48.0':
|
||||
resolution: {integrity: sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.46.2':
|
||||
resolution: {integrity: sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.48.0':
|
||||
resolution: {integrity: sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/type-utils@8.46.2':
|
||||
resolution: {integrity: sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -3253,16 +3290,33 @@ packages:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/type-utils@8.48.0':
|
||||
resolution: {integrity: sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/types@8.46.2':
|
||||
resolution: {integrity: sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/types@8.48.0':
|
||||
resolution: {integrity: sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.46.2':
|
||||
resolution: {integrity: sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.48.0':
|
||||
resolution: {integrity: sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/utils@8.46.2':
|
||||
resolution: {integrity: sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -3270,10 +3324,21 @@ packages:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/utils@8.48.0':
|
||||
resolution: {integrity: sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.46.2':
|
||||
resolution: {integrity: sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.48.0':
|
||||
resolution: {integrity: sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@vitejs/plugin-react@4.7.0':
|
||||
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
@@ -4452,6 +4517,15 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
peerDependencies:
|
||||
picomatch: ^3 || ^4
|
||||
peerDependenciesMeta:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fetch-blob@3.2.0:
|
||||
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
|
||||
engines: {node: ^12.20 || >= 14.13}
|
||||
@@ -6609,6 +6683,10 @@ packages:
|
||||
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinypool@1.1.1:
|
||||
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
@@ -11215,6 +11293,23 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.1
|
||||
'@typescript-eslint/parser': 8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
|
||||
'@typescript-eslint/scope-manager': 8.48.0
|
||||
'@typescript-eslint/type-utils': 8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
|
||||
'@typescript-eslint/utils': 8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
|
||||
'@typescript-eslint/visitor-keys': 8.48.0
|
||||
eslint: 9.28.0(jiti@1.21.6)
|
||||
graphemer: 1.4.0
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.1.0(typescript@5.7.2)
|
||||
typescript: 5.7.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.46.2
|
||||
@@ -11227,6 +11322,18 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.48.0
|
||||
'@typescript-eslint/types': 8.48.0
|
||||
'@typescript-eslint/typescript-estree': 8.48.0(typescript@5.7.2)
|
||||
'@typescript-eslint/visitor-keys': 8.48.0
|
||||
debug: 4.4.1
|
||||
eslint: 9.28.0(jiti@1.21.6)
|
||||
typescript: 5.7.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.46.2(typescript@5.7.2)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.7.2)
|
||||
@@ -11236,15 +11343,33 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.48.0(typescript@5.7.2)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.48.0(typescript@5.7.2)
|
||||
'@typescript-eslint/types': 8.48.0
|
||||
debug: 4.4.1
|
||||
typescript: 5.7.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/scope-manager@8.46.2':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.46.2
|
||||
'@typescript-eslint/visitor-keys': 8.46.2
|
||||
|
||||
'@typescript-eslint/scope-manager@8.48.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.48.0
|
||||
'@typescript-eslint/visitor-keys': 8.48.0
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.46.2(typescript@5.7.2)':
|
||||
dependencies:
|
||||
typescript: 5.7.2
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.48.0(typescript@5.7.2)':
|
||||
dependencies:
|
||||
typescript: 5.7.2
|
||||
|
||||
'@typescript-eslint/type-utils@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.46.2
|
||||
@@ -11257,8 +11382,22 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/type-utils@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.48.0
|
||||
'@typescript-eslint/typescript-estree': 8.48.0(typescript@5.7.2)
|
||||
'@typescript-eslint/utils': 8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
|
||||
debug: 4.4.1
|
||||
eslint: 9.28.0(jiti@1.21.6)
|
||||
ts-api-utils: 2.1.0(typescript@5.7.2)
|
||||
typescript: 5.7.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/types@8.46.2': {}
|
||||
|
||||
'@typescript-eslint/types@8.48.0': {}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.46.2(typescript@5.7.2)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.46.2(typescript@5.7.2)
|
||||
@@ -11275,6 +11414,21 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.48.0(typescript@5.7.2)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.48.0(typescript@5.7.2)
|
||||
'@typescript-eslint/tsconfig-utils': 8.48.0(typescript@5.7.2)
|
||||
'@typescript-eslint/types': 8.48.0
|
||||
'@typescript-eslint/visitor-keys': 8.48.0
|
||||
debug: 4.4.1
|
||||
minimatch: 9.0.5
|
||||
semver: 7.7.3
|
||||
tinyglobby: 0.2.15
|
||||
ts-api-utils: 2.1.0(typescript@5.7.2)
|
||||
typescript: 5.7.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.0(eslint@9.28.0(jiti@1.21.6))
|
||||
@@ -11286,11 +11440,27 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.0(eslint@9.28.0(jiti@1.21.6))
|
||||
'@typescript-eslint/scope-manager': 8.48.0
|
||||
'@typescript-eslint/types': 8.48.0
|
||||
'@typescript-eslint/typescript-estree': 8.48.0(typescript@5.7.2)
|
||||
eslint: 9.28.0(jiti@1.21.6)
|
||||
typescript: 5.7.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.46.2':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.46.2
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.48.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.48.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
||||
'@vitejs/plugin-react@4.7.0(vite@7.0.6(@types/node@22.15.31)(jiti@1.21.6)(terser@5.44.0)(yaml@2.8.1))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.0
|
||||
@@ -12414,13 +12584,13 @@ snapshots:
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
eslint-config-next@16.0.0(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2):
|
||||
eslint-config-next@16.0.0(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2):
|
||||
dependencies:
|
||||
'@next/eslint-plugin-next': 16.0.0
|
||||
eslint: 9.28.0(jiti@1.21.6)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.28.0(jiti@1.21.6))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6))
|
||||
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.28.0(jiti@1.21.6))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6))
|
||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.28.0(jiti@1.21.6))
|
||||
eslint-plugin-react: 7.37.2(eslint@9.28.0(jiti@1.21.6))
|
||||
eslint-plugin-react-hooks: 7.0.0(eslint@9.28.0(jiti@1.21.6))
|
||||
@@ -12446,48 +12616,48 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.28.0(jiti@1.21.6)):
|
||||
eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.28.0(jiti@1.21.6)):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.1
|
||||
enhanced-resolve: 5.18.3
|
||||
eslint: 9.28.0(jiti@1.21.6)
|
||||
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6))
|
||||
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6))
|
||||
fast-glob: 3.3.2
|
||||
get-tsconfig: 4.8.1
|
||||
is-bun-module: 1.3.0
|
||||
is-glob: 4.0.3
|
||||
optionalDependencies:
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6))
|
||||
transitivePeerDependencies:
|
||||
- '@typescript-eslint/parser'
|
||||
- eslint-import-resolver-node
|
||||
- eslint-import-resolver-webpack
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6)):
|
||||
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6)):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
|
||||
'@typescript-eslint/parser': 8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
|
||||
eslint: 9.28.0(jiti@1.21.6)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.28.0(jiti@1.21.6))
|
||||
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.28.0(jiti@1.21.6))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6)):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6)):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
|
||||
'@typescript-eslint/parser': 8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
|
||||
eslint: 9.28.0(jiti@1.21.6)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.28.0(jiti@1.21.6))
|
||||
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.28.0(jiti@1.21.6))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6)):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6)):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
@@ -12498,7 +12668,7 @@ snapshots:
|
||||
doctrine: 2.1.0
|
||||
eslint: 9.28.0(jiti@1.21.6)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6))
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6))
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
@@ -12510,7 +12680,7 @@ snapshots:
|
||||
string.prototype.trimend: 1.0.9
|
||||
tsconfig-paths: 3.15.0
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
|
||||
'@typescript-eslint/parser': 8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
|
||||
transitivePeerDependencies:
|
||||
- eslint-import-resolver-typescript
|
||||
- eslint-import-resolver-webpack
|
||||
@@ -12760,6 +12930,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
fetch-blob@3.2.0:
|
||||
dependencies:
|
||||
node-domexception: 1.0.0
|
||||
@@ -15063,6 +15237,11 @@ snapshots:
|
||||
fdir: 6.4.6(picomatch@4.0.2)
|
||||
picomatch: 4.0.2
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
|
||||
tinypool@1.1.1: {}
|
||||
|
||||
tinyrainbow@2.0.0: {}
|
||||
|
||||
Reference in New Issue
Block a user