mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 22:41:06 +08:00
feat(web): 发现 is its own tab with a trail, category chips and a cover list
Browsing shared the 搜书 card: a row of plain buttons (back and paging among the categories) above the search results area. It now has its own tab, shown when a source can be browsed: a breadcrumb of the levels walked, the levels below as wrapping chips with a filter for long lists, and books as a cover list that grows with 加载更多. 搜书 only searches. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
4e9c9b7c30
commit
9875252d48
4 files changed
+143
-17
No files matched your search
@@ -537,6 +537,7 @@ SweetAlert、Mantine `Modal`、自绘遮罩或角落浮动卡片。
|
||||
- 确认框焦点默认在「取消」;破坏性操作传 `danger`,按钮用危险色。
|
||||
- `⋯` 操作菜单和单选筛选(收藏类型等)用 `web/src/ui/action-menu.tsx` 的 `ActionMenu`:从触发按钮下方弹出的下拉菜单(手机和桌面一致),没有标题栏和遮罩,选中、Esc、Tab 或点外部即关闭,支持方向键;样式在 `web/src/styles/action-menu.css`。需要以问题形式出现的单选(书架的选择对话框)用 `ActionSheet`,即 compact Sheet 里的同一列菜单项。动作是数据(`label`、`onSelect`、`danger`、`checked`、`description`——条目下的一行说明,经 `aria-describedby` 读出),菜单先关再执行动作,所以动作可以再开一个 Sheet 或跳转。不再使用 Mantine `Menu` 或 `<details>` 下拉。
|
||||
- 章节书的目录:条目带 `volume` 时,在该卷第一章上方画一行卷标题(`.toc-volume`,不可点、不计入章节数);带 `note` 时在章节名下显示一行小字(`.toc-note`,如更新时间)。
|
||||
- 书源页的“发现”是单独的标签页(`features/sources/components/SourceExplore.tsx`),有可浏览的来源时才出现,不再和搜书共用一块区域。一个页面是一层:面包屑是走过的层级,点任意一级返回;下一层的分组、书源、分类画成可换行的圆角按钮,超过 16 个时出现筛选框;书籍是带封面的列表,来源给出 `nextCursor` 时底部有“加载更多”,追加而不是翻页。返回和翻页由页面负责,来源只描述当前这一层。
|
||||
- 来源封面用 `ui/lazy-cover.tsx` 的 `LazyCover`:进入视口后才经 API 取图,固定 3:4 占位。搜书结果每本书显示第一个带封面的书源的封面。书架上每本书的 ⋯ 菜单都有“更换封面”,打开 `ui/change-cover-sheet.tsx`,列出书源中有封面的版本,点选即保存。来自书源的书(`format: 'chapters'`)按书名和作者在其它书源中查找;本地书在读者选的书源库里按书名搜索,只列出书名相同的结果,并可“恢复文件自带的封面”。
|
||||
- 锚定在控件上的只有 `ActionMenu` 下拉、选择框(`MediaSelect`、`SearchableSelect`)和正文选区工具条,它们不是 Sheet。
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { SourceEntry, SourceInstance, SourcePage } from '../../../api/sources.ts';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { LazyCover } from '../../../ui/lazy-cover.tsx';
|
||||
import { notify } from '../../../ui/notifications.ts';
|
||||
import { SearchableSelect } from '../../../ui/searchable-select.tsx';
|
||||
import { Button, Callout, Icon } from '../../../ui/toolkit.tsx';
|
||||
import { SourceBookDetailSheet } from './SourceBookDetailSheet.tsx';
|
||||
|
||||
/** One step of the trail: the page a crumb leads back to. */
|
||||
interface Crumb { ref?: string; title: string }
|
||||
|
||||
const coverUrl = (sourceId: string, entry: SourceEntry) => entry.coverRef
|
||||
? `/api/v1/sources/${encodeURIComponent(sourceId)}/cover?entryRef=${encodeURIComponent(entry.ref)}&coverRef=${encodeURIComponent(entry.coverRef)}` : null;
|
||||
|
||||
/**
|
||||
* 发现: walk what a source offers without a keyword.
|
||||
*
|
||||
* A page is one level: the levels below it (groups, sources, categories) are chips,
|
||||
* and the books on it are a list that grows with 加载更多. The trail of levels is a
|
||||
* breadcrumb, so going back is a tap on where you were rather than a button a
|
||||
* source has to supply.
|
||||
*/
|
||||
export function SourceExplore({ sources }: { sources: SourceInstance[] }) {
|
||||
const runtime = useRuntime(), navigate = useNavigate(), queryClient = useQueryClient();
|
||||
const [sourceId, setSourceId] = useState('');
|
||||
const selected = sources.find(source => source.id === sourceId) ?? sources.find(source => source.isDefault) ?? sources[0];
|
||||
const selectedId = selected?.id ?? '';
|
||||
const [trail, setTrail] = useState<Crumb[]>([]);
|
||||
const [page, setPage] = useState<SourcePage>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [more, setMore] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [detail, setDetail] = useState<SourceEntry | null>(null);
|
||||
const [acquired, setAcquired] = useState<Record<string, string>>({});
|
||||
const epoch = useRef(0);
|
||||
const open = async (crumbs: Crumb[], ref?: string) => {
|
||||
if (!selectedId) return;
|
||||
const run = ++epoch.current;
|
||||
setBusy(true); setError(''); setFilter('');
|
||||
try {
|
||||
const next = await runtime.api.sourceCatalog(selectedId, ref ? { ref } : {});
|
||||
if (epoch.current !== run) return;
|
||||
setPage(next); setTrail([...crumbs, { ...(ref ? { ref } : {}), title: next.title || (crumbs.length ? '…' : '发现') }]);
|
||||
} catch (reason) { if (epoch.current === run) setError(reason instanceof Error ? reason.message : '加载失败,请重试'); }
|
||||
finally { if (epoch.current === run) setBusy(false); }
|
||||
};
|
||||
const current = trail.at(-1);
|
||||
const loadMore = async () => {
|
||||
if (!page?.nextCursor || more || !selectedId) return;
|
||||
const run = epoch.current;
|
||||
setMore(true); setError('');
|
||||
try {
|
||||
const next = await runtime.api.sourceCatalog(selectedId, { ...(current?.ref ? { ref: current.ref } : {}), cursor: page.nextCursor });
|
||||
if (epoch.current !== run) return;
|
||||
const seen = new Set(page.items.map(entry => entry.ref));
|
||||
const { nextCursor: _previous, ...rest } = page, cursor = next.items.length ? next.nextCursor : undefined;
|
||||
setPage({ ...rest, items: [...page.items, ...next.items.filter(entry => !seen.has(entry.ref))], ...(cursor ? { nextCursor: cursor } : {}) });
|
||||
} catch (reason) { if (epoch.current === run) setError(reason instanceof Error ? reason.message : '加载失败,请重试'); }
|
||||
finally { setMore(false); }
|
||||
};
|
||||
useEffect(() => {
|
||||
++epoch.current; setPage(undefined); setTrail([]); setError(''); setDetail(null); setAcquired({});
|
||||
if (selectedId) void open([]);
|
||||
return () => { ++epoch.current; };
|
||||
}, [selectedId]);
|
||||
const detailQuery = useQuery({ queryKey: ['source-detail', runtime.api.baseUrl, selectedId, detail?.ref], queryFn: () => runtime.api.sourceDetail(selectedId, detail!.ref), enabled: !!detail });
|
||||
useEffect(() => { if (detail && detailQuery.data) setDetail({ ...detail, ...detailQuery.data }); }, [detailQuery.data]);
|
||||
const acquire = useMutation({ mutationFn: ({ ref, option }: { ref: string; option?: string }) => runtime.api.acquireSource(selectedId, ref, option), onError: reason => notify(reason instanceof Error ? reason.message : '加入书架失败', { kind: 'error' }) });
|
||||
const acquireEntry = (entry: SourceEntry, option?: string) => acquire.mutate(option === undefined ? { ref: entry.ref } : { ref: entry.ref, option }, { onSuccess: result => {
|
||||
if (result.kind === 'action-required') notify(result.action?.label ?? '请先完成授权', { kind: 'error' });
|
||||
else if (result.publicationId) { setAcquired(previous => ({ ...previous, [entry.ref]: result.publicationId! })); notify(`已加入书架:《${entry.title}》`, { kind: 'success' }); void queryClient.invalidateQueries({ queryKey: ['shelf'] }); }
|
||||
} });
|
||||
if (!sources.length) return <section className="sources-card"><p className="muted">还没有支持发现的书源。添加并启用带发现规则的书源库后,就能在这里按分类浏览。</p></section>;
|
||||
const links = page?.navigation ?? [], shown = links.filter(link => link.title.toLocaleLowerCase().includes(filter.trim().toLocaleLowerCase()));
|
||||
return <>
|
||||
<section className="sources-card source-picker">
|
||||
<SearchableSelect label="选择来源" value={selectedId} options={sources.map(source => ({ value: source.id, label: source.name + (source.isDefault ? '(默认)' : '') }))} onChange={setSourceId} />
|
||||
</section>
|
||||
<section className="sources-card explore" aria-busy={busy}>
|
||||
<nav className="explore-trail" aria-label="当前位置">{trail.map((crumb, index) => index === trail.length - 1
|
||||
? <strong key={index} aria-current="page">{crumb.title}</strong>
|
||||
: <span key={index}><button type="button" disabled={busy} onClick={() => void open(trail.slice(0, index), crumb.ref)}>{crumb.title}</button><Icon name="chevron-right" size={14} /></span>)}
|
||||
{!trail.length && <strong>发现</strong>}
|
||||
</nav>
|
||||
{error && <Callout tone="error" onDismiss={() => setError('')}>{error}</Callout>}
|
||||
{busy && !page && <p className="muted" role="status">正在加载…</p>}
|
||||
{links.length > 16 && <input className="explore-filter" type="search" aria-label="筛选" placeholder={`在 ${links.length} 项中筛选`} value={filter} onChange={event => setFilter(event.currentTarget.value)} />}
|
||||
{links.length > 0 && <div className="explore-links">{shown.map(link => <button type="button" className="explore-link" key={link.ref} disabled={busy} onClick={() => void open(trail, link.ref)}>{link.title}</button>)}
|
||||
{!shown.length && <p className="muted">没有匹配的项</p>}</div>}
|
||||
{page && page.items.length > 0 && <div className="explore-books">{page.items.map(entry => { const option = entry.options?.[0], bookId = acquired[entry.ref]; return <article className="explore-book" key={entry.ref}>
|
||||
<LazyCover url={coverUrl(selectedId, entry)} title={entry.title} />
|
||||
<div className="explore-book-text"><strong>{entry.title}</strong><small>{entry.authors?.join(' / ') || '作者未知'}{entry.latestChapter ? ` · ${entry.latestChapter}` : ''}</small>{entry.description && <p>{entry.description}</p>}</div>
|
||||
<div className="explore-book-actions"><Button onClick={() => setDetail(entry)}>详情</Button>{bookId
|
||||
? <Button className="primary" onClick={() => navigate(`/book/${encodeURIComponent(bookId)}`)}>阅读</Button>
|
||||
: <Button className="primary" disabled={option?.available === false || acquire.isPending} onClick={() => acquireEntry(entry, option?.id)}>{option?.label ?? '加入书架'}</Button>}</div>
|
||||
</article>; })}</div>}
|
||||
{page && !busy && !links.length && !page.items.length && <div className="catalog-empty"><strong>这里还没有内容</strong><p>换一个分类或书源试试。</p></div>}
|
||||
{page?.nextCursor && <div className="catalog-pagination"><button className="button" type="button" disabled={more || busy} onClick={() => void loadMore()}>{more ? '正在加载…' : '加载更多'}</button></div>}
|
||||
</section>
|
||||
{detail && <SourceBookDetailSheet detail={detail} pending={detailQuery.isPending} error={detailQuery.error} acquiring={acquire.isPending} onAcquire={acquireEntry} onClose={() => setDetail(null)} />}
|
||||
</>;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Sheet } from '../../../ui/sheet.tsx';
|
||||
import { ActionMenu } from '../../../ui/action-menu.tsx';
|
||||
import { notify } from '../../../ui/notifications.ts';
|
||||
import { LazyCover } from '../../../ui/lazy-cover.tsx';
|
||||
import { SourceExplore } from '../components/SourceExplore.tsx';
|
||||
|
||||
const sourceCoverUrl = (sourceId: string, entry: SourceEntry) => entry.coverRef
|
||||
? `/api/v1/sources/${encodeURIComponent(sourceId)}/cover?entryRef=${encodeURIComponent(entry.ref)}&coverRef=${encodeURIComponent(entry.coverRef)}` : null;
|
||||
@@ -24,8 +25,8 @@ import { groupSourceResults } from '../../../ui/source-results.ts';
|
||||
import { CatalogFeedback } from '../../../ui/catalog-feedback.tsx';
|
||||
import { PluginManager } from '../components/PluginManager.tsx';
|
||||
|
||||
type SourceTab = 'search' | 'sources' | 'updates' | 'plugins';
|
||||
const SOURCE_TABS: SourceTab[] = ['search', 'sources', 'updates', 'plugins'];
|
||||
type SourceTab = 'search' | 'explore' | 'sources' | 'updates' | 'plugins';
|
||||
const SOURCE_TABS: SourceTab[] = ['search', 'explore', 'sources', 'updates', 'plugins'];
|
||||
type SearchState = 'idle' | 'searching' | 'stopped' | 'complete' | 'error' | 'limited';
|
||||
|
||||
export function SourcesPage() {
|
||||
@@ -80,14 +81,6 @@ export function SourcesPage() {
|
||||
};
|
||||
useEffect(() => { if (!sourceId && selectedSourceId) setSourceId(selectedSourceId); }, [selectedSourceId, sourceId]);
|
||||
const sourceTypeLabel = (id: string) => types.data?.find(item => item.id === id)?.label ?? id;
|
||||
const loadCatalog = async (ref?: string) => {
|
||||
if (!selectedSourceId || !selectedSource?.descriptor?.capabilities.includes('browse')) return;
|
||||
const epoch = ++catalogEpoch.current;
|
||||
setCatalogBusy(true); setCatalogError(''); setSearchState('idle'); setResultGroupKey(null); setAcquired(null);
|
||||
try { const page = await runtime.api.sourceCatalog(selectedSourceId, ref ? { ref } : {}); if (catalogEpoch.current === epoch) setCatalogPage(page); }
|
||||
catch (reason) { if (catalogEpoch.current === epoch) setCatalogError(reason instanceof Error ? reason.message : '目录加载失败'); }
|
||||
finally { if (catalogEpoch.current === epoch) setCatalogBusy(false); }
|
||||
};
|
||||
const stopSearch = () => { searchRun.current?.abort(); searchRun.current = null; setCatalogBusy(false); setSearchState('stopped'); };
|
||||
const searchSources = async (append = false) => {
|
||||
const request = append ? searchRequest.current : { query: keyword.trim(), filters: { ...catalogFilters } };
|
||||
@@ -115,11 +108,12 @@ export function SourcesPage() {
|
||||
++catalogEpoch.current; searchRun.current?.abort(); searchRun.current = null;
|
||||
setCatalogBusy(false); setCatalogPage(undefined); setCatalogError(''); setCatalogFilters({}); setDetail(null); searchSession.current = null;
|
||||
setSearchState('idle'); setKeyword(''); setResultGroupKey(null); setResultGroupQuery(''); setSourceNameQuery(''); searchRequest.current = null;
|
||||
if (selectedSource?.descriptor?.capabilities.includes('browse')) void loadCatalog();
|
||||
return () => { ++catalogEpoch.current; searchRun.current?.abort(); searchRun.current = null; };
|
||||
}, [selectedSourceId]);
|
||||
useEffect(() => { if (detail && detailQuery.data) setDetail({ ...detail, ...detailQuery.data }); }, [detailQuery.data]);
|
||||
const tabList = ([['search', '搜书'], ['sources', '书源管理'], ['updates', '自动追更'], ...(admin ? [['plugins', '插件管理']] : [])] as Array<[SourceTab, string]>);
|
||||
// 发现 appears once a source can be browsed; it is a place of its own, not a mode of 搜书.
|
||||
const exploreSources = enabledSources.filter(source => source.descriptor?.capabilities.includes('browse') && source.pluginId !== 'reader.local');
|
||||
const tabList = ([['search', '搜书'], ...(exploreSources.length || tab === 'explore' ? [['explore', '发现']] : []), ['sources', '书源管理'], ['updates', '自动追更'], ...(admin ? [['plugins', '插件管理']] : [])] as Array<[SourceTab, string]>);
|
||||
return <div className="sources-screen sources-hub">
|
||||
<SourcesHeader title="书源与追更" backTo="/shelf" />
|
||||
<nav className="sources-tabs" role="tablist" aria-label="书源功能">
|
||||
@@ -139,11 +133,11 @@ export function SourcesPage() {
|
||||
<section className="sources-card source-picker">
|
||||
{/* The tab already says 搜书; with no source there is nothing to pick, so no disabled picker either. */}
|
||||
{enabledSources.length > 0 && <SearchableSelect label="选择来源" value={selectedSourceId} options={enabledSources.map(source => ({ value: source.id, label: source.name + (source.isDefault ? '(默认)' : '') }))} onChange={setSourceId} />}
|
||||
{!selectedSourceId && <p className="muted">{enabledSources.length ? '选择来源后,即可搜索书籍或浏览目录。' : sources.isPending ? '正在读取来源…' : '还没有可用的书源。添加或启用一个书源后,就能在这里搜书。'}</p>}
|
||||
{!selectedSourceId && <p className="muted">{enabledSources.length ? '选择来源后,即可搜索书籍。' : sources.isPending ? '正在读取来源…' : '还没有可用的书源。添加或启用一个书源后,就能在这里搜书。'}</p>}
|
||||
{/* An empty state names its way out: the fix for "no source" lives on the next tab. */}
|
||||
{!selectedSourceId && !enabledSources.length && !sources.isPending && <IconTextButton className="primary source-picker-cta" icon="plus" label="前往书源管理" onClick={() => setTab('sources')} />}
|
||||
</section>
|
||||
{selectedSource && <section className="sources-card source-catalog"><h2>搜索与浏览</h2>
|
||||
{selectedSource && <section className="sources-card source-catalog"><h2>搜索书籍</h2>
|
||||
{catalogError && <Callout tone="error" onDismiss={() => setCatalogError('')}>{catalogError}</Callout>}
|
||||
{selectedSource.descriptor?.capabilities.includes('search') && <form className="sources-search" onSubmit={event => { event.preventDefault(); void searchSources(resume); }}>
|
||||
{sourceFilters.data?.filter(field => field.type === 'select').map(field => <SearchableSelect key={field.key} label={field.label} value={catalogFilters[field.key] ?? ''} disabled={catalogBusy} options={filterOptions(field)} onChange={value => setCatalogFilters(previous => { const next = { ...previous, [field.key]: value }; for (const dependent of sourceFilters.data ?? []) { if ((dependent.changeAction ?? dependent.dependsOn) === field.key && !dependent.options?.some(option => option.value === next[dependent.key] && (!option.parentValues || !value || option.parentValues.includes(value)))) next[dependent.key] = ''; } return next; })} />)}
|
||||
@@ -156,15 +150,14 @@ export function SourcesPage() {
|
||||
{searchState !== 'idle' && <div className="search-progress" role="status"><span>{({ searching: '正在搜索', stopped: '已停止搜索', complete: '搜索完成', error: '搜索中断', limited: '已达到结果上限' })[searchState]}{catalogPage?.batch ? ` · 已检查 ${catalogPage.batch.completed} / ${catalogPage.batch.total} 个来源` : ''} · 已找到 {results.length} 本书</span>{catalogPage?.batch && <progress aria-label="书源搜索进度" max={Math.max(1, catalogPage.batch.total)} value={catalogPage.batch.completed} />}</div>}
|
||||
{catalogPage && grouped && <label className="catalog-group-filter">筛选分组<input type="search" placeholder="搜索书名或作者" value={resultGroupQuery} onChange={event => setResultGroupQuery(event.currentTarget.value)} /></label>}
|
||||
{catalogPage && <CatalogFeedback page={catalogPage} count={results.length} merged={searchState !== 'idle'} searching={searchState === 'searching'} />}
|
||||
{searchState === 'idle' && catalogPage?.title && <p className="notice">{catalogPage.title}</p>}
|
||||
{catalogPage?.navigation && <div className="source-navigation">{catalogPage.navigation.map(item => <Button key={item.ref} onClick={() => void loadCatalog(item.ref)}>{item.title}</Button>)}</div>}
|
||||
{catalogBusy && !catalogPage && <FloatingNotice message="正在加载目录…" busy />}
|
||||
{catalogPage && <div className="source-results">{results.filter(group => { const q = resultGroupQuery.trim().toLocaleLowerCase(); return !q || group.entry.title.toLocaleLowerCase().includes(q) || group.entry.authors?.some(author => author.toLocaleLowerCase().includes(q)); }).map(group => <article className="sources-row catalog-book" key={group.key}><div className="catalog-book-main"><LazyCover url={sourceCoverUrl(selectedSourceId, group.entries.find(entry => entry.coverRef) ?? group.entry)} title={group.entry.title} /><div><strong>{group.entry.title}</strong><small>{group.entry.authors?.join(' / ') || '作者未知'}</small>{!grouped && group.entry.description && <p className="source-description">{group.entry.description}</p>}</div></div>{grouped ? <button className="button catalog-source-count" type="button" onClick={() => setResultGroupKey(group.key)}><span>{group.entries.length} 条书源</span><Icon name="chevron-right" size={16} /></button> : <div className="sources-actions"><button className="button" type="button" onClick={() => setDetail(group.entry)}>详情</button>{(group.entry.options?.length ? group.entry.options : [{ id: '', label: '加入书架' }]).map(option => <button className="button primary" type="button" key={option.id} disabled={option.available === false || acquire.isPending} onClick={() => acquireEntry(group.entry, option.id)}>{option.label}</button>)}</div>}</article>)}</div>}
|
||||
{catalogPage && !catalogBusy && !catalogPage.items.length && !catalogPage.navigation?.length && <div className="catalog-empty"><strong>没有找到匹配书籍</strong><p>试试其他关键词,或调整搜索范围。</p></div>}
|
||||
{catalogPage && !catalogBusy && !catalogPage.items.length && <div className="catalog-empty"><strong>没有找到匹配书籍</strong><p>试试其他关键词,或调整搜索范围。</p></div>}
|
||||
{searchState === 'complete' && catalogPage?.nextCursor && <div className="catalog-pagination"><button className="button" type="button" disabled={!canResume} onClick={() => void searchSources(true)}>加载更多结果</button></div>}
|
||||
{acquired && <button className="button" type="button" onClick={() => navigate(`/book/${encodeURIComponent(acquired.id)}`)}>阅读《{acquired.title}》</button>}
|
||||
</section>}
|
||||
</>}
|
||||
{tab === 'explore' && <SourceExplore sources={exploreSources} />}
|
||||
{tab === 'sources' && <section className="sources-card sources-list">
|
||||
<div className="sources-list-heading"><h2>我的来源 <span className="source-count">{sources.data?.length ?? 0}</span></h2>{admin && <Link className="button source-add" to="/sources/manage/new"><Icon name="plus" size={16} />添加来源</Link>}</div>
|
||||
{sources.isPending && <FloatingNotice message="正在加载书源…" busy />}{sources.error && <p className="notice error">{describeError(sources.error)}</p>}{!sources.isPending && !sources.data?.length && <p className="muted">还没有配置书源。请添加来源或启用插件。</p>}
|
||||
|
||||
@@ -3721,6 +3721,32 @@ book-content {
|
||||
.catalog-empty > .icon { font-size:1.7rem; color:var(--reader-muted); }
|
||||
.catalog-empty p { margin:0; font-size:var(--text-ui); color:var(--reader-muted); line-height:1.7; }
|
||||
.catalog-book { padding:1.4rem 0; align-items:flex-start; }
|
||||
/* 发现: a trail of where you are, the levels below as chips, then books. */
|
||||
.explore-trail { display:flex; flex-wrap:wrap; align-items:center; gap:var(--space-1); margin-bottom:var(--space-3); font-size:var(--text-ui); color:var(--reader-muted); }
|
||||
.explore-trail span { display:inline-flex; align-items:center; gap:var(--space-1); }
|
||||
.explore-trail button { min-height:44px; padding:0 var(--space-1); border:0; background:transparent; color:var(--reader-accent); font:inherit; cursor:pointer; }
|
||||
.explore-trail button:disabled { opacity:.6; cursor:default; }
|
||||
.explore-trail strong { color:var(--reader-ink); font-size:var(--text-title); overflow-wrap:anywhere; }
|
||||
.explore-filter { width:100%; box-sizing:border-box; min-height:44px; margin-bottom:var(--space-3); padding:.6rem .8rem; border:1px solid var(--reader-line); border-radius:var(--radius-md); background:var(--reader-paper); color:var(--reader-ink); font:inherit; font-size:var(--text-title); }
|
||||
.explore-filter::-webkit-search-cancel-button, .explore-filter::-webkit-search-decoration { appearance:none; display:none; }
|
||||
.explore-links { display:flex; flex-wrap:wrap; gap:var(--space-2); }
|
||||
.explore-link { min-height:44px; max-width:100%; padding:.5rem .9rem; border:1px solid var(--reader-line); border-radius:var(--radius-pill); background:var(--reader-surface); color:var(--reader-ink); font:inherit; font-size:var(--text-ui); overflow-wrap:anywhere; cursor:pointer; }
|
||||
.explore-link:disabled { opacity:.6; cursor:default; }
|
||||
.explore-link:focus-visible, .explore-trail button:focus-visible { outline:var(--focus-ring); outline-offset:2px; }
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.explore-link:hover { border-color:var(--reader-accent); color:var(--reader-accent); }
|
||||
.explore-trail button:hover { text-decoration:underline; }
|
||||
}
|
||||
.explore-links + .explore-books { margin-top:var(--space-4); }
|
||||
.explore-book { display:grid; grid-template-columns:auto minmax(0, 1fr); gap:var(--space-2) var(--space-3); padding:var(--space-3) 0; border-top:1px solid var(--reader-line); }
|
||||
.explore-book:first-child { border-top:0; padding-top:0; }
|
||||
.explore-book .lazy-cover { width:4rem; grid-row:span 2; }
|
||||
.explore-book-text { min-width:0; }
|
||||
.explore-book-text strong { display:block; font-size:var(--text-title); overflow-wrap:anywhere; }
|
||||
.explore-book-text small { display:block; margin-top:.2rem; color:var(--reader-muted); font-size:var(--text-meta); overflow-wrap:anywhere; }
|
||||
.explore-book-text p { margin:.4rem 0 0; color:var(--reader-muted); font-size:var(--text-ui); line-height:1.6; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
|
||||
.explore-book-actions { grid-column:2; display:flex; gap:var(--space-2); justify-content:flex-end; }
|
||||
.explore-book-actions .button { min-height:44px; }
|
||||
.catalog-book-main { display:flex; align-items:flex-start; gap:var(--space-3); }
|
||||
.catalog-book-main > div { flex:1; min-width:0; }
|
||||
/* A source's cover: a fixed 3:4 frame, so rows do not jump when the image arrives. */
|
||||
|
||||
Reference in new issue
Block a user