sync: initial support to sync document files across devices (#260)

This commit is contained in:
Huang Xin
2025-01-28 00:03:01 +01:00
committed by GitHub
parent 4cea0bf75a
commit efd83180f1
47 changed files with 2314 additions and 292 deletions
+3
View File
@@ -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]);
};
+59 -3
View File
@@ -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'),
});
}
}
}
}
+1 -1
View File
@@ -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'>
+1 -1
View File
@@ -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
) : (
+86
View File
@@ -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');
}
};
+4 -15
View File
@@ -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' });
}
}
+68 -15
View File
@@ -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,
};
+3 -3
View File
@@ -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) => {
+8 -4
View File
@@ -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);
+3
View File
@@ -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]
}
+3 -1
View File
@@ -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 {
+1
View File
@@ -18,6 +18,7 @@ export interface SystemSettings {
version: number;
localBooksDir: string;
keepLogin: boolean;
autoUpload: boolean;
lastSyncedAtBooks: number;
lastSyncedAtConfigs: number;
+3
View File
@@ -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>;
}
+7
View File
@@ -0,0 +1,7 @@
export type UserStorageQuota = {
free: number;
plus: number;
pro: number;
};
export type UserPlan = keyof UserStorageQuota;
+44
View File
@@ -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;
};
+4
View File
@@ -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');
};
+17
View File
@@ -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;
+32 -6
View File
@@ -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,
};
};
+1217
View File
File diff suppressed because it is too large Load Diff