forked from akai/readest
sync: initial support to sync document files across devices (#260)
This commit is contained in:
@@ -29,6 +29,8 @@
|
||||
"release-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- bash scripts/release-mac-appstore.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.735.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.735.0",
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"@fabianlars/tauri-plugin-oauth": "2",
|
||||
"@supabase/auth-ui-react": "^0.4.7",
|
||||
@@ -55,6 +57,7 @@
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"next": "15.1.0",
|
||||
"posthog-js": "^1.205.0",
|
||||
"react": "19.0.0",
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Speichern",
|
||||
"Scrolled Mode": "Scroll-Modus",
|
||||
"Search": "Suchen",
|
||||
"Search books...": "Bücher suchen...",
|
||||
"Search Books...": "Bücher suchen...",
|
||||
"Search...": "Suchen...",
|
||||
"Select Book": "Buch auswählen",
|
||||
"Select books": "Bücher auswählen",
|
||||
"Select multiple books": "Mehrere Bücher auswählen",
|
||||
"Select Books": "Bücher auswählen",
|
||||
"Select Multiple Books": "Mehrere Bücher auswählen",
|
||||
"Sepia": "Sepia",
|
||||
"Serif Font": "Serifenschrift",
|
||||
"Show Book Details": "Buchdetails anzeigen",
|
||||
@@ -138,5 +138,11 @@
|
||||
"Already the latest version": "Bereits die neueste Version",
|
||||
"Book Details": "Buchdetails",
|
||||
"From Local File": "Aus lokaler Datei",
|
||||
"TOC": "Inhaltsverzeichnis"
|
||||
"TOC": "Inhaltsverzeichnis",
|
||||
"Book uploaded: {{title}}": "Buch hochgeladen: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Fehler beim Hochladen des Buches: {{title}}",
|
||||
"Book downloaded: {{title}}": "Buch heruntergeladen: {{title}}",
|
||||
"Failed to download book: {{title}}": "Fehler beim Herunterladen des Buches: {{title}}",
|
||||
"Upload Book": "Buch hochladen",
|
||||
"Auto Upload Books to Cloud": "Bücher automatisch in die Cloud hochladen"
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Αποθήκευση",
|
||||
"Scrolled Mode": "Λειτουργία κύλισης",
|
||||
"Search": "Αναζήτηση",
|
||||
"Search books...": "Αναζήτηση βιβλίων...",
|
||||
"Search Books...": "Αναζήτηση βιβλίων...",
|
||||
"Search...": "Αναζήτηση...",
|
||||
"Select Book": "Επιλογή βιβλίου",
|
||||
"Select books": "Επιλογή βιβλίων",
|
||||
"Select multiple books": "Επιλογή πολλαπλών βιβλίων",
|
||||
"Select Books": "Επιλογή βιβλίων",
|
||||
"Select Multiple Books": "Επιλογή πολλαπλών βιβλίων",
|
||||
"Sepia": "Σέπια",
|
||||
"Serif Font": "Γραμματοσειρά Serif",
|
||||
"Show Book Details": "Εμφάνιση λεπτομερειών βιβλίου",
|
||||
@@ -138,5 +138,11 @@
|
||||
"Already the latest version": "Ήδη η τελευταία έκδοση",
|
||||
"Book Details": "Λεπτομέρειες βιβλίου",
|
||||
"From Local File": "Από τοπικό αρχείο",
|
||||
"TOC": "Πίνακας περιεχομένων"
|
||||
"TOC": "Πίνακας περιεχομένων",
|
||||
"Book uploaded: {{title}}": "Το βιβλίο με τίτλο {{title}} ανέβηκε",
|
||||
"Failed to upload book: {{title}}": "Αποτυχία ανέβασμα βιβλίου: {{title}}",
|
||||
"Book downloaded: {{title}}": "Το βιβλίο με τίτλο {{title}} κατέβηκε",
|
||||
"Failed to download book: {{title}}": "Αποτυχία λήψης βιβλίου: {{title}}",
|
||||
"Upload Book": "Ανέβασμα βιβλίου",
|
||||
"Auto Upload Books to Cloud": "Αυτόματο ανέβασμα βιβλίων στο cloud"
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Guardar",
|
||||
"Scrolled Mode": "Modo desplazamiento",
|
||||
"Search": "Buscar",
|
||||
"Search books...": "Buscar libros...",
|
||||
"Search Books...": "Buscar libros...",
|
||||
"Search...": "Buscar...",
|
||||
"Select Book": "Seleccionar libro",
|
||||
"Select books": "Seleccionar libros",
|
||||
"Select multiple books": "Seleccionar varios libros",
|
||||
"Select Books": "Seleccionar libros",
|
||||
"Select Multiple Books": "Seleccionar varios libros",
|
||||
"Sepia": "Sepia",
|
||||
"Serif Font": "Fuente serif",
|
||||
"Show Book Details": "Mostrar detalles del libro",
|
||||
@@ -138,5 +138,11 @@
|
||||
"Already the latest version": "Ya es la última versión",
|
||||
"Book Details": "Detalles del libro",
|
||||
"From Local File": "Desde archivo local",
|
||||
"TOC": "Índice"
|
||||
"TOC": "Índice",
|
||||
"Book uploaded: {{title}}": "Libro subido: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Error al subir libro: {{title}}",
|
||||
"Book downloaded: {{title}}": "Libro descargado: {{title}}",
|
||||
"Failed to download book: {{title}}": "Error al descargar libro: {{title}}",
|
||||
"Upload Book": "Subir libro",
|
||||
"Auto Upload Books to Cloud": "Subir libros automáticamente a la nube"
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Enregistrer",
|
||||
"Scrolled Mode": "Mode défilement",
|
||||
"Search": "Rechercher",
|
||||
"Search books...": "Rechercher des livres...",
|
||||
"Search Books...": "Rechercher des livres...",
|
||||
"Search...": "Rechercher...",
|
||||
"Select Book": "Sélectionner un livre",
|
||||
"Select books": "Sélectionner des livres",
|
||||
"Select multiple books": "Sélectionner plusieurs livres",
|
||||
"Select Books": "Sélectionner des livres",
|
||||
"Select Multiple Books": "Sélectionner plusieurs livres",
|
||||
"Sepia": "Sépia",
|
||||
"Serif Font": "Police avec empattement",
|
||||
"Show Book Details": "Afficher les détails du livre",
|
||||
@@ -138,5 +138,11 @@
|
||||
"Already the latest version": "Déjà la dernière version",
|
||||
"Book Details": "Détails du livre",
|
||||
"From Local File": "Depuis un fichier local",
|
||||
"TOC": "Table des matières"
|
||||
"TOC": "Table des matières",
|
||||
"Book uploaded: {{title}}": "Livre téléchargé : {{title}}",
|
||||
"Failed to upload book: {{title}}": "Échec du téléchargement du livre : {{title}}",
|
||||
"Book downloaded: {{title}}": "Livre téléchargé : {{title}}",
|
||||
"Failed to download book: {{title}}": "Échec du téléchargement du livre : {{title}}",
|
||||
"Upload Book": "Télécharger un livre",
|
||||
"Auto Upload Books to Cloud": "Télécharger automatiquement les livres dans le cloud"
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "सहेजें",
|
||||
"Scrolled Mode": "स्क्रॉल मोड",
|
||||
"Search": "खोजें",
|
||||
"Search books...": "पुस्तकें खोजें...",
|
||||
"Search Books...": "पुस्तकें खोजें...",
|
||||
"Search...": "खोजें...",
|
||||
"Select Book": "पुस्तक चुनें",
|
||||
"Select books": "पुस्तकें चुनें",
|
||||
"Select multiple books": "कई पुस्तकें चुनें",
|
||||
"Select Books": "पुस्तकें चुनें",
|
||||
"Select Multiple Books": "कई पुस्तकें चुनें",
|
||||
"Sepia": "सेपिया",
|
||||
"Serif Font": "सेरिफ फ़ॉन्ट",
|
||||
"Show Book Details": "पुस्तक विवरण दिखाएं",
|
||||
@@ -138,5 +138,11 @@
|
||||
"Already the latest version": "पहले से ही नवीनतम संस्करण",
|
||||
"Book Details": "पुस्तक विवरण",
|
||||
"From Local File": "स्थानीय फ़ाइल से",
|
||||
"TOC": "सामग्री"
|
||||
"TOC": "सामग्री",
|
||||
"Book uploaded: {{title}}": "पुस्तक अपलोड की गई: {{title}}",
|
||||
"Failed to upload book: {{title}}": "पुस्तक अपलोड करने में विफल: {{title}}",
|
||||
"Book downloaded: {{title}}": "पुस्तक डाउनलोड की गई: {{title}}",
|
||||
"Failed to download book: {{title}}": "पुस्तक डाउनलोड करने में विफल: {{title}}",
|
||||
"Upload Book": "पुस्तक अपलोड करें",
|
||||
"Auto Upload Books to Cloud": "पुस्तकें बादल में स्वचालित रूप से अपलोड करें"
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Simpan",
|
||||
"Scrolled Mode": "Mode Gulir",
|
||||
"Search": "Cari",
|
||||
"Search books...": "Cari buku...",
|
||||
"Search Books...": "Cari buku...",
|
||||
"Search...": "Cari...",
|
||||
"Select Book": "Pilih Buku",
|
||||
"Select books": "Pilih buku",
|
||||
"Select multiple books": "Pilih beberapa buku",
|
||||
"Select Books": "Pilih buku",
|
||||
"Select Multiple Books": "Pilih beberapa buku",
|
||||
"Sepia": "Sepia",
|
||||
"Serif Font": "Font Serif",
|
||||
"Show Book Details": "Tampilkan Detail Buku",
|
||||
@@ -138,5 +138,11 @@
|
||||
"Already the latest version": "Sudah versi terbaru",
|
||||
"Book Details": "Detail Buku",
|
||||
"From Local File": "Dari file lokal",
|
||||
"TOC": "Daftar Isi"
|
||||
"TOC": "Daftar Isi",
|
||||
"Book uploaded: {{title}}": "Buku diunggah: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Gagal mengunggah buku: {{title}}",
|
||||
"Book downloaded: {{title}}": "Buku diunduh: {{title}}",
|
||||
"Failed to download book: {{title}}": "Gagal mengunduh buku: {{title}}",
|
||||
"Upload Book": "Unggah Buku",
|
||||
"Auto Upload Books to Cloud": "Unggah Buku Otomatis ke Cloud"
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Salva",
|
||||
"Scrolled Mode": "Modalità scorrimento",
|
||||
"Search": "Cerca",
|
||||
"Search books...": "Cerca libri...",
|
||||
"Search Books...": "Cerca libri...",
|
||||
"Search...": "Cerca...",
|
||||
"Select Book": "Seleziona libro",
|
||||
"Select books": "Seleziona libri",
|
||||
"Select multiple books": "Seleziona più libri",
|
||||
"Select Books": "Seleziona libri",
|
||||
"Select Multiple Books": "Seleziona più libri",
|
||||
"Sepia": "Seppia",
|
||||
"Serif Font": "Font serif",
|
||||
"Show Book Details": "Mostra dettagli libro",
|
||||
@@ -138,5 +138,11 @@
|
||||
"Already the latest version": "Già l'ultima versione",
|
||||
"Book Details": "Dettagli libro",
|
||||
"From Local File": "Da file locale",
|
||||
"TOC": "Sommario"
|
||||
"TOC": "Sommario",
|
||||
"Book uploaded: {{title}}": "Libro caricato: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Caricamento libro non riuscito: {{title}}",
|
||||
"Book downloaded: {{title}}": "Libro scaricato: {{title}}",
|
||||
"Failed to download book: {{title}}": "Download libro non riuscito: {{title}}",
|
||||
"Upload Book": "Carica libro",
|
||||
"Auto Upload Books to Cloud": "Carica libri automaticamente su cloud"
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "保存",
|
||||
"Scrolled Mode": "スクロールモード",
|
||||
"Search": "検索",
|
||||
"Search books...": "書籍を検索...",
|
||||
"Search Books...": "書籍を検索...",
|
||||
"Search...": "検索...",
|
||||
"Select Book": "書籍を選択",
|
||||
"Select books": "書籍を選択",
|
||||
"Select multiple books": "複数の書籍を選択",
|
||||
"Select Books": "書籍を選択",
|
||||
"Select Multiple Books": "複数の書籍を選択",
|
||||
"Sepia": "セピア",
|
||||
"Serif Font": "明朝体",
|
||||
"Show Book Details": "書籍の詳細を表示",
|
||||
@@ -138,5 +138,11 @@
|
||||
"Already the latest version": "すでに最新バージョン",
|
||||
"Book Details": "書籍の詳細",
|
||||
"From Local File": "ローカルファイルから",
|
||||
"TOC": "目次"
|
||||
"TOC": "目次",
|
||||
"Book uploaded: {{title}}": "書籍がアップロードされました:{{title}}",
|
||||
"Failed to upload book: {{title}}": "書籍のアップロードに失敗しました:{{title}}",
|
||||
"Book downloaded: {{title}}": "書籍がダウンロードされました:{{title}}",
|
||||
"Failed to download book: {{title}}": "書籍のダウンロードに失敗しました:{{title}}",
|
||||
"Upload Book": "書籍をアップロード",
|
||||
"Auto Upload Books to Cloud": "書籍を自動的にクラウドにアップロード"
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "저장",
|
||||
"Scrolled Mode": "스크롤 모드",
|
||||
"Search": "검색",
|
||||
"Search books...": "책 검색...",
|
||||
"Search Books...": "책 검색...",
|
||||
"Search...": "검색...",
|
||||
"Select Book": "책 선택",
|
||||
"Select books": "책 선택",
|
||||
"Select multiple books": "여러 책 선택",
|
||||
"Select Books": "책 선택",
|
||||
"Select Multiple Books": "여러 책 선택",
|
||||
"Sepia": "세피아",
|
||||
"Serif Font": "세리프체",
|
||||
"Show Book Details": "책 세부 정보 표시",
|
||||
@@ -138,5 +138,11 @@
|
||||
"Already the latest version": "이미 최신 버전",
|
||||
"Book Details": "책 세부 정보",
|
||||
"From Local File": "로컬 파일에서",
|
||||
"TOC": "목차"
|
||||
"TOC": "목차",
|
||||
"Book uploaded: {{title}}": "책 업로드됨: {{title}}",
|
||||
"Failed to upload book: {{title}}": "책 업로드 실패: {{title}}",
|
||||
"Book downloaded: {{title}}": "책 다운로드됨: {{title}}",
|
||||
"Failed to download book: {{title}}": "책 다운로드 실패: {{title}}",
|
||||
"Upload Book": "책 업로드",
|
||||
"Auto Upload Books to Cloud": "책 자동으로 클라우드에 업로드"
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Zapisz",
|
||||
"Scrolled Mode": "Tryb przewijania",
|
||||
"Search": "Szukaj",
|
||||
"Search books...": "Szukaj książek...",
|
||||
"Search Books...": "Szukaj książek...",
|
||||
"Search...": "Szukaj...",
|
||||
"Select Book": "Wybierz książkę",
|
||||
"Select books": "Wybierz książki",
|
||||
"Select multiple books": "Wybierz wiele książek",
|
||||
"Select Books": "Wybierz książki",
|
||||
"Select Multiple Books": "Wybierz wiele książek",
|
||||
"Sepia": "Sepia",
|
||||
"Serif Font": "Czcionka szeryfowa",
|
||||
"Show Book Details": "Pokaż szczegóły książki",
|
||||
@@ -138,5 +138,11 @@
|
||||
"Already the latest version": "Już najnowsza wersja",
|
||||
"Book Details": "Szczegóły książki",
|
||||
"From Local File": "Z pliku lokalnego",
|
||||
"TOC": "Spis treści"
|
||||
"TOC": "Spis treści",
|
||||
"Book uploaded: {{title}}": "Książka przesłana: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Nie udało się przesłać książki: {{title}}",
|
||||
"Book downloaded: {{title}}": "Książka pobrana: {{title}}",
|
||||
"Failed to download book: {{title}}": "Nie udało się pobrać książki: {{title}}",
|
||||
"Upload Book": "Prześlij książkę",
|
||||
"Auto Upload Books to Cloud": "Automatycznie przesyłaj książki do chmury"
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Salvar",
|
||||
"Scrolled Mode": "Modo de Rolagem",
|
||||
"Search": "Buscar",
|
||||
"Search books...": "Procurar livros...",
|
||||
"Search Books...": "Procurar livros...",
|
||||
"Search...": "Pesquisar...",
|
||||
"Select Book": "Selecionar Livro",
|
||||
"Select books": "Selecionar livros",
|
||||
"Select multiple books": "Selecionar múltiplos livros",
|
||||
"Select Books": "Selecionar livros",
|
||||
"Select Multiple Books": "Selecionar múltiplos livros",
|
||||
"Sepia": "Sépia",
|
||||
"Serif Font": "Fonte Serif",
|
||||
"Show Book Details": "Mostrar Detalhes do Livro",
|
||||
@@ -138,5 +138,11 @@
|
||||
"Already the latest version": "Já é a versão mais recente",
|
||||
"Book Details": "Detalhes do Livro",
|
||||
"From Local File": "Do arquivo local",
|
||||
"TOC": "Índice"
|
||||
"TOC": "Índice",
|
||||
"Book uploaded: {{title}}": "Livro enviado: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Falha ao enviar livro: {{title}}",
|
||||
"Book downloaded: {{title}}": "Livro baixado: {{title}}",
|
||||
"Failed to download book: {{title}}": "Falha ao baixar livro: {{title}}",
|
||||
"Upload Book": "Enviar Livro",
|
||||
"Auto Upload Books to Cloud": "Enviar Livros para a Nuvem Automaticamente"
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Сохранить",
|
||||
"Scrolled Mode": "Режим прокрутки",
|
||||
"Search": "Поиск",
|
||||
"Search books...": "Поиск книг...",
|
||||
"Search Books...": "Поиск книг...",
|
||||
"Search...": "Поиск...",
|
||||
"Select Book": "Выбрать книгу",
|
||||
"Select books": "Выбрать книги",
|
||||
"Select multiple books": "Выбрать несколько книг",
|
||||
"Select Books": "Выбрать книги",
|
||||
"Select Multiple Books": "Выбрать несколько книг",
|
||||
"Sepia": "Сепия",
|
||||
"Serif Font": "Шрифт с засечками",
|
||||
"Show Book Details": "Показать детали книги",
|
||||
@@ -138,5 +138,11 @@
|
||||
"Already the latest version": "Уже последняя версия",
|
||||
"Book Details": "Детали книги",
|
||||
"From Local File": "Из локального файла",
|
||||
"TOC": "Содержание"
|
||||
"TOC": "Содержание",
|
||||
"Book uploaded: {{title}}": "Книга загружена: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Не удалось загрузить книгу: {{title}}",
|
||||
"Book downloaded: {{title}}": "Книга скачана: {{title}}",
|
||||
"Failed to download book: {{title}}": "Не удалось скачать книгу: {{title}}",
|
||||
"Upload Book": "Загрузить книгу",
|
||||
"Auto Upload Books to Cloud": "Автоматическая загрузка книг в облако"
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Kaydet",
|
||||
"Scrolled Mode": "Kaydırma Modu",
|
||||
"Search": "Ara",
|
||||
"Search books...": "Kitap ara...",
|
||||
"Search Books...": "Kitap ara...",
|
||||
"Search...": "Ara...",
|
||||
"Select Book": "Kitap Seç",
|
||||
"Select books": "Kitapları seç",
|
||||
"Select multiple books": "Birden fazla kitap seç",
|
||||
"Select Books": "Kitapları seç",
|
||||
"Select Multiple Books": "Birden fazla kitap seç",
|
||||
"Sepia": "Sepya",
|
||||
"Serif Font": "Serif Yazı Tipi",
|
||||
"Show Book Details": "Kitap Detaylarını Göster",
|
||||
@@ -138,5 +138,11 @@
|
||||
"Already the latest version": "Zaten en son sürüm",
|
||||
"Book Details": "Kitap Detayları",
|
||||
"From Local File": "Yerel dosyadan",
|
||||
"TOC": "İçindekiler"
|
||||
"TOC": "İçindekiler",
|
||||
"Book uploaded: {{title}}": "Kitap yüklendi: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Kitap yüklenemedi: {{title}}",
|
||||
"Book downloaded: {{title}}": "Kitap indirildi: {{title}}",
|
||||
"Failed to download book: {{title}}": "Kitap indirilemedi: {{title}}",
|
||||
"Upload Book": "Kitap Yükle",
|
||||
"Auto Upload Books to Cloud": "Kitapları Otomatik Olarak Buluta Yükle"
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Зберегти",
|
||||
"Scrolled Mode": "Режим прокрутки",
|
||||
"Search": "Пошук",
|
||||
"Search books...": "Шукати книги...",
|
||||
"Search Books...": "Шукати книги...",
|
||||
"Search...": "Пошук...",
|
||||
"Select Book": "Вибрати книгу",
|
||||
"Select books": "Вибрати книги",
|
||||
"Select multiple books": "Вибрати кілька книг",
|
||||
"Select Books": "Вибрати книги",
|
||||
"Select Multiple Books": "Вибрати кілька книг",
|
||||
"Sepia": "Сепія",
|
||||
"Serif Font": "Шрифт із засічками",
|
||||
"Show Book Details": "Показати деталі книги",
|
||||
@@ -138,5 +138,11 @@
|
||||
"Already the latest version": "Вже остання версія",
|
||||
"Book Details": "Деталі книги",
|
||||
"From Local File": "З локального файлу",
|
||||
"TOC": "Зміст"
|
||||
"TOC": "Зміст",
|
||||
"Book uploaded: {{title}}": "Книгу завантажено: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Не вдалося завантажити книгу: {{title}}",
|
||||
"Book downloaded: {{title}}": "Книгу завантажено: {{title}}",
|
||||
"Failed to download book: {{title}}": "Не вдалося завантажити книгу: {{title}}",
|
||||
"Upload Book": "Завантажити книгу",
|
||||
"Auto Upload Books to Cloud": "Автоматично завантажувати книги в хмару"
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Lưu",
|
||||
"Scrolled Mode": "Chế độ cuộn",
|
||||
"Search": "Tìm kiếm",
|
||||
"Search books...": "Tìm kiếm sách...",
|
||||
"Search Books...": "Tìm kiếm sách...",
|
||||
"Search...": "Tìm kiếm...",
|
||||
"Select Book": "Chọn sách",
|
||||
"Select books": "Chọn sách",
|
||||
"Select multiple books": "Chọn nhiều sách",
|
||||
"Select Books": "Chọn sách",
|
||||
"Select Multiple Books": "Chọn nhiều sách",
|
||||
"Sepia": "Nâu cổ",
|
||||
"Serif Font": "Phông chữ có chân",
|
||||
"Show Book Details": "Hiển thị chi tiết sách",
|
||||
@@ -138,5 +138,11 @@
|
||||
"Already the latest version": "Đã là phiên bản mới nhất",
|
||||
"Book Details": "Chi tiết sách",
|
||||
"From Local File": "Từ tệp địa phương",
|
||||
"TOC": "Mục lục"
|
||||
"TOC": "Mục lục",
|
||||
"Book uploaded: {{title}}": "Sách đã tải lên: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Không thể tải lên sách: {{title}}",
|
||||
"Book downloaded: {{title}}": "Sách đã tải về: {{title}}",
|
||||
"Failed to download book: {{title}}": "Không thể tải về sách: {{title}}",
|
||||
"Upload Book": "Tải lên sách",
|
||||
"Auto Upload Books to Cloud": "Tự động tải sách lên đám mây"
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "保存",
|
||||
"Scrolled Mode": "滚动模式",
|
||||
"Search": "搜索",
|
||||
"Search books...": "搜索书籍...",
|
||||
"Search Books...": "搜索书籍...",
|
||||
"Search...": "搜索...",
|
||||
"Select Book": "选择书籍",
|
||||
"Select books": "选择书籍",
|
||||
"Select multiple books": "选择多本书籍",
|
||||
"Select Books": "选择书籍",
|
||||
"Select Multiple Books": "选择多本书籍",
|
||||
"Sepia": "旧韵",
|
||||
"Serif Font": "衬线字体",
|
||||
"Show Book Details": "显示书籍详情",
|
||||
@@ -138,5 +138,11 @@
|
||||
"Already the latest version": "已经是最新版本",
|
||||
"Book Details": "书籍详情",
|
||||
"From Local File": "从本地文件导入",
|
||||
"TOC": "目录"
|
||||
"TOC": "目录",
|
||||
"Book uploaded: {{title}}": "书籍已上传:{{title}}",
|
||||
"Failed to upload book: {{title}}": "上传书籍失败:{{title}}",
|
||||
"Book downloaded: {{title}}": "书籍已下载:{{title}}",
|
||||
"Failed to download book: {{title}}": "下载书籍失败:{{title}}",
|
||||
"Upload Book": "上传书籍",
|
||||
"Auto Upload Books to Cloud": "自动上传书籍到云端"
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "保存",
|
||||
"Scrolled Mode": "滾動模式",
|
||||
"Search": "搜索",
|
||||
"Search books...": "搜索書籍...",
|
||||
"Search Books...": "搜索書籍...",
|
||||
"Search...": "搜索...",
|
||||
"Select Book": "選擇書籍",
|
||||
"Select books": "選擇書籍",
|
||||
"Select multiple books": "選擇多本書籍",
|
||||
"Select Books": "選擇書籍",
|
||||
"Select Multiple Books": "選擇多本書籍",
|
||||
"Sepia": "舊韻",
|
||||
"Serif Font": "襯線字體",
|
||||
"Show Book Details": "顯示書籍詳情",
|
||||
@@ -138,5 +138,11 @@
|
||||
"Already the latest version": "已經是最新版本",
|
||||
"Book Details": "書籍詳情",
|
||||
"From Local File": "從本地文件導入",
|
||||
"TOC": "目錄"
|
||||
"TOC": "目錄",
|
||||
"Book uploaded: {{title}}": "書籍已上傳:{{title}}",
|
||||
"Failed to upload book: {{title}}": "書籍上傳失敗:{{title}}",
|
||||
"Book downloaded: {{title}}": "書籍已下載:{{title}}",
|
||||
"Failed to download book: {{title}}": "書籍下載失敗:{{title}}",
|
||||
"Upload Book": "上傳書籍",
|
||||
"Auto Upload Books to Cloud": "自動上傳書籍到雲端"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import clsx from 'clsx';
|
||||
import Image from 'next/image';
|
||||
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
|
||||
import { CiCircleMore } from 'react-icons/ci';
|
||||
import { LiaCloudUploadAltSolid, LiaCloudDownloadAltSolid } from 'react-icons/lia';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import ReadingProgress from './ReadingProgress';
|
||||
import { Book } from '@/types/book';
|
||||
|
||||
interface BookItemProps {
|
||||
book: Book;
|
||||
isSelectMode: boolean;
|
||||
selectedBooks: string[];
|
||||
clickedBookHash: string | null;
|
||||
handleBookClick: (book: Book) => void;
|
||||
handleBookUpload: (book: Book) => void;
|
||||
handleBookDownload: (book: Book) => void;
|
||||
showBookDetailsModal: (book: Book) => void;
|
||||
bookContextMenuHandler: (book: Book, e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
const BookItem: React.FC<BookItemProps> = ({
|
||||
book,
|
||||
isSelectMode,
|
||||
selectedBooks,
|
||||
clickedBookHash,
|
||||
handleBookClick,
|
||||
handleBookUpload,
|
||||
handleBookDownload,
|
||||
showBookDetailsModal,
|
||||
bookContextMenuHandler,
|
||||
}) => {
|
||||
const iconSize15 = useResponsiveSize(15);
|
||||
|
||||
return (
|
||||
<div
|
||||
className='book-item cursor-pointer'
|
||||
onContextMenu={bookContextMenuHandler.bind(null, book)}
|
||||
>
|
||||
<div className='bg-base-100 shadow-md' onClick={() => handleBookClick(book)}>
|
||||
<div className='relative aspect-[28/41]'>
|
||||
<Image
|
||||
src={book.coverImageUrl!}
|
||||
alt={book.title}
|
||||
fill={true}
|
||||
className='object-cover'
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('invisible');
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'invisible absolute inset-0 flex items-center justify-center p-1',
|
||||
'text-neutral-content rounded-none text-center font-serif text-base font-medium',
|
||||
'line-clamp-3',
|
||||
)}
|
||||
>
|
||||
{book.title}
|
||||
</div>
|
||||
{(selectedBooks.includes(book.hash) || clickedBookHash === book.hash) && (
|
||||
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
|
||||
)}
|
||||
{isSelectMode && (
|
||||
<div className='absolute bottom-1 right-1'>
|
||||
{selectedBooks.includes(book.hash) ? (
|
||||
<MdCheckCircle className='fill-blue-500' />
|
||||
) : (
|
||||
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex w-full p-0 pt-2',
|
||||
isWebAppPlatform() ? 'flex-col' : 'flex-row justify-between',
|
||||
)}
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='block overflow-hidden text-ellipsis whitespace-nowrap text-[0.6em] text-xs font-semibold'>
|
||||
{book.title}
|
||||
</h4>
|
||||
</div>
|
||||
<div
|
||||
className={clsx('flex items-center', book.progress ? 'justify-between' : 'justify-end')}
|
||||
>
|
||||
{book.progress && <ReadingProgress book={book} />}
|
||||
{isWebAppPlatform() && (
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<button
|
||||
className='show-detail-button opacity-0 group-hover:opacity-100'
|
||||
onClick={() => {
|
||||
if (!book.uploadedAt) {
|
||||
handleBookUpload(book);
|
||||
} else if (!book.downloadedAt) {
|
||||
handleBookDownload(book);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!book.uploadedAt && <LiaCloudUploadAltSolid size={iconSize15} />}
|
||||
{book.uploadedAt && !book.downloadedAt && (
|
||||
<LiaCloudDownloadAltSolid size={iconSize15} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className='show-detail-button opacity-0 group-hover:opacity-100'
|
||||
onClick={() => showBookDetailsModal(book)}
|
||||
>
|
||||
<CiCircleMore size={iconSize15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookItem;
|
||||
@@ -1,14 +1,9 @@
|
||||
import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import { MdDelete, MdOpenInNew } from 'react-icons/md';
|
||||
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
|
||||
import { CiCircleMore } from 'react-icons/ci';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MdDelete, MdOpenInNew } from 'react-icons/md';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import { Menu, MenuItem } from '@tauri-apps/api/menu';
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import { Book, BooksGroup } from '@/types/book';
|
||||
@@ -16,17 +11,17 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { navigateToReader } from '@/utils/nav';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { getFilename } from '@/utils/book';
|
||||
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
|
||||
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
|
||||
import Alert from '@/components/Alert';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import BookDetailModal from '@/components/BookDetailModal';
|
||||
import ReadingProgress from './ReadingProgress';
|
||||
import BookItem from './BookItem';
|
||||
import GroupItem from './GroupItem';
|
||||
|
||||
type BookshelfItem = Book | BooksGroup;
|
||||
|
||||
@@ -57,16 +52,24 @@ const generateBookshelfItems = (books: Book[]): BookshelfItem[] => {
|
||||
interface BookshelfProps {
|
||||
libraryBooks: Book[];
|
||||
isSelectMode: boolean;
|
||||
onImportBooks: () => void;
|
||||
handleImportBooks: () => void;
|
||||
handleBookUpload: (book: Book) => void;
|
||||
handleBookDownload: (book: Book) => void;
|
||||
}
|
||||
|
||||
const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImportBooks }) => {
|
||||
const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
libraryBooks,
|
||||
isSelectMode,
|
||||
handleImportBooks,
|
||||
handleBookUpload,
|
||||
handleBookDownload,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { deleteBook } = useLibraryStore();
|
||||
const { updateBook } = useLibraryStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedBooks, setSelectedBooks] = useState<string[]>([]);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
@@ -74,7 +77,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
const [importBookUrl] = useState(searchParams?.get('url') || '');
|
||||
const isImportingBook = useRef(false);
|
||||
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
|
||||
const iconSize15 = useResponsiveSize(15);
|
||||
|
||||
const showBookDetailsModal = (book: Book) => {
|
||||
setShowDetailsBook(book);
|
||||
@@ -111,14 +113,23 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
|
||||
const bookshelfItems = generateBookshelfItems(libraryBooks);
|
||||
|
||||
const handleBookClick = (id: string) => {
|
||||
const handleBookClick = async (book: Book) => {
|
||||
if (book.uploadedAt && !book.downloadedAt) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await appService?.downloadBook(book);
|
||||
updateBook(envConfig, book);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
if (isSelectMode) {
|
||||
toggleSelection(id);
|
||||
toggleSelection(book.hash);
|
||||
} else {
|
||||
setClickedImage(id);
|
||||
setClickedImage(book.hash);
|
||||
setTimeout(() => setClickedImage(null), 300);
|
||||
setTimeout(() => setLoading(true), 200);
|
||||
navigateToReader(router, [id]);
|
||||
navigateToReader(router, [book.hash]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -137,7 +148,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
for (const selectedBook of selectedBooks) {
|
||||
const book = libraryBooks.find((b) => b.hash === selectedBook);
|
||||
if (book) {
|
||||
deleteBook(envConfig, book);
|
||||
updateBook(envConfig, book, true);
|
||||
}
|
||||
}
|
||||
setSelectedBooks([]);
|
||||
@@ -158,7 +169,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
const openBookMenuItem = await MenuItem.new({
|
||||
text: isSelectMode ? _('Select Book') : _('Open Book'),
|
||||
action: async () => {
|
||||
handleBookClick(book.hash);
|
||||
handleBookClick(book);
|
||||
},
|
||||
});
|
||||
const showBookInFinderMenuItem = await MenuItem.new({
|
||||
@@ -174,16 +185,23 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
showBookDetailsModal(book);
|
||||
},
|
||||
});
|
||||
const uploadBookMenuItem = await MenuItem.new({
|
||||
text: _('Upload Book'),
|
||||
action: async () => {
|
||||
handleBookUpload(book);
|
||||
},
|
||||
});
|
||||
const deleteBookMenuItem = await MenuItem.new({
|
||||
text: _('Delete'),
|
||||
action: async () => {
|
||||
deleteBook(envConfig, book);
|
||||
updateBook(envConfig, book, true);
|
||||
},
|
||||
});
|
||||
const menu = await Menu.new();
|
||||
menu.append(openBookMenuItem);
|
||||
menu.append(showBookDetailsMenuItem);
|
||||
menu.append(showBookInFinderMenuItem);
|
||||
menu.append(uploadBookMenuItem);
|
||||
menu.append(deleteBookMenuItem);
|
||||
menu.popup();
|
||||
};
|
||||
@@ -198,137 +216,40 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
>
|
||||
<div className='flex-grow'>
|
||||
{'format' in item ? (
|
||||
<div
|
||||
className='book-item cursor-pointer'
|
||||
onContextMenu={bookContextMenuHandler.bind(null, item as Book)}
|
||||
>
|
||||
<div
|
||||
key={(item as Book).hash}
|
||||
className='bg-base-100 shadow-md'
|
||||
onClick={() => handleBookClick(item.hash)}
|
||||
>
|
||||
<div className='relative aspect-[28/41]'>
|
||||
<Image
|
||||
src={item.coverImageUrl!}
|
||||
alt={item.title}
|
||||
fill={true}
|
||||
className='object-cover'
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove(
|
||||
'invisible',
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'invisible absolute inset-0 flex items-center justify-center p-1',
|
||||
'text-neutral-content rounded-none text-center font-serif text-base font-medium',
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
{(selectedBooks.includes(item.hash) || clickedImage === item.hash) && (
|
||||
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
|
||||
)}
|
||||
{isSelectMode && (
|
||||
<div className='absolute bottom-1 right-1'>
|
||||
{selectedBooks.includes(item.hash) ? (
|
||||
<MdCheckCircle className='fill-blue-500' />
|
||||
) : (
|
||||
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex w-full p-0 pt-2',
|
||||
isWebAppPlatform() ? 'flex-col' : 'flex-row justify-between',
|
||||
)}
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='block overflow-hidden text-ellipsis whitespace-nowrap text-[0.6em] text-xs font-semibold'>
|
||||
{(item as Book).title}
|
||||
</h4>
|
||||
</div>
|
||||
{item.progress && (
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<ReadingProgress book={item as Book} />
|
||||
{isWebAppPlatform() && (
|
||||
<button
|
||||
type='button'
|
||||
className='show-detail-button opacity-0 group-hover:opacity-100'
|
||||
onClick={showBookDetailsModal.bind(null, item as Book)}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
showBookDetailsModal(item as Book);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CiCircleMore size={iconSize15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!item.progress && isWebAppPlatform() && (
|
||||
<div className={'flex items-center justify-end'}>
|
||||
<button
|
||||
type='button'
|
||||
className='show-detail-button opacity-0 group-hover:opacity-100'
|
||||
onClick={showBookDetailsModal.bind(null, item as Book)}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
showBookDetailsModal(item as Book);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CiCircleMore size={iconSize15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<BookItem
|
||||
book={item}
|
||||
isSelectMode={isSelectMode}
|
||||
selectedBooks={selectedBooks}
|
||||
clickedBookHash={clickedImage}
|
||||
handleBookClick={handleBookClick}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
showBookDetailsModal={showBookDetailsModal}
|
||||
bookContextMenuHandler={bookContextMenuHandler}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
{(item as BooksGroup).books.map((book) => (
|
||||
<div key={book.hash} className='card bg-base-100 w-full shadow-md'>
|
||||
<figure>
|
||||
<Image
|
||||
width={10}
|
||||
height={10}
|
||||
src={book.coverImageUrl!}
|
||||
alt={book.title || ''}
|
||||
className='h-48 w-full object-cover'
|
||||
/>
|
||||
</figure>
|
||||
<div className='card-body p-4'>
|
||||
<h3 className='card-title line-clamp-2 text-sm'>{book.title}</h3>
|
||||
<p className='text-neutral-content line-clamp-1 text-xs'>{book.author}</p>
|
||||
<ReadingProgress book={book} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<h2 className='mb-2 text-lg font-bold'>{(item as BooksGroup).name}</h2>
|
||||
</div>
|
||||
<GroupItem group={item} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{bookshelfItems.length > 0 && (
|
||||
<div
|
||||
className='border-1 bg-base-100 hover:bg-base-300/50 m-4 flex aspect-[28/41] items-center justify-center'
|
||||
role='button'
|
||||
onClick={onImportBooks}
|
||||
onClick={handleImportBooks}
|
||||
>
|
||||
<PiPlus className='size-10' color='gray' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedBooks.length > 0 && (
|
||||
<div className='text-base-content bg-base-300 fixed bottom-4 left-1/2 flex -translate-x-1/2 transform space-x-4 rounded-lg p-4 shadow-lg'>
|
||||
<div
|
||||
className={clsx(
|
||||
'text-base-content bg-base-300 fixed bottom-4 left-1/2 flex',
|
||||
'-translate-x-1/2 transform space-x-4 rounded-lg p-4 shadow-lg',
|
||||
)}
|
||||
>
|
||||
<button onClick={openSelectedBooks} className='flex items-center space-x-2'>
|
||||
<MdOpenInNew />
|
||||
<span>{_('Open')}</span>
|
||||
@@ -352,7 +273,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
onClickConfirm={confirmDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDetailsBook && (
|
||||
<BookDetailModal
|
||||
isOpen={!!showDetailsBook}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import Image from 'next/image';
|
||||
import { BooksGroup } from '@/types/book';
|
||||
import ReadingProgress from './ReadingProgress';
|
||||
|
||||
interface GroupItemProps {
|
||||
group: BooksGroup;
|
||||
}
|
||||
|
||||
const GroupItem: React.FC<GroupItemProps> = ({ group }) => {
|
||||
return (
|
||||
<div>
|
||||
{group.books.map((book) => (
|
||||
<div key={book.hash} className='card bg-base-100 w-full shadow-md'>
|
||||
<figure>
|
||||
<Image
|
||||
width={10}
|
||||
height={10}
|
||||
src={book.coverImageUrl!}
|
||||
alt={book.title || ''}
|
||||
className='h-48 w-full object-cover'
|
||||
/>
|
||||
</figure>
|
||||
<div className='card-body p-4'>
|
||||
<h3 className='card-title line-clamp-2 text-sm'>{book.title}</h3>
|
||||
<p className='text-neutral-content line-clamp-1 text-xs'>{book.author}</p>
|
||||
<ReadingProgress book={book} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<h2 className='mb-2 text-lg font-bold'>{group.name}</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupItem;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { navigateToLogin } from '@/utils/nav';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
|
||||
interface ImportMenuProps {
|
||||
setIsDropdownOpen?: (open: boolean) => void;
|
||||
onImportBooks: () => void;
|
||||
}
|
||||
|
||||
const ImportMenu: React.FC<ImportMenuProps> = ({ setIsDropdownOpen, onImportBooks }) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const { envConfig } = useEnv();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
|
||||
|
||||
const toggleAutoUploadBooks = () => {
|
||||
if (!user) {
|
||||
navigateToLogin(router);
|
||||
}
|
||||
settings.autoUpload = !settings.autoUpload;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
setIsAutoUpload(settings.autoUpload);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const handleImportBooks = () => {
|
||||
onImportBooks();
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<ul
|
||||
tabIndex={-1}
|
||||
className='dropdown-content dropdown-center bg-base-100 menu rounded-box z-[1] mt-3 w-52 p-2 shadow'
|
||||
>
|
||||
<MenuItem label={_('From Local File')} onClick={handleImportBooks} />
|
||||
<MenuItem
|
||||
label={_('Auto Upload Books to Cloud')}
|
||||
icon={isAutoUpload ? <MdCheck className='text-base-content' /> : undefined}
|
||||
onClick={toggleAutoUploadBooks}
|
||||
/>
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportMenu;
|
||||
@@ -12,6 +12,7 @@ import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import SettingsMenu from './SettingsMenu';
|
||||
import ImportMenu from './ImportMenu';
|
||||
|
||||
interface LibraryHeaderProps {
|
||||
isSelectMode: boolean;
|
||||
@@ -57,7 +58,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
</span>
|
||||
<input
|
||||
type='text'
|
||||
placeholder={_('Search books...')}
|
||||
placeholder={_('Search Books...')}
|
||||
spellCheck='false'
|
||||
className={clsx(
|
||||
'input rounded-badge bg-base-300/50 h-7 w-full pl-10 pr-10',
|
||||
@@ -66,30 +67,26 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
)}
|
||||
/>
|
||||
<div className='absolute right-4 flex items-center space-x-4 text-gray-500'>
|
||||
<span className='mx-2 h-5 w-[1px] bg-gray-400'></span>
|
||||
<div className='dropdown dropdown-bottom flex h-5 cursor-pointer justify-center'>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Import Books')}>
|
||||
<PiPlus tabIndex={-1} className='h-5 w-5' />
|
||||
</div>
|
||||
<ul
|
||||
tabIndex={-1}
|
||||
className='dropdown-content dropdown-center bg-base-100 menu rounded-box z-[1] mt-3 w-52 p-2 shadow'
|
||||
>
|
||||
<li>
|
||||
<button className='text-base-content' onClick={onImportBooks}>
|
||||
{_('From Local File')}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<span className='mx-2 h-6 w-[1px] bg-gray-400'></span>
|
||||
<Dropdown
|
||||
className='exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center'
|
||||
buttonClassName='p-0 h-6 min-h-6 w-6 flex items-center justify-center'
|
||||
toggleButton={
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Import Books')}>
|
||||
<PiPlus className='m-0.5 h-5 w-5' />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ImportMenu onImportBooks={onImportBooks} />
|
||||
</Dropdown>
|
||||
<button
|
||||
onClick={onToggleSelectMode}
|
||||
aria-label={_('Select multiple books')}
|
||||
aria-label={_('Select Multiple Books')}
|
||||
className='h-6'
|
||||
>
|
||||
<div
|
||||
className='lg:tooltip lg:tooltip-bottom cursor-pointer'
|
||||
data-tip={_('Select books')}
|
||||
data-tip={_('Select Books')}
|
||||
>
|
||||
<PiSelectionAllDuotone
|
||||
role='button'
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useSync } from '@/hooks/useSync';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
|
||||
export const useBooksSync = () => {
|
||||
const { user } = useAuth();
|
||||
const { appService } = useEnv();
|
||||
const { library, setLibrary } = useLibraryStore();
|
||||
const { syncedBooks, syncBooks, lastSyncedAtBooks } = useSync();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
syncBooks([], 'pull');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const lastSyncTime = useRef<number>(0);
|
||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const getNewBooks = () => {
|
||||
if (!user) return [];
|
||||
const newBooks = library.filter(
|
||||
(book) => lastSyncedAtBooks < book.updatedAt || lastSyncedAtBooks < (book.deletedAt ?? 0),
|
||||
);
|
||||
return newBooks;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const now = Date.now();
|
||||
const timeSinceLastSync = now - lastSyncTime.current;
|
||||
if (timeSinceLastSync > SYNC_BOOKS_INTERVAL_SEC * 1000) {
|
||||
lastSyncTime.current = now;
|
||||
const newBooks = getNewBooks();
|
||||
syncBooks(newBooks, 'both');
|
||||
} else {
|
||||
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
|
||||
syncTimeoutRef.current = setTimeout(
|
||||
() => {
|
||||
lastSyncTime.current = Date.now();
|
||||
const newBooks = getNewBooks();
|
||||
syncBooks(newBooks, 'both');
|
||||
syncTimeoutRef.current = null;
|
||||
},
|
||||
SYNC_BOOKS_INTERVAL_SEC * 1000 - timeSinceLastSync,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [library]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateLibrary = async () => {
|
||||
if (syncedBooks?.length) {
|
||||
const updatedLibrary = library.map((oldBook) => {
|
||||
const matchingBook = syncedBooks.find((newBook) => newBook.hash === oldBook.hash);
|
||||
if (matchingBook) {
|
||||
return {
|
||||
...oldBook,
|
||||
...matchingBook,
|
||||
updatedAt: oldBook.updatedAt,
|
||||
deletedAt: oldBook.deletedAt,
|
||||
progress: oldBook.progress,
|
||||
};
|
||||
}
|
||||
return oldBook;
|
||||
});
|
||||
await Promise.all(
|
||||
syncedBooks.map(async (newBook) => {
|
||||
if (!updatedLibrary.some((oldBook) => oldBook.hash === newBook.hash)) {
|
||||
if (newBook.uploadedAt) {
|
||||
try {
|
||||
await appService?.downloadBook(newBook, true);
|
||||
newBook.coverImageUrl = await appService?.generateCoverImageUrl(newBook);
|
||||
updatedLibrary.push(newBook);
|
||||
} catch {
|
||||
console.error('Failed to download book:', newBook);
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
setLibrary(updatedLibrary);
|
||||
appService?.saveLibraryBooks(updatedLibrary);
|
||||
}
|
||||
};
|
||||
updateLibrary();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [syncedBooks]);
|
||||
};
|
||||
@@ -28,6 +28,7 @@ import { Toast } from '@/components/Toast';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import LibraryHeader from './components/LibraryHeader';
|
||||
import Bookshelf from './components/Bookshelf';
|
||||
import { useBooksSync } from './hooks/useBooksSync';
|
||||
|
||||
const LibraryPage = () => {
|
||||
const router = useRouter();
|
||||
@@ -35,19 +36,22 @@ const LibraryPage = () => {
|
||||
const { token, user } = useAuth();
|
||||
const {
|
||||
library: libraryBooks,
|
||||
updateBook,
|
||||
setLibrary,
|
||||
checkOpenWithBooks,
|
||||
clearOpenWithBooks,
|
||||
} = useLibraryStore();
|
||||
const _ = useTranslation();
|
||||
const { updateAppTheme } = useTheme();
|
||||
const { setSettings, saveSettings } = useSettingsStore();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isInitiating = useRef(false);
|
||||
const [libraryLoaded, setLibraryLoaded] = useState(false);
|
||||
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||
const demoBooks = useDemoBooks();
|
||||
|
||||
useBooksSync();
|
||||
|
||||
useEffect(() => {
|
||||
updateAppTheme('base-200');
|
||||
const doAppUpdates = async () => {
|
||||
@@ -163,8 +167,12 @@ const LibraryPage = () => {
|
||||
const failedFiles = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
await appService?.importBook(file, libraryBooks);
|
||||
const book = await appService?.importBook(file, libraryBooks);
|
||||
setLibrary(libraryBooks);
|
||||
if (user && book && !book.uploadedAt && settings.autoUpload) {
|
||||
console.log('Uploading book:', book.title);
|
||||
handleBookUpload(book);
|
||||
}
|
||||
} catch (error) {
|
||||
const filename = typeof file === 'string' ? file : file.name;
|
||||
const baseFilename = getBaseFilename(filename);
|
||||
@@ -200,6 +208,52 @@ const LibraryPage = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleBookUpload = async (book: Book) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await appService?.uploadBook(book);
|
||||
updateBook(envConfig, book);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'success',
|
||||
message: _('Book uploaded: {{title}}', {
|
||||
title: book.title,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Failed to upload book: {{title}}', {
|
||||
title: book.title,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBookDownload = async (book: Book) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await appService?.downloadBook(book);
|
||||
updateBook(envConfig, book);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'success',
|
||||
message: _('Book downloaded: {{title}}', {
|
||||
title: book.title,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Failed to download book: {{title}}', {
|
||||
title: book.title,
|
||||
}),
|
||||
type: 'error',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportBooks = async () => {
|
||||
console.log('Importing books...');
|
||||
let files;
|
||||
@@ -261,7 +315,9 @@ const LibraryPage = () => {
|
||||
<Bookshelf
|
||||
libraryBooks={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
onImportBooks={handleImportBooks}
|
||||
handleImportBooks={handleImportBooks}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -13,14 +13,16 @@ import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/servic
|
||||
export const useProgressSync = (bookKey: string) => {
|
||||
const _ = useTranslation();
|
||||
const { getConfig, setConfig } = useBookDataStore();
|
||||
const { getView } = useReaderStore();
|
||||
const { getView, getProgress } = useReaderStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { syncedConfigs, syncConfigs } = useSync(bookKey);
|
||||
const { user } = useAuth();
|
||||
const view = getView(bookKey);
|
||||
const config = getConfig(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
// flag to prevent accidental sync without first pulling the config
|
||||
const configSynced = useRef(false);
|
||||
const firstPulled = useRef(false);
|
||||
|
||||
const pushConfig = (bookKey: string, config: BookConfig | null) => {
|
||||
if (!config || !user) return;
|
||||
@@ -48,15 +50,16 @@ export const useProgressSync = (bookKey: string) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
pullConfig(bookKey);
|
||||
// return () => {
|
||||
// if (configSynced.current) {
|
||||
// pushConfig(bookKey, config);
|
||||
// }
|
||||
// };
|
||||
if (!progress) return;
|
||||
if (!firstPulled.current) {
|
||||
firstPulled.current = true;
|
||||
pullConfig(bookKey);
|
||||
}
|
||||
return () => {
|
||||
syncConfig();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [progress]);
|
||||
|
||||
const lastProgressSyncTime = useRef<number>(0);
|
||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -98,11 +101,13 @@ export const useProgressSync = (bookKey: string) => {
|
||||
const syncedFraction = syncedConfig.progress![0] / syncedConfig.progress![1];
|
||||
const configFraction = config!.progress![0] / config!.progress![1];
|
||||
if (syncedFraction > configFraction) {
|
||||
view?.goToFraction(syncedFraction);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'success',
|
||||
message: _('Reading Progress Synced'),
|
||||
});
|
||||
if (view) {
|
||||
view.goToFraction(syncedFraction);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'success',
|
||||
message: _('Reading Progress Synced'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ const Alert: React.FC<{
|
||||
role='alert'
|
||||
className={clsx(
|
||||
'alert fixed bottom-4 left-1/2 z-[100] flex -translate-x-1/2 transform items-center justify-between',
|
||||
'rounded-lg bg-gray-100 p-4 shadow-2xl',
|
||||
'bg-base-300 rounded-lg p-4 shadow-2xl',
|
||||
'w-full max-w-[90vw] sm:max-w-[70vw] md:max-w-[50vw] lg:max-w-[40vw] xl:max-w-[40vw]',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -26,7 +26,7 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
const [bookMeta, setBookMeta] = useState<BookDoc['metadata'] | null>(null);
|
||||
const { envConfig } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { deleteBook } = useLibraryStore();
|
||||
const { updateBook } = useLibraryStore();
|
||||
|
||||
useEffect(() => {
|
||||
const loadingTimeout = setTimeout(() => setLoading(true), 300);
|
||||
@@ -51,7 +51,7 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
deleteBook(envConfig, book);
|
||||
updateBook(envConfig, book, true);
|
||||
handleClose();
|
||||
setShowDeleteAlert(false);
|
||||
};
|
||||
@@ -71,8 +71,9 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
title={_('Book Details')}
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
className='!bg-[rgba(0,0,0,0.5)]'
|
||||
boxClassName='sm:min-w-[480px]'
|
||||
contentClassName='!p-6'
|
||||
contentClassName='!px-6 !py-2'
|
||||
>
|
||||
<div className='z-50 flex w-full select-text items-center justify-center'>
|
||||
<div className='relative w-full rounded-lg'>
|
||||
|
||||
@@ -57,7 +57,7 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
boxClassName,
|
||||
)}
|
||||
>
|
||||
<div className='dialog-header bg-base-100 sticky top-2 z-10 flex items-center justify-between px-4'>
|
||||
<div className='dialog-header bg-base-100 sticky top-1 z-10 flex items-center justify-between px-4'>
|
||||
{header ? (
|
||||
header
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { getAPIBaseUrl } from '@/services/environment';
|
||||
import { getAccessToken, getUserID } from '@/utils/access';
|
||||
|
||||
const UPLOAD_API_ENDPOINT = getAPIBaseUrl() + '/storage/upload';
|
||||
const DOWNLOAD_API_ENDPOINT = getAPIBaseUrl() + '/storage/download';
|
||||
|
||||
export const uploadFile = async (file: File, bookHash?: string) => {
|
||||
const token = await getAccessToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
try {
|
||||
const response = await fetch(UPLOAD_API_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
bookHash,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.log('Error generating presigned post:', errorData.error);
|
||||
throw new Error('File upload failed');
|
||||
}
|
||||
|
||||
const urlResponse = await response.json();
|
||||
const { uploadUrl } = urlResponse;
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
console.error('File upload failed:', await uploadResponse.text());
|
||||
throw new Error('File upload failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('File upload failed:', error);
|
||||
throw new Error('File upload failed');
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadFile = async (fp: string) => {
|
||||
const token = await getAccessToken();
|
||||
const userId = await getUserID();
|
||||
if (!token || !userId) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
try {
|
||||
const fileKey = `${userId}/${fp}`;
|
||||
const response = await fetch(
|
||||
`${DOWNLOAD_API_ENDPOINT}?fileKey=${encodeURIComponent(fileKey)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('Error generating download URL:', errorData.error);
|
||||
throw new Error('File download failed');
|
||||
}
|
||||
|
||||
const { downloadUrl } = await response.json();
|
||||
|
||||
const downloadResponse = await fetch(downloadUrl);
|
||||
if (!downloadResponse.ok) {
|
||||
console.error('Error downloading file:', await downloadResponse.text());
|
||||
throw new Error('File download failed');
|
||||
}
|
||||
|
||||
return await downloadResponse.blob();
|
||||
} catch (error) {
|
||||
console.error('File download failed:', error);
|
||||
throw new Error('File download failed');
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { Book, BookConfig, BookNote, BookDataRecord } from '@/types/book';
|
||||
import { getAPIBaseUrl, isWebAppPlatform } from '@/services/environment';
|
||||
import { getAPIBaseUrl } from '@/services/environment';
|
||||
import { getAccessToken } from '@/utils/access';
|
||||
|
||||
const SYNC_API_ENDPOINT = getAPIBaseUrl() + '/sync';
|
||||
|
||||
@@ -29,7 +29,7 @@ export class SyncClient {
|
||||
* Returns updated or deleted records since that time.
|
||||
*/
|
||||
async pullChanges(since: number, type?: SyncType, book?: string): Promise<SyncResult> {
|
||||
const token = await this.getAccessToken();
|
||||
const token = await getAccessToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
|
||||
const url = `${SYNC_API_ENDPOINT}?since=${encodeURIComponent(since)}&type=${type ?? ''}&book=${book ?? ''}`;
|
||||
@@ -52,7 +52,7 @@ export class SyncClient {
|
||||
* Uses last-writer-wins logic as implemented on the server side.
|
||||
*/
|
||||
async pushChanges(payload: SyncData): Promise<SyncResult> {
|
||||
const token = await this.getAccessToken();
|
||||
const token = await getAccessToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
|
||||
const res = await fetch(SYNC_API_ENDPOINT, {
|
||||
@@ -71,15 +71,4 @@ export class SyncClient {
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string | null> {
|
||||
// In browser context there might be two instances of supabase one in the app route
|
||||
// and the other in the pages route, and they might have different sessions
|
||||
// making the access token invalid for API calls. In that case we should use localStorage.
|
||||
if (isWebAppPlatform()) {
|
||||
return localStorage.getItem('token') ?? null;
|
||||
}
|
||||
const { data } = await supabase.auth.getSession();
|
||||
return data?.session?.access_token ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { supabase, createSupabaseClient } from '@/utils/supabase';
|
||||
import { corsAllMethods, runMiddleware } from '@/utils/cors';
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { s3Client } from '@/utils/s3';
|
||||
|
||||
const getUserAndToken = async (authHeader: string | undefined) => {
|
||||
if (!authHeader) return {};
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser(token);
|
||||
|
||||
if (error || !user) return {};
|
||||
return { user, token };
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
await runMiddleware(req, res, corsAllMethods);
|
||||
|
||||
try {
|
||||
const { user, token } = await getUserAndToken(req.headers['authorization']);
|
||||
if (!user || !token) {
|
||||
return res.status(403).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const { fileKey } = req.query;
|
||||
|
||||
if (!fileKey || typeof fileKey !== 'string') {
|
||||
return res.status(400).json({ error: 'Missing or invalid fileKey' });
|
||||
}
|
||||
|
||||
// Verify the file belongs to the user
|
||||
const supabase = createSupabaseClient(token);
|
||||
const { data: fileRecord, error: fileError } = await supabase
|
||||
.from('files')
|
||||
.select('user_id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('file_key', fileKey) // index idx_files_file_key_deleted_at on public.files
|
||||
.is('deleted_at', null)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (fileError || !fileRecord) {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
if (fileRecord.user_id !== user.id) {
|
||||
return res.status(403).json({ error: 'Unauthorized access to the file' });
|
||||
}
|
||||
|
||||
const getCommand = new GetObjectCommand({
|
||||
Bucket: process.env['R2_BUCKET_NAME'] || '',
|
||||
Key: fileKey,
|
||||
});
|
||||
|
||||
try {
|
||||
const downloadUrl = await getSignedUrl(s3Client, getCommand, {
|
||||
expiresIn: 1800,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
downloadUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating signed URL for download:', error);
|
||||
res.status(500).json({ error: 'Could not create signed URL for download' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.status(500).json({ error: 'Something went wrong' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { supabase, createSupabaseClient } from '@/utils/supabase';
|
||||
import { corsAllMethods, runMiddleware } from '@/utils/cors';
|
||||
import { PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { getPlanData } from '@/utils/access';
|
||||
import { s3Client } from '@/utils/s3';
|
||||
|
||||
const getUserAndToken = async (authHeader: string | undefined) => {
|
||||
if (!authHeader) return {};
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser(token);
|
||||
|
||||
if (error || !user) return {};
|
||||
return { user, token };
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
await runMiddleware(req, res, corsAllMethods);
|
||||
|
||||
try {
|
||||
const { user, token } = await getUserAndToken(req.headers['authorization']);
|
||||
if (!user || !token) {
|
||||
return res.status(403).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const { fileName, fileSize, bookHash } = req.body;
|
||||
if (!fileName || !fileSize) {
|
||||
return res.status(400).json({ error: 'Missing file info' });
|
||||
}
|
||||
|
||||
const { usage, quota } = getPlanData(token);
|
||||
if (usage + fileSize > quota) {
|
||||
return res.status(403).json({ error: 'Insufficient storage quota', usage });
|
||||
}
|
||||
|
||||
const objectKey = `${user.id}/${fileName}`;
|
||||
const signableHeaders = new Set<string>();
|
||||
signableHeaders.add('content-length');
|
||||
const putCommand = new PutObjectCommand({
|
||||
Bucket: process.env['R2_BUCKET_NAME'] || '',
|
||||
Key: objectKey,
|
||||
ContentLength: fileSize,
|
||||
});
|
||||
|
||||
const supabase = createSupabaseClient(token);
|
||||
const { data: inserted, error: insertError } = await supabase
|
||||
.from('files')
|
||||
.insert([
|
||||
{
|
||||
user_id: user.id,
|
||||
book_hash: bookHash,
|
||||
file_key: objectKey,
|
||||
file_size: fileSize,
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
console.log('Inserted record:', inserted);
|
||||
if (insertError) return res.status(500).json({ error: insertError.message });
|
||||
|
||||
try {
|
||||
const uploadUrl = await getSignedUrl(s3Client, putCommand, {
|
||||
expiresIn: 1800,
|
||||
signableHeaders,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
uploadUrl,
|
||||
fileKey: objectKey,
|
||||
usage: usage + fileSize,
|
||||
quota,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating presigned post:', error);
|
||||
res.status(500).json({ error: 'Could not create presigned post' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.status(500).json({ error: 'Something went wrong' });
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,11 @@ import {
|
||||
SYSTEM_SETTINGS_VERSION,
|
||||
DEFAULT_BOOK_SEARCH_CONFIG,
|
||||
DEFAULT_TTS_CONFIG,
|
||||
CLOUD_BOOKS_SUBDIR,
|
||||
} from './constants';
|
||||
import { isValidURL } from '@/utils/misc';
|
||||
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
|
||||
import { downloadFile, uploadFile } from '@/libs/storage';
|
||||
|
||||
export abstract class BaseAppService implements AppService {
|
||||
localBooksDir: string = '';
|
||||
@@ -80,6 +82,7 @@ export abstract class BaseAppService implements AppService {
|
||||
lastSyncedAtConfigs: 0,
|
||||
lastSyncedAtNotes: 0,
|
||||
keepLogin: false,
|
||||
autoUpload: false,
|
||||
globalReadSettings: DEFAULT_READSETTINGS,
|
||||
globalViewSettings: {
|
||||
...DEFAULT_BOOK_LAYOUT,
|
||||
@@ -150,6 +153,8 @@ export abstract class BaseAppService implements AppService {
|
||||
title: formatTitle(loadedBook.metadata.title),
|
||||
author: formatAuthors(loadedBook.metadata.language, loadedBook.metadata.author),
|
||||
createdAt: existingBook ? existingBook.createdAt : Date.now(),
|
||||
uploadedAt: existingBook ? existingBook.uploadedAt : null,
|
||||
downloadedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (!(await this.fs.exists(getDir(book), 'Books'))) {
|
||||
@@ -177,17 +182,12 @@ export abstract class BaseAppService implements AppService {
|
||||
if (typeof file === 'string' && isValidURL(file)) {
|
||||
book.url = file;
|
||||
}
|
||||
if (this.appPlatform === 'web') {
|
||||
book.coverImageUrl = await this.getCoverImageBlobUrl(book);
|
||||
} else {
|
||||
book.coverImageUrl = this.getCoverImageUrl(book);
|
||||
}
|
||||
book.coverImageUrl = await this.generateCoverImageUrl(book);
|
||||
|
||||
return book;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async deleteBook(book: Book): Promise<void> {
|
||||
@@ -198,6 +198,54 @@ export abstract class BaseAppService implements AppService {
|
||||
}
|
||||
}
|
||||
|
||||
async uploadBook(book: Book): Promise<void> {
|
||||
console.log('Uploading book:', book.title);
|
||||
let file: File;
|
||||
let uploaded = false;
|
||||
for (const fp of [getFilename(book), getCoverFilename(book)]) {
|
||||
if (await this.fs.exists(fp, 'Books')) {
|
||||
const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`;
|
||||
if (this.appPlatform === 'web') {
|
||||
const content = await this.fs.readFile(fp, 'Books', 'binary');
|
||||
file = new File([content], cfp);
|
||||
} else {
|
||||
file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), cfp).open();
|
||||
}
|
||||
await uploadFile(file, book.hash);
|
||||
uploaded = true;
|
||||
}
|
||||
}
|
||||
if (uploaded) {
|
||||
book.updatedAt = Date.now();
|
||||
book.uploadedAt = Date.now();
|
||||
book.downloadedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
async downloadBook(book: Book, onlyCover = false): Promise<void> {
|
||||
console.log('Downloading book:', book.title);
|
||||
const fps = [getCoverFilename(book)];
|
||||
if (!onlyCover) {
|
||||
fps.push(getFilename(book));
|
||||
}
|
||||
|
||||
let downloaded = false;
|
||||
for (const fp of fps) {
|
||||
const existed = await this.fs.exists(fp, 'Books');
|
||||
if (existed) {
|
||||
downloaded = true;
|
||||
} else {
|
||||
const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`;
|
||||
const fileobj = (await downloadFile(cfp)) as Blob;
|
||||
await this.fs.writeFile(fp, 'Books', await fileobj.arrayBuffer());
|
||||
downloaded = true;
|
||||
}
|
||||
}
|
||||
if (!onlyCover && downloaded) {
|
||||
book.downloadedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
async loadBookContent(book: Book, settings: SystemSettings): Promise<BookContent> {
|
||||
let file: File;
|
||||
const fp = getFilename(book);
|
||||
@@ -217,12 +265,15 @@ export abstract class BaseAppService implements AppService {
|
||||
}
|
||||
|
||||
async loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig> {
|
||||
const { globalViewSettings } = settings;
|
||||
try {
|
||||
const str = await this.fs.readFile(getConfigFilename(book), 'Books', 'text');
|
||||
const { globalViewSettings } = settings;
|
||||
return deserializeConfig(str as string, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG);
|
||||
let str = '{}';
|
||||
if (await this.fs.exists(getConfigFilename(book), 'Books')) {
|
||||
str = (await this.fs.readFile(getConfigFilename(book), 'Books', 'text')) as string;
|
||||
}
|
||||
return deserializeConfig(str, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG);
|
||||
} catch {
|
||||
return INIT_BOOK_CONFIG;
|
||||
return deserializeConfig('{}', globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +294,12 @@ export abstract class BaseAppService implements AppService {
|
||||
await this.fs.writeFile(getConfigFilename(book), 'Books', serializedConfig);
|
||||
}
|
||||
|
||||
async generateCoverImageUrl(book: Book): Promise<string> {
|
||||
return this.appPlatform === 'web'
|
||||
? await this.getCoverImageBlobUrl(book)
|
||||
: this.getCoverImageUrl(book);
|
||||
}
|
||||
|
||||
async loadLibraryBooks(): Promise<Book[]> {
|
||||
console.log('Loading library books...');
|
||||
let books: Book[] = [];
|
||||
@@ -258,11 +315,7 @@ export abstract class BaseAppService implements AppService {
|
||||
|
||||
await Promise.all(
|
||||
books.map(async (book) => {
|
||||
if (this.appPlatform === 'web') {
|
||||
book.coverImageUrl = await this.getCoverImageBlobUrl(book);
|
||||
} else {
|
||||
book.coverImageUrl = this.getCoverImageUrl(book);
|
||||
}
|
||||
book.coverImageUrl = await this.generateCoverImageUrl(book);
|
||||
book.updatedAt ??= book.lastUpdated || Date.now();
|
||||
return book;
|
||||
}),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ViewConfig,
|
||||
} from '@/types/book';
|
||||
import { ReadSettings } from '@/types/settings';
|
||||
import { UserStorageQuota } from '@/types/user';
|
||||
|
||||
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
|
||||
export const CLOUD_BOOKS_SUBDIR = 'Readest/Books';
|
||||
@@ -350,7 +351,14 @@ export const READEST_WEB_BASE_URL = 'https://web.readest.com';
|
||||
|
||||
export const SYNC_PROGRESS_INTERVAL_SEC = 60;
|
||||
export const SYNC_NOTES_INTERVAL_SEC = 60;
|
||||
export const SYNC_BOOKS_INTERVAL_SEC = 60;
|
||||
export const CHECK_UPDATE_INTERVAL_SEC = 24 * 60 * 60;
|
||||
|
||||
export const MAX_ZOOM_LEVEL = 140;
|
||||
export const MIN_ZOOM_LEVEL = 95;
|
||||
|
||||
export const DEFAULT_STORAGE_QUOTA: UserStorageQuota = {
|
||||
free: 500 * 1024 * 1024,
|
||||
plus: 2 * 1024 * 1024 * 1024,
|
||||
pro: 10 * 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
@@ -25,13 +25,13 @@ interface BookDataState {
|
||||
settings: SystemSettings,
|
||||
) => void;
|
||||
updateBooknotes: (key: string, booknotes: BookNote[]) => BookConfig | undefined;
|
||||
getBookData: (key: string) => BookData | null;
|
||||
getBookData: (keyOrId: string) => BookData | null;
|
||||
}
|
||||
|
||||
export const useBookDataStore = create<BookDataState>((set, get) => ({
|
||||
booksData: {},
|
||||
getBookData: (key: string) => {
|
||||
const id = key.split('-')[0]!;
|
||||
getBookData: (keyOrId: string) => {
|
||||
const id = keyOrId.split('-')[0]!;
|
||||
return get().booksData[id] || null;
|
||||
},
|
||||
getConfig: (key: string | null) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ interface LibraryState {
|
||||
checkOpenWithBooks: boolean;
|
||||
clearOpenWithBooks: () => void;
|
||||
setLibrary: (books: Book[]) => void;
|
||||
deleteBook: (envConfig: EnvConfigType, book: Book) => void;
|
||||
updateBook: (envConfig: EnvConfigType, book: Book, isDelete?: boolean) => void;
|
||||
}
|
||||
|
||||
export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
@@ -15,13 +15,17 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
checkOpenWithBooks: true,
|
||||
clearOpenWithBooks: () => set({ checkOpenWithBooks: false }),
|
||||
setLibrary: (books) => set({ library: books }),
|
||||
deleteBook: async (envConfig: EnvConfigType, book: Book) => {
|
||||
updateBook: async (envConfig: EnvConfigType, book: Book, isDelete = false) => {
|
||||
const appService = await envConfig.getAppService();
|
||||
const { library } = get();
|
||||
const bookIndex = library.findIndex((b) => b.hash === book.hash);
|
||||
if (bookIndex !== -1) {
|
||||
library.splice(bookIndex, 1);
|
||||
appService.deleteBook(book);
|
||||
if (isDelete) {
|
||||
library.splice(bookIndex, 1);
|
||||
appService.deleteBook(book);
|
||||
} else {
|
||||
library[bookIndex] = book;
|
||||
}
|
||||
}
|
||||
set({ library });
|
||||
appService.saveLibraryBooks(library);
|
||||
|
||||
@@ -18,6 +18,9 @@ export interface Book {
|
||||
updatedAt: number;
|
||||
deletedAt?: number | null;
|
||||
|
||||
uploadedAt?: number | null;
|
||||
downloadedAt?: number | null;
|
||||
|
||||
lastUpdated?: number; // deprecated in favor of updatedAt
|
||||
progress?: [number, number]; // Add progress field: [current, total]
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ export interface DBBook {
|
||||
title: string;
|
||||
author: string;
|
||||
group?: string;
|
||||
tags?: string;
|
||||
tags?: string[];
|
||||
progress?: [number, number];
|
||||
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
uploaded_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DBBookConfig {
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface SystemSettings {
|
||||
version: number;
|
||||
localBooksDir: string;
|
||||
keepLogin: boolean;
|
||||
autoUpload: boolean;
|
||||
|
||||
lastSyncedAtBooks: number;
|
||||
lastSyncedAtConfigs: number;
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface AppService {
|
||||
overwrite?: boolean,
|
||||
): Promise<Book | null>;
|
||||
deleteBook(book: Book): Promise<void>;
|
||||
uploadBook(book: Book): Promise<void>;
|
||||
downloadBook(book: Book, onlyCover?: boolean): Promise<void>;
|
||||
loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig>;
|
||||
fetchBookDetails(book: Book, settings: SystemSettings): Promise<BookDoc['metadata']>;
|
||||
saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings): Promise<void>;
|
||||
@@ -47,4 +49,5 @@ export interface AppService {
|
||||
saveLibraryBooks(books: Book[]): Promise<void>;
|
||||
getCoverImageUrl(book: Book): string;
|
||||
getCoverImageBlobUrl(book: Book): Promise<string>;
|
||||
generateCoverImageUrl(book: Book): Promise<string>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export type UserStorageQuota = {
|
||||
free: number;
|
||||
plus: number;
|
||||
pro: number;
|
||||
};
|
||||
|
||||
export type UserPlan = keyof UserStorageQuota;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import { UserPlan } from '@/types/user';
|
||||
import { DEFAULT_STORAGE_QUOTA } from '@/services/constants';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
|
||||
interface Token {
|
||||
plan: UserPlan;
|
||||
storage_usage_bytes: number;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
export const getPlanData = (token: string) => {
|
||||
const data = jwtDecode<Token>(token) || {};
|
||||
const plan = data['plan'] || 'free';
|
||||
const usage = data['storage_usage_bytes'] || 0;
|
||||
const quota = DEFAULT_STORAGE_QUOTA[plan] || DEFAULT_STORAGE_QUOTA['free'];
|
||||
|
||||
return {
|
||||
plan,
|
||||
usage,
|
||||
quota,
|
||||
};
|
||||
};
|
||||
|
||||
export const getAccessToken = async (): Promise<string | null> => {
|
||||
// In browser context there might be two instances of supabase one in the app route
|
||||
// and the other in the pages route, and they might have different sessions
|
||||
// making the access token invalid for API calls. In that case we should use localStorage.
|
||||
if (isWebAppPlatform()) {
|
||||
return localStorage.getItem('token') ?? null;
|
||||
}
|
||||
const { data } = await supabase.auth.getSession();
|
||||
return data?.session?.access_token ?? null;
|
||||
};
|
||||
|
||||
export const getUserID = async (): Promise<string | null> => {
|
||||
if (isWebAppPlatform()) {
|
||||
const user = localStorage.getItem('user') ?? '{}';
|
||||
return JSON.parse(user).id ?? null;
|
||||
}
|
||||
const { data } = await supabase.auth.getSession();
|
||||
return data?.session?.user?.id ?? null;
|
||||
};
|
||||
@@ -18,6 +18,10 @@ export const navigateToReader = (
|
||||
}
|
||||
};
|
||||
|
||||
export const navigateToLogin = (router: ReturnType<typeof useRouter>) => {
|
||||
router.push('/auth');
|
||||
};
|
||||
|
||||
export const navigateToLibrary = (router: ReturnType<typeof useRouter>) => {
|
||||
router.push('/library');
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { S3Client } from '@aws-sdk/client-s3';
|
||||
|
||||
const S3_ACCOUNT_ID = process.env['R2_ACCOUNT_ID'] || '';
|
||||
const S3_ACCESS_KEY_ID = process.env['R2_ACCESS_KEY_ID'] || '';
|
||||
const S3_SECRET_ACCESS_KEY = process.env['R2_SECRET_ACCESS_KEY'] || '';
|
||||
const S3_REGION = process.env['R2_REGION'] || 'auto';
|
||||
|
||||
export const s3Client = new S3Client({
|
||||
region: S3_REGION,
|
||||
endpoint: `https://${S3_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: S3_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
export const r2Client = s3Client;
|
||||
@@ -37,8 +37,19 @@ export const transformBookConfigFromDB = (dbBookConfig: DBBookConfig): BookConfi
|
||||
};
|
||||
|
||||
export const transformBookToDB = (book: unknown, userId: string): DBBook => {
|
||||
const { hash, format, title, author, group, tags, createdAt, updatedAt, deletedAt } =
|
||||
book as Book;
|
||||
const {
|
||||
hash,
|
||||
format,
|
||||
title,
|
||||
author,
|
||||
group,
|
||||
tags,
|
||||
progress,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
deletedAt,
|
||||
uploadedAt,
|
||||
} = book as Book;
|
||||
|
||||
return {
|
||||
user_id: userId,
|
||||
@@ -47,16 +58,29 @@ export const transformBookToDB = (book: unknown, userId: string): DBBook => {
|
||||
title,
|
||||
author,
|
||||
group,
|
||||
tags: tags && JSON.stringify(tags),
|
||||
tags: tags,
|
||||
progress: progress,
|
||||
created_at: new Date(createdAt).toISOString(),
|
||||
updated_at: new Date(updatedAt).toISOString(),
|
||||
deleted_at: deletedAt ? new Date(deletedAt).toISOString() : null,
|
||||
uploaded_at: uploadedAt ? new Date(uploadedAt).toISOString() : null,
|
||||
};
|
||||
};
|
||||
|
||||
export const transformBookFromDB = (dbBook: DBBook): Book => {
|
||||
const { book_hash, format, title, author, group, tags, created_at, updated_at, deleted_at } =
|
||||
dbBook;
|
||||
const {
|
||||
book_hash,
|
||||
format,
|
||||
title,
|
||||
author,
|
||||
group,
|
||||
tags,
|
||||
progress,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at,
|
||||
uploaded_at,
|
||||
} = dbBook;
|
||||
|
||||
return {
|
||||
hash: book_hash,
|
||||
@@ -64,10 +88,12 @@ export const transformBookFromDB = (dbBook: DBBook): Book => {
|
||||
title,
|
||||
author,
|
||||
group,
|
||||
tags: tags && JSON.parse(tags),
|
||||
tags: tags,
|
||||
progress: progress,
|
||||
createdAt: new Date(created_at!).getTime(),
|
||||
updatedAt: new Date(updated_at!).getTime(),
|
||||
deletedAt: deleted_at ? new Date(deleted_at!).getTime() : null,
|
||||
uploadedAt: uploaded_at ? new Date(uploaded_at!).getTime() : null,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Generated
+1217
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user