feat(web): shelf and library paint a cover-grid skeleton while loading

Both screens showed an empty page with a floating "正在加载…" until the
first response. The .book-grid.is-skeleton styles (same grid and density as
the real one, a shimmer that goes flat under reduced motion) were already in
reader.css from the legacy shelf but nothing rendered them; BookGridSkeleton
does, so the first paint fills in instead of jumping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hectorqinandClaude Opus 5.5 committed 2026-10-22 13:22:17 +08:00
1 parent a634e9e955
commit 7fbf9dbbf0
3 files changed
+21 -2

No files matched your search

@@ -1,5 +1,6 @@
import { BookOpen, FileText, Folder, Plus, Search, Upload } from 'lucide-react';
import { coverTint } from '../../../ui/cover-tint.ts';
import { BookGridSkeleton } from '../../../ui/book-grid-skeleton.tsx';
import { ActionMenu } from '../../../ui/action-menu.tsx';
import { Callout, Icon } from '../../../ui/toolkit.tsx';
import { useQuery, useQueryClient } from '@tanstack/react-query';
@@ -49,7 +50,7 @@ export function LibraryBrowsePage() {
{listing.data && listing.data.crumbs.length > 1 && <div className="manager-crumbs library-path" aria-label="当前文件夹">{listing.data.crumbs.map((crumb, index) => <span key={`${crumb.path}-${index}`}>{index > 0 && <span className="manager-crumb-sep">/</span>}<button type="button" className="manager-crumb" onClick={() => navigate(crumb.path)}>{crumb.name}</button></span>)}</div>}
<main className="manager-body library-browse-body">
<form className="shelf-search library-search" role="search" onSubmit={(event: FormEvent) => { event.preventDefault(); navigate(path, 1, draft.trim()); }}><Search className="search-glyph" size={18} aria-hidden="true" /><input type="search" aria-label="搜索书库" placeholder="在书库中搜索书名、作者" value={draft} onChange={event => setDraft(event.currentTarget.value)} />{draft && <button type="button" className="search-clear" aria-label="清除搜索" onClick={() => { setDraft(''); navigate(path); }}>×</button>}</form>
{books.isPending && <FloatingNotice message="正在加载书库…" busy />}{books.error && <Callout tone="error" title="书库加载失败">{books.error.message}</Callout>}
{books.isPending && <><FloatingNotice message="正在加载书库…" busy /><BookGridSkeleton className="library-browse-grid" density="cozy" /></>}{books.error && <Callout tone="error" title="书库加载失败">{books.error.message}</Callout>}
{empty && <div className="empty-state collection-empty"><BookOpen className="empty-glyph" aria-hidden="true" /><p>{search ? '没有匹配的书' : '书库还没有书'}</p><p className="muted">{search ? '换个关键词,或者检查一下作者名的写法。' : '前往文件管理添加书籍,再回到这里浏览。'}</p><div className="empty-actions">{search ? <button type="button" className="button" onClick={() => { setDraft(''); navigate(path); }}>清除搜索</button> : role === 'admin' ? <Link className="button primary" to="/library/files"><Folder size={16} />文件管理</Link> : null}</div></div>}
{books.data && books.data.items.length > 0 && <div className="book-grid library-browse-grid" data-density="cozy" data-show-author="true">{books.data.items.map(book => <div className="book-card is-row" key={book.id}><Link className="book-open" to={`/book/${encodeURIComponent(book.id)}`} aria-label={`${book.title} ${book.author ?? ''}`}><Cover book={book} /><div className="title">{book.title || '未命名'}</div><div className="author">{book.author || '未知作者'}</div></Link>{book.shelfState === 'off' && <button type="button" className="book-shelve" aria-label={`把${book.title}加入书架`} disabled={busy === book.id} onClick={() => void toggle(book)}>+ 加入书架</button>}</div>)}</div>}
{books.data && <div className="manager-status muted">{search ? `找到 ${count} 本` : `${count} 本 · 第 ${page} / ${pages} 页`}</div>}
+2 -1
View File
@@ -1,5 +1,6 @@
import { useQueryClient } from '@tanstack/react-query';
import { coverTint } from '../../../ui/cover-tint.ts';
import { BookGridSkeleton } from '../../../ui/book-grid-skeleton.tsx';
import { Link, useSearchParams } from 'react-router-dom';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { BookOpen, LibraryBig, MoreHorizontal, Search } from 'lucide-react';
@@ -143,7 +144,7 @@ export function ShelfPage() {
</div>
<FloatingNotice message={actionError} error />
{(!query.isPending && (cards.length > 0 || queryText || filtered)) && <div className="shelf-toolbar"><span className="shelf-count muted">{queryText ? `找到 ${query.data?.total ?? 0} 本` : `共 ${query.data?.total ?? 0} 本`}</span><div className="shelf-sort" role="group" aria-label="排序方式">{SHELF_SORTS.map(option => <button key={option.value} type="button" className="chip" aria-pressed={option.value === sort} onClick={() => onSort(option.value)}>{option.label}</button>)}</div></div>}
{query.isPending && <FloatingNotice message="正在加载书架…" busy />}
{query.isPending && <><FloatingNotice message="正在加载书架…" busy /><BookGridSkeleton density={settings.shelfDensity} /></>}
{query.error && <Callout tone="error" title="书架加载失败">{query.error.message}</Callout>}
{query.data && cards.length === 0 && filtered && !queryText && <div className="empty-state collection-empty"><BookOpen className="empty-glyph" aria-hidden="true" /><p>{status ? `还没有${readingLabels[status]}的书` : '这个分组里没有书'}</p><p className="muted">{status === 'want' ? '在书籍的“⋯”菜单里可以标记想读。' : '换一个分类,或查看全部书籍。'}</p><div className="empty-actions"><button type="button" className="button" onClick={clearFilters}>查看全部书籍</button></div></div>}
{query.data && cards.length === 0 && !(filtered && !queryText) && <div className="empty-state collection-empty"><BookOpen className="empty-glyph" aria-hidden="true" /><p>{queryText ? '没有匹配的书' : '书架还没有书'}</p><p className="muted">{queryText ? '换个关键词,或者检查一下作者名的写法' : '从书库挑选喜欢的书,或通过书源搜索,加入书架后就能开始阅读。'}</p><div className="empty-actions">{queryText ? <button type="button" className="button" onClick={clearSearch}>清除搜索</button> : <><Link className="button primary" to="/sources"><Search size={16} />去搜书</Link><Link className="button" to="/library"><LibraryBig size={16} />打开书库</Link></>}</div></div>}
+17
View File
@@ -0,0 +1,17 @@
/**
* The shelf's shape before the books arrive (styles: `.book-grid.is-skeleton`).
*
* Same grid, same density as the real one, so the first paint is filled in rather
* than replaced: a skeleton in a different grid makes the reader watch the layout
* move twice. Hidden from assistive tech — the FloatingNotice beside it announces
* the load.
*/
export function BookGridSkeleton({ count = 12, density, className }: { count?: number; density?: string; className?: string }) {
return <div className={`book-grid is-skeleton${className ? ` ${className}` : ''}`} data-density={density} aria-hidden="true">
{Array.from({ length: count }, (_, index) => <div className="book-card" key={index}>
<div className="cover" />
<span className="skeleton-line" />
<span className="skeleton-line short" />
</div>)}
</div>;
}