feat(library): add recently read shelf to the library (#3797) (#4829)
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (rust) (push) Waiting to run
Publish Docker image / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Publish Docker image / merge (push) Blocked by required conditions
PR checks / rust_lint (push) Waiting to run
PR checks / build_web_app (push) Waiting to run
PR checks / test_web_app (1) (push) Waiting to run
PR checks / test_web_app (2) (push) Waiting to run
PR checks / test_extensions (push) Waiting to run
PR checks / build_tauri_app (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Deploy to vercel on merge / build_and_deploy (push) Waiting to run

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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin 2026-06-28 01:31:15 +08:00 committed by GitHub
parent d932444b78
commit 4d08b01b41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 644 additions and 107 deletions

View file

@ -1939,5 +1939,7 @@
"Word": "كلمة",
"Sentence": "جملة",
"Granularity": "الدقة",
"Refresh Page": "تحديث الصفحة"
"Refresh Page": "تحديث الصفحة",
"Recently read": "المقروءة مؤخرًا",
"Show recently read": "إظهار المقروءة مؤخرًا"
}

View file

@ -1807,5 +1807,7 @@
"Word": "শব্দ",
"Sentence": "বাক্য",
"Granularity": "সূক্ষ্মতা",
"Refresh Page": "পৃষ্ঠা রিফ্রেশ করুন"
"Refresh Page": "পৃষ্ঠা রিফ্রেশ করুন",
"Recently read": "সম্প্রতি পড়া",
"Show recently read": "সম্প্রতি পড়া দেখান"
}

View file

@ -1768,5 +1768,7 @@
"Word": "ཚིག",
"Sentence": "ཚིག་གྲུབ།",
"Granularity": "ཞིབ་ཕྲའི་ཚད",
"Refresh Page": "ཤོག་ལྷེ་བསྐྱར་གསོ།"
"Refresh Page": "ཤོག་ལྷེ་བསྐྱར་གསོ།",
"Recently read": "ཉེ་ཆར་བཀླགས་པ།",
"Show recently read": "ཉེ་ཆར་བཀླགས་པ་སྟོན་པ།"
}

View file

@ -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"
}

View file

@ -1807,5 +1807,7 @@
"Word": "Λέξη",
"Sentence": "Πρόταση",
"Granularity": "Διαβάθμιση",
"Refresh Page": "Ανανέωση σελίδας"
"Refresh Page": "Ανανέωση σελίδας",
"Recently read": "Πρόσφατα αναγνωσμένα",
"Show recently read": "Εμφάνιση πρόσφατα αναγνωσμένων"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -1807,5 +1807,7 @@
"Word": "کلمه",
"Sentence": "جمله",
"Granularity": "دانه‌بندی",
"Refresh Page": "تازه‌سازی صفحه"
"Refresh Page": "تازه‌سازی صفحه",
"Recently read": "اخیراً خوانده‌شده",
"Show recently read": "نمایش موارد اخیراً خوانده‌شده"
}

View file

@ -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"
}

View file

@ -1840,5 +1840,7 @@
"Word": "מילה",
"Sentence": "משפט",
"Granularity": "רמת פירוט",
"Refresh Page": "רענן דף"
"Refresh Page": "רענן דף",
"Recently read": "נקראו לאחרונה",
"Show recently read": "הצגת ספרים שנקראו לאחרונה"
}

View file

@ -1807,5 +1807,7 @@
"Word": "शब्द",
"Sentence": "वाक्य",
"Granularity": "सूक्ष्मता",
"Refresh Page": "पृष्ठ ताज़ा करें"
"Refresh Page": "पृष्ठ ताज़ा करें",
"Recently read": "हाल ही में पढ़े गए",
"Show recently read": "हाल ही में पढ़े गए दिखाएँ"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -1774,5 +1774,7 @@
"Word": "単語",
"Sentence": "文",
"Granularity": "粒度",
"Refresh Page": "ページを更新"
"Refresh Page": "ページを更新",
"Recently read": "最近読んだ本",
"Show recently read": "最近読んだ本を表示"
}

View file

@ -1774,5 +1774,7 @@
"Word": "단어",
"Sentence": "문장",
"Granularity": "단위",
"Refresh Page": "페이지 새로고침"
"Refresh Page": "페이지 새로고침",
"Recently read": "최근 읽은 책",
"Show recently read": "최근 읽은 책 표시"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -1873,5 +1873,7 @@
"Word": "Слово",
"Sentence": "Предложение",
"Granularity": "Детализация",
"Refresh Page": "Обновить страницу"
"Refresh Page": "Обновить страницу",
"Recently read": "Недавно прочитанные",
"Show recently read": "Показывать недавно прочитанные"
}

View file

@ -1807,5 +1807,7 @@
"Word": "වචනය",
"Sentence": "වාක්‍යය",
"Granularity": "විස්තර මට්ටම",
"Refresh Page": "පිටුව නැවුම් කරන්න"
"Refresh Page": "පිටුව නැවුම් කරන්න",
"Recently read": "මෑතකදී කියවූ",
"Show recently read": "මෑතකදී කියවූ ඒවා පෙන්වන්න"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -1807,5 +1807,7 @@
"Word": "சொல்",
"Sentence": "வாக்கியம்",
"Granularity": "நுண்மை",
"Refresh Page": "பக்கத்தைப் புதுப்பி"
"Refresh Page": "பக்கத்தைப் புதுப்பி",
"Recently read": "சமீபத்தில் படித்தவை",
"Show recently read": "சமீபத்தில் படித்தவற்றைக் காட்டு"
}

View file

@ -1774,5 +1774,7 @@
"Word": "คำ",
"Sentence": "ประโยค",
"Granularity": "ระดับความละเอียด",
"Refresh Page": "รีเฟรชหน้า"
"Refresh Page": "รีเฟรชหน้า",
"Recently read": "อ่านล่าสุด",
"Show recently read": "แสดงรายการอ่านล่าสุด"
}

View file

@ -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"
}

View file

@ -1873,5 +1873,7 @@
"Word": "Слово",
"Sentence": "Речення",
"Granularity": "Деталізація",
"Refresh Page": "Оновити сторінку"
"Refresh Page": "Оновити сторінку",
"Recently read": "Нещодавно прочитані",
"Show recently read": "Показувати нещодавно прочитані"
}

View file

@ -1807,5 +1807,7 @@
"Word": "Soz",
"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"
}

View file

@ -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"
}

View file

@ -1774,5 +1774,7 @@
"Word": "单词",
"Sentence": "句子",
"Granularity": "粒度",
"Refresh Page": "刷新页面"
"Refresh Page": "刷新页面",
"Recently read": "最近阅读",
"Show recently read": "显示最近阅读"
}

View file

@ -1774,5 +1774,7 @@
"Word": "單字",
"Sentence": "句子",
"Granularity": "粒度",
"Refresh Page": "重新整理頁面"
"Refresh Page": "重新整理頁面",
"Recently read": "最近閱讀",
"Show recently read": "顯示最近閱讀"
}

View file

@ -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<Omit<Book, 'metadata'> & { metadata?: Partial<BookMetadata> }> = {},
): 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([]);
});
});

View file

@ -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<BookshelfListContext>['List'] = React.fo
));
BookshelfGridList.displayName = 'BookshelfGridList';
const BookshelfLinearList: Components['List'] = React.forwardRef<HTMLDivElement, ListProps>(
({ children, style, 'data-testid': testId }, ref) => (
<div ref={ref} data-testid={testId} className={BOOKSHELF_LIST_CLASSES} style={style}>
{children}
</div>
),
);
const BookshelfLinearList: Components<unknown, BookshelfListContext>['List'] = React.forwardRef<
HTMLDivElement,
ListProps
>(({ children, style, 'data-testid': testId }, ref) => (
<div ref={ref} data-testid={testId} className={BOOKSHELF_LIST_CLASSES} style={style}>
{children}
</div>
));
BookshelfLinearList.displayName = 'BookshelfLinearList';
const BookshelfHeader = ({ context }: { context?: BookshelfListContext }) => (
<>{context?.recentShelfHeader ?? null}</>
);
const GRID_VIRTUOSO_COMPONENTS: GridComponents<BookshelfListContext> = {
List: BookshelfGridList,
Header: BookshelfHeader,
Footer: () => <div style={{ height: 34 }} />,
};
const LIST_VIRTUOSO_COMPONENTS: Components = {
const LIST_VIRTUOSO_COMPONENTS: Components<unknown, BookshelfListContext> = {
List: BookshelfLinearList,
Header: BookshelfHeader,
Footer: () => <div style={{ height: 34 }} />,
};
@ -668,12 +685,63 @@ const Bookshelf: React.FC<BookshelfProps> = ({
// 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 ? (
<RecentShelf
books={recentBooks}
coverFit={coverFit as LibraryCoverFitType}
autoColumns={settings.libraryAutoColumns}
fixedColumns={settings.libraryColumns}
onOpenBook={openRecentBook}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
showBookDetailsModal={handleShowDetailsBook}
/>
) : null,
[
showRecentShelf,
recentBooks,
coverFit,
settings.libraryAutoColumns,
settings.libraryColumns,
openRecentBook,
handleBookUpload,
handleBookDownload,
handleShowDetailsBook,
],
);
const listContext = useMemo<BookshelfListContext>(
() => ({
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<BookshelfProps> = ({
/>
)}
{hasItems && !isGridMode && (
<Virtuoso
<Virtuoso<unknown, BookshelfListContext>
overscan={200}
totalCount={sortedBookshelfItems.length}
components={LIST_VIRTUOSO_COMPONENTS}
context={listContext}
computeItemKey={computeItemKey}
itemContent={renderBookshelfItem}
scrollerRef={handleScrollerRef}

View file

@ -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<BookshelfItemProps> = ({
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/<hash>/ 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/<hash>/
// 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(

View file

@ -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<RecentSlideProps> = ({
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 (
<div className='min-w-0 shrink-0' style={{ flexBasis: RECENT_SLIDE_WIDTH }}>
{/* Same chassis as the grid item (BookshelfItem grid branch) so the cover,
title, progress and badges render identically. */}
<div
className={clsx(
'visible-focus-inset-2 group flex h-full cursor-pointer select-none flex-col',
'sm:hover:bg-base-300/50 px-0 py-2 sm:rounded-md sm:px-4 sm:py-4',
pressing ? 'not-eink:scale-95' : 'scale-100',
)}
role='button'
tabIndex={0}
aria-label={book.title}
style={{ transition: 'transform 0.2s' }}
onKeyDown={handleKeyDown}
{...handlers}
>
<div className='flex h-full flex-col justify-end'>
<BookItem
mode='grid'
book={book}
coverFit={coverFit}
isSelectMode={false}
bookSelected={false}
transferProgress={null}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
showBookDetailsModal={showBookDetailsModal}
/>
</div>
</div>
</div>
);
};
/**
* 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<RecentShelfProps> = ({
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<HTMLDivElement>(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<number | null>(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 (
<div className='recent-shelf select-none pt-3'>
<h3 className='text-base-content/60 mb-1 ps-4 text-xs font-medium sm:ps-6'>
{_('Recently read')}
</h3>
<div className='relative'>
{/* Horizontal-only scroll; px insets + gap mirror the grid. */}
<div
ref={scrollerRef}
onScroll={updateArrows}
className='no-scrollbar overflow-x-auto overflow-y-hidden overscroll-x-contain px-4 sm:px-2'
>
<div
className={clsx('flex gap-x-4 sm:gap-x-0 [--rs-gap:1rem] sm:[--rs-gap:0px]', colsClass)}
style={colsStyle}
>
{books.map((book) => (
<RecentSlide
key={book.hash}
book={book}
coverFit={coverFit}
onOpenBook={onOpenBook}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
showBookDetailsModal={showBookDetailsModal}
/>
))}
</div>
</div>
{showLeft && (
<button
type='button'
aria-label={_('Scroll left')}
onClick={() => scrollByPage(-1)}
style={{ top: coverCenter ?? '50%' }}
className='eink-bordered bg-base-100 border-base-content/10 hover:border-base-content/30 absolute start-2 -translate-y-1/2 rounded-full border p-1 shadow-sm transition-colors duration-200'
>
<MdChevronLeft
size={20}
className='text-base-content/60 hover:text-base-content/80 rtl:rotate-180'
/>
</button>
)}
{showRight && (
<button
type='button'
aria-label={_('Scroll right')}
onClick={() => scrollByPage(1)}
style={{ top: coverCenter ?? '50%' }}
className='eink-bordered bg-base-100 border-base-content/10 hover:border-base-content/30 absolute end-2 -translate-y-1/2 rounded-full border p-1 shadow-sm transition-colors duration-200'
>
<MdChevronRight
size={20}
className='text-base-content/60 hover:text-base-content/80 rtl:rotate-180'
/>
</button>
)}
</div>
{/* Modern divider: an inset hairline with breathing room above and below
so it does not crowd the first shelf row. */}
<div aria-hidden='true' className='border-base-content/10 mx-4 mb-3 mt-4 border-t sm:mx-6' />
</div>
);
};
export default RecentShelf;

View file

@ -109,6 +109,14 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ 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<ViewMenuProps> = ({ setIsDropdownOpen }) => {
/>
))}
{/* Recently read shelf */}
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem
label={_('Show recently read')}
buttonClass='h-8'
toggled={settings.libraryRecentShelfEnabled}
onClick={handleToggleRecentShelf}
transient
/>
{/* Group By - Collapsible */}
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem label={_('Group by...')} detailsOpen={true} buttonClass='py-[4px]'>

View file

@ -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<SetStateAction<boolean>>;
handleBookDownload: (
book: Book,
options?: { redownload?: boolean; queued?: boolean },
) => Promise<boolean>;
}
/**
* 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/<hash>/ 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 };
};

View file

@ -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

View file

@ -150,6 +150,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
libraryCoverFit: 'crop',
libraryAutoColumns: true,
libraryColumns: 6,
libraryRecentShelfEnabled: false,
metadataSeriesCollapsed: false,
metadataOthersCollapsed: false,

View file

@ -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