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