From 4d08b01b4157691e6ace16ba7a40789b4aad2ec4 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sun, 28 Jun 2026 01:31:15 +0800 Subject: [PATCH] feat(library): add recently read shelf to the library (#3797) (#4829) Add an opt-in "Recently read" carousel at the top of the library that shows the most recently read books for quick resume. The strip reuses the BookItem component and mirrors the bookshelf grid column widths, so covers render and align identically at any column count. It scrolls horizontally with arrow buttons, opens a book through a shared availability-aware path (downloads cloud-only synced books first), and is toggled from the View menu (off by default). Co-authored-by: Claude Opus 4.8 (1M context) --- .../public/locales/ar/translation.json | 4 +- .../public/locales/bn/translation.json | 4 +- .../public/locales/bo/translation.json | 4 +- .../public/locales/de/translation.json | 4 +- .../public/locales/el/translation.json | 4 +- .../public/locales/en/translation.json | 4 +- .../public/locales/es/translation.json | 4 +- .../public/locales/fa/translation.json | 4 +- .../public/locales/fr/translation.json | 4 +- .../public/locales/he/translation.json | 4 +- .../public/locales/hi/translation.json | 4 +- .../public/locales/hu/translation.json | 4 +- .../public/locales/id/translation.json | 4 +- .../public/locales/it/translation.json | 4 +- .../public/locales/ja/translation.json | 4 +- .../public/locales/ko/translation.json | 4 +- .../public/locales/ms/translation.json | 4 +- .../public/locales/nl/translation.json | 4 +- .../public/locales/pl/translation.json | 4 +- .../public/locales/pt-BR/translation.json | 4 +- .../public/locales/pt/translation.json | 4 +- .../public/locales/ro/translation.json | 4 +- .../public/locales/ru/translation.json | 4 +- .../public/locales/si/translation.json | 4 +- .../public/locales/sl/translation.json | 4 +- .../public/locales/sv/translation.json | 4 +- .../public/locales/ta/translation.json | 4 +- .../public/locales/th/translation.json | 4 +- .../public/locales/tr/translation.json | 4 +- .../public/locales/uk/translation.json | 4 +- .../public/locales/uz/translation.json | 4 +- .../public/locales/vi/translation.json | 4 +- .../public/locales/zh-CN/translation.json | 4 +- .../public/locales/zh-TW/translation.json | 4 +- .../app/library/recent-shelf.test.ts | 80 ++++++ .../src/app/library/components/Bookshelf.tsx | 89 ++++++- .../app/library/components/BookshelfItem.tsx | 68 +---- .../app/library/components/RecentShelf.tsx | 238 ++++++++++++++++++ .../src/app/library/components/ViewMenu.tsx | 18 ++ .../src/app/library/hooks/useOpenBook.ts | 97 +++++++ .../src/app/library/utils/libraryUtils.ts | 22 ++ apps/readest-app/src/services/constants.ts | 1 + apps/readest-app/src/types/settings.ts | 2 + 43 files changed, 644 insertions(+), 107 deletions(-) create mode 100644 apps/readest-app/src/__tests__/app/library/recent-shelf.test.ts create mode 100644 apps/readest-app/src/app/library/components/RecentShelf.tsx create mode 100644 apps/readest-app/src/app/library/hooks/useOpenBook.ts diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index a64edb69..028eef45 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -1939,5 +1939,7 @@ "Word": "كلمة", "Sentence": "جملة", "Granularity": "الدقة", - "Refresh Page": "تحديث الصفحة" + "Refresh Page": "تحديث الصفحة", + "Recently read": "المقروءة مؤخرًا", + "Show recently read": "إظهار المقروءة مؤخرًا" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index f6b8ccce..e32c0e80 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -1807,5 +1807,7 @@ "Word": "শব্দ", "Sentence": "বাক্য", "Granularity": "সূক্ষ্মতা", - "Refresh Page": "পৃষ্ঠা রিফ্রেশ করুন" + "Refresh Page": "পৃষ্ঠা রিফ্রেশ করুন", + "Recently read": "সম্প্রতি পড়া", + "Show recently read": "সম্প্রতি পড়া দেখান" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 3d6a119c..4a7afd09 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -1768,5 +1768,7 @@ "Word": "ཚིག", "Sentence": "ཚིག་གྲུབ།", "Granularity": "ཞིབ་ཕྲའི་ཚད", - "Refresh Page": "ཤོག་ལྷེ་བསྐྱར་གསོ།" + "Refresh Page": "ཤོག་ལྷེ་བསྐྱར་གསོ།", + "Recently read": "ཉེ་ཆར་བཀླགས་པ།", + "Show recently read": "ཉེ་ཆར་བཀླགས་པ་སྟོན་པ།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 9a98eda9..bde2b80f 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -1807,5 +1807,7 @@ "Word": "Wort", "Sentence": "Satz", "Granularity": "Granularität", - "Refresh Page": "Seite aktualisieren" + "Refresh Page": "Seite aktualisieren", + "Recently read": "Zuletzt gelesen", + "Show recently read": "Zuletzt gelesen anzeigen" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 3a188727..a107fd26 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -1807,5 +1807,7 @@ "Word": "Λέξη", "Sentence": "Πρόταση", "Granularity": "Διαβάθμιση", - "Refresh Page": "Ανανέωση σελίδας" + "Refresh Page": "Ανανέωση σελίδας", + "Recently read": "Πρόσφατα αναγνωσμένα", + "Show recently read": "Εμφάνιση πρόσφατα αναγνωσμένων" } diff --git a/apps/readest-app/public/locales/en/translation.json b/apps/readest-app/public/locales/en/translation.json index 76b08d53..f466b2de 100644 --- a/apps/readest-app/public/locales/en/translation.json +++ b/apps/readest-app/public/locales/en/translation.json @@ -67,5 +67,7 @@ "Are you sure to clear all {{count}} highlights and notes?_one": "Are you sure to clear all {{count}} highlight and note?", "Are you sure to clear all {{count}} highlights and notes?_other": "Are you sure to clear all {{count}} highlights and notes?", "Imported {{count}} annotations_one": "Imported {{count}} annotation", - "Imported {{count}} annotations_other": "Imported {{count}} annotations" + "Imported {{count}} annotations_other": "Imported {{count}} annotations", + "Recently read": "Recently read", + "Show recently read": "Show recently read" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 9303787a..4a21407f 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -1840,5 +1840,7 @@ "Word": "Palabra", "Sentence": "Oración", "Granularity": "Granularidad", - "Refresh Page": "Actualizar página" + "Refresh Page": "Actualizar página", + "Recently read": "Leídos recientemente", + "Show recently read": "Mostrar leídos recientemente" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 7a1aeceb..1a0045cf 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -1807,5 +1807,7 @@ "Word": "کلمه", "Sentence": "جمله", "Granularity": "دانه‌بندی", - "Refresh Page": "تازه‌سازی صفحه" + "Refresh Page": "تازه‌سازی صفحه", + "Recently read": "اخیراً خوانده‌شده", + "Show recently read": "نمایش موارد اخیراً خوانده‌شده" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index ddf54132..e9be5ab6 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -1840,5 +1840,7 @@ "Word": "Mot", "Sentence": "Phrase", "Granularity": "Granularité", - "Refresh Page": "Actualiser la page" + "Refresh Page": "Actualiser la page", + "Recently read": "Lectures récentes", + "Show recently read": "Afficher les lectures récentes" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index 850f5968..e8e9ebc2 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -1840,5 +1840,7 @@ "Word": "מילה", "Sentence": "משפט", "Granularity": "רמת פירוט", - "Refresh Page": "רענן דף" + "Refresh Page": "רענן דף", + "Recently read": "נקראו לאחרונה", + "Show recently read": "הצגת ספרים שנקראו לאחרונה" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 91459f3e..586fdffa 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -1807,5 +1807,7 @@ "Word": "शब्द", "Sentence": "वाक्य", "Granularity": "सूक्ष्मता", - "Refresh Page": "पृष्ठ ताज़ा करें" + "Refresh Page": "पृष्ठ ताज़ा करें", + "Recently read": "हाल ही में पढ़े गए", + "Show recently read": "हाल ही में पढ़े गए दिखाएँ" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index 85f0bd59..330ffa2e 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -1807,5 +1807,7 @@ "Word": "Szó", "Sentence": "Mondat", "Granularity": "Részletesség", - "Refresh Page": "Oldal frissítése" + "Refresh Page": "Oldal frissítése", + "Recently read": "Nemrég olvasott", + "Show recently read": "Nemrég olvasottak megjelenítése" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 89dfc88b..9ddcde4a 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -1774,5 +1774,7 @@ "Word": "Kata", "Sentence": "Kalimat", "Granularity": "Granularitas", - "Refresh Page": "Segarkan Halaman" + "Refresh Page": "Segarkan Halaman", + "Recently read": "Baru dibaca", + "Show recently read": "Tampilkan baru dibaca" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index dbe746f7..28def618 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -1840,5 +1840,7 @@ "Word": "Parola", "Sentence": "Frase", "Granularity": "Granularità", - "Refresh Page": "Aggiorna pagina" + "Refresh Page": "Aggiorna pagina", + "Recently read": "Letti di recente", + "Show recently read": "Mostra letti di recente" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index aef29625..073f35af 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -1774,5 +1774,7 @@ "Word": "単語", "Sentence": "文", "Granularity": "粒度", - "Refresh Page": "ページを更新" + "Refresh Page": "ページを更新", + "Recently read": "最近読んだ本", + "Show recently read": "最近読んだ本を表示" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 50441cf4..22a3238b 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -1774,5 +1774,7 @@ "Word": "단어", "Sentence": "문장", "Granularity": "단위", - "Refresh Page": "페이지 새로고침" + "Refresh Page": "페이지 새로고침", + "Recently read": "최근 읽은 책", + "Show recently read": "최근 읽은 책 표시" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index 68d5f53d..3922a832 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -1774,5 +1774,7 @@ "Word": "Perkataan", "Sentence": "Ayat", "Granularity": "Granulariti", - "Refresh Page": "Segarkan Halaman" + "Refresh Page": "Segarkan Halaman", + "Recently read": "Baru dibaca", + "Show recently read": "Tunjukkan baru dibaca" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 931c5291..00cf1d21 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -1807,5 +1807,7 @@ "Word": "Woord", "Sentence": "Zin", "Granularity": "Granulariteit", - "Refresh Page": "Pagina vernieuwen" + "Refresh Page": "Pagina vernieuwen", + "Recently read": "Onlangs gelezen", + "Show recently read": "Onlangs gelezen tonen" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index c8fcd18f..8f74af28 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -1873,5 +1873,7 @@ "Word": "Słowo", "Sentence": "Zdanie", "Granularity": "Ziarnistość", - "Refresh Page": "Odśwież stronę" + "Refresh Page": "Odśwież stronę", + "Recently read": "Ostatnio czytane", + "Show recently read": "Pokaż ostatnio czytane" } diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index 8841d16f..bb26cf63 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -1840,5 +1840,7 @@ "Word": "Palavra", "Sentence": "Frase", "Granularity": "Granularidade", - "Refresh Page": "Atualizar página" + "Refresh Page": "Atualizar página", + "Recently read": "Lidos recentemente", + "Show recently read": "Mostrar lidos recentemente" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index dc612fbc..53a0b96a 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -1840,5 +1840,7 @@ "Word": "Palavra", "Sentence": "Frase", "Granularity": "Granularidade", - "Refresh Page": "Atualizar Página" + "Refresh Page": "Atualizar Página", + "Recently read": "Lidos recentemente", + "Show recently read": "Mostrar lidos recentemente" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index 35a1ac9c..e742fb98 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -1840,5 +1840,7 @@ "Word": "Cuvânt", "Sentence": "Propoziție", "Granularity": "Granularitate", - "Refresh Page": "Reîmprospătează pagina" + "Refresh Page": "Reîmprospătează pagina", + "Recently read": "Citite recent", + "Show recently read": "Afișează citite recent" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index ffc9d3a1..436c26e3 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -1873,5 +1873,7 @@ "Word": "Слово", "Sentence": "Предложение", "Granularity": "Детализация", - "Refresh Page": "Обновить страницу" + "Refresh Page": "Обновить страницу", + "Recently read": "Недавно прочитанные", + "Show recently read": "Показывать недавно прочитанные" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index c13aca5b..6e1acb99 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -1807,5 +1807,7 @@ "Word": "වචනය", "Sentence": "වාක්‍යය", "Granularity": "විස්තර මට්ටම", - "Refresh Page": "පිටුව නැවුම් කරන්න" + "Refresh Page": "පිටුව නැවුම් කරන්න", + "Recently read": "මෑතකදී කියවූ", + "Show recently read": "මෑතකදී කියවූ ඒවා පෙන්වන්න" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index 6d6d6b1b..c34dda5b 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -1873,5 +1873,7 @@ "Word": "Beseda", "Sentence": "Stavek", "Granularity": "Granularnost", - "Refresh Page": "Osveži stran" + "Refresh Page": "Osveži stran", + "Recently read": "Nedavno prebrano", + "Show recently read": "Pokaži nedavno prebrano" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index 9f72358c..2bde388d 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -1807,5 +1807,7 @@ "Word": "Ord", "Sentence": "Mening", "Granularity": "Detaljnivå", - "Refresh Page": "Uppdatera sidan" + "Refresh Page": "Uppdatera sidan", + "Recently read": "Nyligen lästa", + "Show recently read": "Visa nyligen lästa" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index aeeda020..6420e51e 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -1807,5 +1807,7 @@ "Word": "சொல்", "Sentence": "வாக்கியம்", "Granularity": "நுண்மை", - "Refresh Page": "பக்கத்தைப் புதுப்பி" + "Refresh Page": "பக்கத்தைப் புதுப்பி", + "Recently read": "சமீபத்தில் படித்தவை", + "Show recently read": "சமீபத்தில் படித்தவற்றைக் காட்டு" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 37674e89..4a322f4d 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -1774,5 +1774,7 @@ "Word": "คำ", "Sentence": "ประโยค", "Granularity": "ระดับความละเอียด", - "Refresh Page": "รีเฟรชหน้า" + "Refresh Page": "รีเฟรชหน้า", + "Recently read": "อ่านล่าสุด", + "Show recently read": "แสดงรายการอ่านล่าสุด" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 79026188..9ec71601 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -1807,5 +1807,7 @@ "Word": "Kelime", "Sentence": "Cümle", "Granularity": "Ayrıntı düzeyi", - "Refresh Page": "Sayfayı Yenile" + "Refresh Page": "Sayfayı Yenile", + "Recently read": "Son okunanlar", + "Show recently read": "Son okunanları göster" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 650dd9c9..64c60bfb 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -1873,5 +1873,7 @@ "Word": "Слово", "Sentence": "Речення", "Granularity": "Деталізація", - "Refresh Page": "Оновити сторінку" + "Refresh Page": "Оновити сторінку", + "Recently read": "Нещодавно прочитані", + "Show recently read": "Показувати нещодавно прочитані" } diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index 8463f31b..bfb7aa93 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -1807,5 +1807,7 @@ "Word": "So‘z", "Sentence": "Gap", "Granularity": "Tafsilot darajasi", - "Refresh Page": "Sahifani yangilash" + "Refresh Page": "Sahifani yangilash", + "Recently read": "Yaqinda o'qilgan", + "Show recently read": "Yaqinda o'qilganlarni ko'rsatish" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index f217d68c..0a0722ff 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -1774,5 +1774,7 @@ "Word": "Từ", "Sentence": "Câu", "Granularity": "Mức độ chi tiết", - "Refresh Page": "Làm mới trang" + "Refresh Page": "Làm mới trang", + "Recently read": "Đã đọc gần đây", + "Show recently read": "Hiển thị mục đã đọc gần đây" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 7483e075..77744576 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -1774,5 +1774,7 @@ "Word": "单词", "Sentence": "句子", "Granularity": "粒度", - "Refresh Page": "刷新页面" + "Refresh Page": "刷新页面", + "Recently read": "最近阅读", + "Show recently read": "显示最近阅读" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index a2a9fb9c..8f84481c 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -1774,5 +1774,7 @@ "Word": "單字", "Sentence": "句子", "Granularity": "粒度", - "Refresh Page": "重新整理頁面" + "Refresh Page": "重新整理頁面", + "Recently read": "最近閱讀", + "Show recently read": "顯示最近閱讀" } diff --git a/apps/readest-app/src/__tests__/app/library/recent-shelf.test.ts b/apps/readest-app/src/__tests__/app/library/recent-shelf.test.ts new file mode 100644 index 00000000..e1bc598e --- /dev/null +++ b/apps/readest-app/src/__tests__/app/library/recent-shelf.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { selectRecentShelfBooks } from '../../../app/library/utils/libraryUtils'; +import { Book } from '../../../types/book'; +import { BookMetadata } from '@/libs/document'; + +// The shelf is "recently read", so a book counts only once it has reading +// progress. The shared helper in library-utils.test.ts does NOT set `progress`, +// so this local helper defaults a read book; cases that need an unread book +// override `progress: undefined`. +const createMockBook = ( + overrides: Partial & { metadata?: Partial }> = {}, +): Book => ({ + hash: `hash-${Math.random().toString(36).slice(2, 11)}`, + format: 'EPUB', + title: 'Test Book', + author: 'Test Author', + createdAt: Date.now(), + updatedAt: Date.now(), + progress: [1, 100], + ...overrides, + metadata: { ...overrides.metadata } as BookMetadata, +}); + +describe('selectRecentShelfBooks', () => { + it('orders books by updatedAt descending (most recent first)', () => { + const older = createMockBook({ hash: 'older', updatedAt: 1000 }); + const newer = createMockBook({ hash: 'newer', updatedAt: 3000 }); + const middle = createMockBook({ hash: 'middle', updatedAt: 2000 }); + + const result = selectRecentShelfBooks([older, newer, middle], 10); + + expect(result.map((book) => book.hash)).toEqual(['newer', 'middle', 'older']); + }); + + it('excludes soft-deleted books', () => { + const live = createMockBook({ hash: 'live', updatedAt: 1000 }); + const deleted = createMockBook({ hash: 'deleted', updatedAt: 2000, deletedAt: 2500 }); + + const result = selectRecentShelfBooks([live, deleted], 10); + + expect(result.map((book) => book.hash)).toEqual(['live']); + }); + + it('excludes imported-but-unread books (no reading progress)', () => { + const read = createMockBook({ hash: 'read', updatedAt: 1000 }); + const justAdded = createMockBook({ hash: 'added', updatedAt: 9999, progress: undefined }); + + const result = selectRecentShelfBooks([read, justAdded], 10); + + expect(result.map((book) => book.hash)).toEqual(['read']); + }); + + it('includes a book once it has been opened (progress present)', () => { + const opened = createMockBook({ hash: 'opened', updatedAt: 2000, progress: [1, 100] }); + + expect(selectRecentShelfBooks([opened], 10).map((book) => book.hash)).toEqual(['opened']); + }); + + it('slices to the requested count, keeping the most recent', () => { + const books = [ + createMockBook({ hash: 'a', updatedAt: 1000 }), + createMockBook({ hash: 'b', updatedAt: 2000 }), + createMockBook({ hash: 'c', updatedAt: 3000 }), + ]; + + const result = selectRecentShelfBooks(books, 2); + + expect(result.map((book) => book.hash)).toEqual(['c', 'b']); + }); + + it('returns all books when fewer than the count exist', () => { + const books = [createMockBook({ updatedAt: 1000 }), createMockBook({ updatedAt: 2000 })]; + + expect(selectRecentShelfBooks(books, 10)).toHaveLength(2); + }); + + it('returns an empty array when there are no books', () => { + expect(selectRecentShelfBooks([], 10)).toEqual([]); + }); +}); diff --git a/apps/readest-app/src/app/library/components/Bookshelf.tsx b/apps/readest-app/src/app/library/components/Bookshelf.tsx index 76b5c30f..7ab7b01c 100644 --- a/apps/readest-app/src/app/library/components/Bookshelf.tsx +++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx @@ -43,6 +43,7 @@ import { compareSortValues, resolveEffectivePrimarySort, resolveEffectiveSecondarySort, + selectRecentShelfBooks, withReadingStatus, } from '../utils/libraryUtils'; import { eventDispatcher } from '@/utils/event'; @@ -60,6 +61,8 @@ import ShareBookDialog from './ShareBookDialog'; import { useAuth } from '@/context/AuthContext'; import GroupingModal from './GroupingModal'; import SetStatusAlert from './SetStatusAlert'; +import RecentShelf, { RECENT_SHELF_BOOK_COUNT } from './RecentShelf'; +import { useOpenBook } from '../hooks/useOpenBook'; interface BookshelfProps { libraryBooks: Book[]; @@ -90,6 +93,13 @@ interface BookshelfProps { type BookshelfListContext = { autoColumns: boolean; fixedColumns: number; + /** + * The recently-read shelf, rendered in the Virtuoso header so it scrolls with + * the shelf content (not sticky). `null` when hidden. Passed through context + * (rather than recreating the Header component) so Virtuoso keeps the Header + * identity stable and does not reset its scroller on every Bookshelf render. + */ + recentShelfHeader: React.ReactNode; }; const BOOKSHELF_GRID_CLASSES = @@ -119,21 +129,28 @@ const BookshelfGridList: GridComponents['List'] = React.fo )); BookshelfGridList.displayName = 'BookshelfGridList'; -const BookshelfLinearList: Components['List'] = React.forwardRef( - ({ children, style, 'data-testid': testId }, ref) => ( -
- {children} -
- ), -); +const BookshelfLinearList: Components['List'] = React.forwardRef< + HTMLDivElement, + ListProps +>(({ children, style, 'data-testid': testId }, ref) => ( +
+ {children} +
+)); BookshelfLinearList.displayName = 'BookshelfLinearList'; +const BookshelfHeader = ({ context }: { context?: BookshelfListContext }) => ( + <>{context?.recentShelfHeader ?? null} +); + const GRID_VIRTUOSO_COMPONENTS: GridComponents = { List: BookshelfGridList, + Header: BookshelfHeader, Footer: () =>
, }; -const LIST_VIRTUOSO_COMPONENTS: Components = { +const LIST_VIRTUOSO_COMPONENTS: Components = { List: BookshelfLinearList, + Header: BookshelfHeader, Footer: () =>
, }; @@ -668,12 +685,63 @@ const Bookshelf: React.FC = ({ // last book; list mode doesn't have an import tile. const gridTotalCount = hasItems ? sortedBookshelfItems.length + 1 : 0; + // Recently-read shelf: shares the availability-aware open path with per-item + // taps so cloud-only synced books download before opening. `openBook` is + // memoized inside the hook, keeping `openRecentBook` -> `recentShelfHeader` + // -> `listContext` identities stable (no full-grid re-render churn). + const { openBook } = useOpenBook({ setLoading, handleBookDownload }); + const openRecentBook = useCallback((book: Book) => openBook(book), [openBook]); + + // Flat recency slice of the whole library, independent of the main shelf's + // sort/grouping. Built from `libraryBooks` (not the sorted/filtered items). + const recentBooks = useMemo( + () => selectRecentShelfBooks(libraryBooks, RECENT_SHELF_BOOK_COUNT), + [libraryBooks], + ); + + // A top-level quick-resume strip: hidden while searching, inside a group, + // selecting, or when nothing has been read yet. + const showRecentShelf = + settings.libraryRecentShelfEnabled && + !queryTerm && + !groupId && + !isSelectMode && + recentBooks.length > 0; + + const recentShelfHeader = useMemo( + () => + showRecentShelf ? ( + + ) : null, + [ + showRecentShelf, + recentBooks, + coverFit, + settings.libraryAutoColumns, + settings.libraryColumns, + openRecentBook, + handleBookUpload, + handleBookDownload, + handleShowDetailsBook, + ], + ); + const listContext = useMemo( () => ({ autoColumns: settings.libraryAutoColumns, fixedColumns: settings.libraryColumns, + recentShelfHeader, }), - [settings.libraryAutoColumns, settings.libraryColumns], + [settings.libraryAutoColumns, settings.libraryColumns, recentShelfHeader], ); const renderBookshelfItem = useCallback( @@ -786,10 +854,11 @@ const Bookshelf: React.FC = ({ /> )} {hasItems && !isGridMode && ( - overscan={200} totalCount={sortedBookshelfItems.length} components={LIST_VIRTUOSO_COMPONENTS} + context={listContext} computeItemKey={computeItemKey} itemContent={renderBookshelfItem} scrollerRef={handleScrollerRef} diff --git a/apps/readest-app/src/app/library/components/BookshelfItem.tsx b/apps/readest-app/src/app/library/components/BookshelfItem.tsx index 731640b9..1c7ea0a6 100644 --- a/apps/readest-app/src/app/library/components/BookshelfItem.tsx +++ b/apps/readest-app/src/app/library/components/BookshelfItem.tsx @@ -1,10 +1,8 @@ import clsx from 'clsx'; import { useCallback } from 'react'; import { useEnv } from '@/context/EnvContext'; -import { useLibraryStore } from '@/store/libraryStore'; import { useSettingsStore } from '@/store/settingsStore'; import { useTranslation } from '@/hooks/useTranslation'; -import { useAppRouter } from '@/hooks/useAppRouter'; import { useLongPress } from '@/hooks/useLongPress'; import { Menu, type MenuItemOptions } from '@tauri-apps/api/menu'; import { revealItemInDir } from '@tauri-apps/plugin-opener'; @@ -13,7 +11,6 @@ import { openExternalUrl } from '@/utils/open'; import { getBookGoodreadsQuery, getGoodreadsSearchUrl } from '@/utils/goodreads'; import { getOSPlatform } from '@/utils/misc'; import { throttle } from '@/utils/throttle'; -import { navigateToReader, showReaderWindow } from '@/utils/nav'; import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings'; import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants'; import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os'; @@ -25,6 +22,7 @@ import { import { md5Fingerprint } from '@/utils/md5'; import BookItem from './BookItem'; import GroupItem from './GroupItem'; +import { useOpenBook } from '../hooks/useOpenBook'; export const generateBookshelfItems = ( books: Book[], @@ -127,80 +125,24 @@ const BookshelfItem: React.FC = ({ handleUpdateReadingStatus, }) => { const _ = useTranslation(); - const router = useAppRouter(); - const { envConfig, appService } = useEnv(); + const { appService } = useEnv(); const { settings } = useSettingsStore(); - const { updateBook } = useLibraryStore(); + const { openBook } = useOpenBook({ setLoading, handleBookDownload }); const showBookDetailsModal = useCallback(async (book: Book) => { handleShowDetailsBook(book); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const makeBookAvailable = async (book: Book) => { - if (book.uploadedAt && !book.downloadedAt) { - if (await appService?.isBookAvailable(book)) { - if (!book.downloadedAt || !book.coverDownloadedAt) { - book.downloadedAt = Date.now(); - book.coverDownloadedAt = Date.now(); - await updateBook(envConfig, book); - } - return true; - } - let available = false; - const loadingTimeout = setTimeout(() => setLoading(true), 200); - try { - available = await handleBookDownload(book, { queued: false }); - await updateBook(envConfig, book); - } finally { - if (loadingTimeout) clearTimeout(loadingTimeout); - setLoading(false); - } - return available; - } - return true; - }; - const handleBookClick = useCallback( async (book: Book) => { if (isSelectMode) { toggleSelection(book.hash); return; } - // In-place books point at a file outside Books// that the user - // (or another app) may have moved, renamed, or deleted between sessions. - // Probe the source before navigating: if it's gone, drop the stale - // library record instead of opening the reader only to fail inside - // loadBookContent and bounce back with a toast. We restrict this to - // purely-local in-place books — cloud-synced books (`uploadedAt`) still - // go through `makeBookAvailable`'s on-demand download path below, and - // hash-copy books (no `filePath`) shouldn't lose their Books// - // file under normal use, so we don't second-guess those here. - if (book.filePath && !book.uploadedAt && !book.deletedAt) { - const available = await appService?.isBookAvailable(book); - if (!available) { - eventDispatcher.dispatch('toast', { - message: _( - 'Book file no longer exists. Confirm deletion to remove it from the library.', - ), - type: 'info', - }); - eventDispatcher.dispatch('delete-books', { ids: [book.hash] }); - return; - } - } - const available = await makeBookAvailable(book); - if (!available) return; - if (appService?.hasWindow && settings.openBookInNewWindow) { - showReaderWindow(appService, [book.hash]); - } else { - setTimeout(() => { - navigateToReader(router, [book.hash]); - }, 0); - } + await openBook(book); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [isSelectMode, settings.openBookInNewWindow, appService], + [isSelectMode, openBook, toggleSelection], ); const handleGroupClick = useCallback( diff --git a/apps/readest-app/src/app/library/components/RecentShelf.tsx b/apps/readest-app/src/app/library/components/RecentShelf.tsx new file mode 100644 index 00000000..d6267dbc --- /dev/null +++ b/apps/readest-app/src/app/library/components/RecentShelf.tsx @@ -0,0 +1,238 @@ +import clsx from 'clsx'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { MdChevronLeft, MdChevronRight } from 'react-icons/md'; +import { Book } from '@/types/book'; +import { LibraryCoverFitType } from '@/types/settings'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useLongPress } from '@/hooks/useLongPress'; +import BookItem from './BookItem'; + +/** + * How many recently-read books the top shelf holds. Fixed (no user option) so + * the row stays a compact quick-resume strip rather than a second library. + */ +export const RECENT_SHELF_BOOK_COUNT = 12; + +interface RecentShelfProps { + books: Book[]; + coverFit: LibraryCoverFitType; + // Mirror the bookshelf grid's column model so covers are the same size. + autoColumns: boolean; + fixedColumns: number; + onOpenBook: (book: Book) => void; + handleBookUpload: (book: Book) => void; + handleBookDownload: (book: Book, options?: { redownload?: boolean; queued?: boolean }) => void; + showBookDetailsModal: (book: Book) => void; +} + +/** + * Each slide is exactly one bookshelf-grid column wide. The width is the grid's + * own gap-aware formula — `(100% - (cols - 1) * gap) / cols` — so it matches a + * CSS-grid column for any column count or gap (flex `basis-1/N` does NOT, since + * it ignores the row gap). `cols`/`gap` come from CSS vars set on the row. + * `min-w-0` stops a flex item from growing to its cover image's intrinsic width. + */ +const RECENT_SLIDE_WIDTH = + 'calc((100% - (var(--rs-cols, 6) - 1) * var(--rs-gap, 0px)) / var(--rs-cols, 6))'; + +type RecentSlideProps = Pick< + RecentShelfProps, + 'coverFit' | 'onOpenBook' | 'handleBookUpload' | 'handleBookDownload' | 'showBookDetailsModal' +> & { book: Book }; + +const RecentSlide: React.FC = ({ + book, + coverFit, + onOpenBook, + handleBookUpload, + handleBookDownload, + showBookDetailsModal, +}) => { + // Pointer-based tap, exactly like the grid (`BookItem` stops click + // propagation). A swipe-to-scroll moves past useLongPress's moveThreshold and + // cancels the tap, so horizontal scrolling never opens a book. + const { pressing, handlers } = useLongPress({ onTap: () => onOpenBook(book) }, [ + book, + onOpenBook, + ]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpenBook(book); + } + }; + + return ( +
+ {/* Same chassis as the grid item (BookshelfItem grid branch) so the cover, + title, progress and badges render identically. */} +
+
+ +
+
+
+ ); +}; + +/** + * Recently-read shelf at the top of the library: a flat, recency-ordered strip + * of covers, independent of the main shelf's sort/grouping. It scrolls only + * horizontally and shares the grid's column widths, gap and insets, so each + * cover lines up with the shelf below and renders identically (reuses + * `BookItem`) at any column count. + */ +const RecentShelf: React.FC = ({ + books, + coverFit, + autoColumns, + fixedColumns, + onOpenBook, + handleBookUpload, + handleBookDownload, + showBookDetailsModal, +}) => { + const _ = useTranslation(); + // `--rs-cols` mirrors the grid's column count: the responsive ladder + // (BOOKSHELF_GRID_CLASSES) when auto, or the fixed setting otherwise. + // `--rs-gap` mirrors the grid's `gap-x-4 sm:gap-x-0` so the width formula + // subtracts the right gap at each breakpoint. + const colsClass = autoColumns + ? '[--rs-cols:3] sm:[--rs-cols:4] md:[--rs-cols:6] xl:[--rs-cols:8] 2xl:[--rs-cols:12]' + : ''; + const colsStyle = autoColumns + ? undefined + : ({ '--rs-cols': fixedColumns } as React.CSSProperties); + + const scrollerRef = useRef(null); + const [showLeft, setShowLeft] = useState(false); + const [showRight, setShowRight] = useState(false); + // Vertical center of the cover artwork (px from the scroller top). The slide + // carries a title below the cover, so centering the arrows on the artwork + // keeps them visually balanced. Null falls back to the row's mid-height. + const [coverCenter, setCoverCenter] = useState(null); + + // Cheap, runs on every scroll: which edges have more content to reveal. + const updateArrows = useCallback(() => { + const el = scrollerRef.current; + if (!el) return; + setShowLeft(el.scrollLeft > 1); + setShowRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); + }, []); + + // Heavier: also re-measures the cover center. Runs on mount/resize, not scroll. + const measure = useCallback(() => { + const el = scrollerRef.current; + if (!el) return; + updateArrows(); + const cover = el.querySelector('.bookitem-main'); + if (cover) { + const rect = cover.getBoundingClientRect(); + setCoverCenter(rect.top - el.getBoundingClientRect().top + rect.height / 2); + } + }, [updateArrows]); + + useEffect(() => { + measure(); + const el = scrollerRef.current; + if (!el) return; + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [measure, books, autoColumns, fixedColumns, coverFit]); + + const scrollByPage = (direction: -1 | 1) => { + const el = scrollerRef.current; + if (!el) return; + el.scrollBy({ left: direction * el.clientWidth * 0.8, behavior: 'smooth' }); + }; + + return ( +
+

+ {_('Recently read')} +

+
+ {/* Horizontal-only scroll; px insets + gap mirror the grid. */} +
+
+ {books.map((book) => ( + + ))} +
+
+ {showLeft && ( + + )} + {showRight && ( + + )} +
+ {/* Modern divider: an inset hairline with breathing room above and below + so it does not crowd the first shelf row. */} + + ); +}; + +export default RecentShelf; diff --git a/apps/readest-app/src/app/library/components/ViewMenu.tsx b/apps/readest-app/src/app/library/components/ViewMenu.tsx index d83cbc40..6edae830 100644 --- a/apps/readest-app/src/app/library/components/ViewMenu.tsx +++ b/apps/readest-app/src/app/library/components/ViewMenu.tsx @@ -109,6 +109,14 @@ const ViewMenu: React.FC = ({ setIsDropdownOpen }) => { await saveSysSettings(envConfig, 'libraryAutoColumns', newValue); }; + const handleToggleRecentShelf = async () => { + await saveSysSettings( + envConfig, + 'libraryRecentShelfEnabled', + !settings.libraryRecentShelfEnabled, + ); + }; + const handleSetColumns = async (value: number) => { await saveSysSettings(envConfig, 'libraryColumns', value); await saveSysSettings(envConfig, 'libraryAutoColumns', false); @@ -218,6 +226,16 @@ const ViewMenu: React.FC = ({ setIsDropdownOpen }) => { /> ))} + {/* Recently read shelf */} + + + {/* Group By - Collapsible */} diff --git a/apps/readest-app/src/app/library/hooks/useOpenBook.ts b/apps/readest-app/src/app/library/hooks/useOpenBook.ts new file mode 100644 index 00000000..27a89593 --- /dev/null +++ b/apps/readest-app/src/app/library/hooks/useOpenBook.ts @@ -0,0 +1,97 @@ +import { Dispatch, SetStateAction, useCallback } from 'react'; +import { Book } from '@/types/book'; +import { useEnv } from '@/context/EnvContext'; +import { useLibraryStore } from '@/store/libraryStore'; +import { useSettingsStore } from '@/store/settingsStore'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useAppRouter } from '@/hooks/useAppRouter'; +import { eventDispatcher } from '@/utils/event'; +import { navigateToReader, showReaderWindow } from '@/utils/nav'; + +interface UseOpenBookOptions { + setLoading: Dispatch>; + handleBookDownload: ( + book: Book, + options?: { redownload?: boolean; queued?: boolean }, + ) => Promise; +} + +/** + * Shared "open this book" flow used both by per-item taps (`BookshelfItem`) and + * the recently-read shelf. Centralizing it keeps the availability handling in + * one place: cloud-synced books (which arrive on other devices as metadata + + * progress without the file blob) are downloaded on demand, and a stale + * in-place record is dropped instead of bouncing the user into a broken reader. + */ +export const useOpenBook = ({ setLoading, handleBookDownload }: UseOpenBookOptions) => { + const _ = useTranslation(); + const router = useAppRouter(); + const { envConfig, appService } = useEnv(); + const { settings } = useSettingsStore(); + const { updateBook } = useLibraryStore(); + + const makeBookAvailable = useCallback( + async (book: Book) => { + if (book.uploadedAt && !book.downloadedAt) { + if (await appService?.isBookAvailable(book)) { + if (!book.downloadedAt || !book.coverDownloadedAt) { + book.downloadedAt = Date.now(); + book.coverDownloadedAt = Date.now(); + await updateBook(envConfig, book); + } + return true; + } + let available = false; + const loadingTimeout = setTimeout(() => setLoading(true), 200); + try { + available = await handleBookDownload(book, { queued: false }); + await updateBook(envConfig, book); + } finally { + if (loadingTimeout) clearTimeout(loadingTimeout); + setLoading(false); + } + return available; + } + return true; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [appService, envConfig, handleBookDownload, setLoading], + ); + + const openBook = useCallback( + async (book: Book) => { + // In-place books point at a file outside Books// that the user (or + // another app) may have moved, renamed, or deleted between sessions. Probe + // the source before navigating: if it's gone, drop the stale record + // instead of opening the reader only to fail and bounce back. Restricted + // to purely-local in-place books — cloud-synced books (`uploadedAt`) still + // go through `makeBookAvailable`'s on-demand download path. + if (book.filePath && !book.uploadedAt && !book.deletedAt) { + const available = await appService?.isBookAvailable(book); + if (!available) { + eventDispatcher.dispatch('toast', { + message: _( + 'Book file no longer exists. Confirm deletion to remove it from the library.', + ), + type: 'info', + }); + eventDispatcher.dispatch('delete-books', { ids: [book.hash] }); + return; + } + } + const available = await makeBookAvailable(book); + if (!available) return; + if (appService?.hasWindow && settings.openBookInNewWindow) { + showReaderWindow(appService, [book.hash]); + } else { + setTimeout(() => { + navigateToReader(router, [book.hash]); + }, 0); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [appService, makeBookAvailable, settings.openBookInNewWindow], + ); + + return { openBook, makeBookAvailable }; +}; diff --git a/apps/readest-app/src/app/library/utils/libraryUtils.ts b/apps/readest-app/src/app/library/utils/libraryUtils.ts index 1d24868c..1a3aa284 100644 --- a/apps/readest-app/src/app/library/utils/libraryUtils.ts +++ b/apps/readest-app/src/app/library/utils/libraryUtils.ts @@ -246,6 +246,28 @@ export const createBookSorter = return compareBookByKey(a, b, secondarySortBy, uiLanguage); }; +/** + * A book counts as "read" once it has reading progress. Importing a book sets + * timestamps but never `progress`; only opening it does. Gating on this keeps + * freshly-added-but-unopened books off the shelf. + */ +const hasBeenRead = (book: Book): boolean => book.progress != null; + +/** + * Pick the books for the recently-read shelf: most-recently-read first, capped + * at `count`. Recency uses `updatedAt` (the library's "Updated" sort key) so the + * row matches the app's existing sort convention. NB: `updatedAt` is last-modified + * (also bumped by status/metadata edits and sync), not strictly last-read. + * Independent of the main shelf's sort/grouping — always a flat, recency slice. + */ +export const selectRecentShelfBooks = (books: Book[], count: number): Book[] => { + const byRecency = createBookSorter(LibrarySortByType.Updated, ''); + return books + .filter((book) => !book.deletedAt && hasBeenRead(book)) + .sort((a, b) => -byRecency(a, b)) + .slice(0, count); +}; + /** * Build a `groupName -> max(book.updatedAt)` map for all groups touched by * the given books. Each book bumps both its direct group and every ancestor diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 3777654e..9e1995a7 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -150,6 +150,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial = { libraryCoverFit: 'crop', libraryAutoColumns: true, libraryColumns: 6, + libraryRecentShelfEnabled: false, metadataSeriesCollapsed: false, metadataOthersCollapsed: false, diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index 5523bb48..dfa938ef 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -272,6 +272,8 @@ export interface SystemSettings { libraryCoverFit: LibraryCoverFitType; libraryAutoColumns: boolean; libraryColumns: number; + /** Show the recently-read carousel at the top of the library (issue #3797). */ + libraryRecentShelfEnabled: boolean; /** * Library page background texture, configured independently from the reader * background (issue #4743). When any of these is undefined the library