diff --git a/docs/ui.md b/docs/ui.md index 198c2e3..5d5edfe 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -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` 或 `
` 下拉。 - 章节书的目录:条目带 `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。 diff --git a/web/src/features/sources/components/SourceExplore.tsx b/web/src/features/sources/components/SourceExplore.tsx new file mode 100644 index 0000000..02a2238 --- /dev/null +++ b/web/src/features/sources/components/SourceExplore.tsx @@ -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([]); + const [page, setPage] = useState(); + const [busy, setBusy] = useState(false); + const [more, setMore] = useState(false); + const [error, setError] = useState(''); + const [filter, setFilter] = useState(''); + const [detail, setDetail] = useState(null); + const [acquired, setAcquired] = useState>({}); + 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

还没有支持发现的书源。添加并启用带发现规则的书源库后,就能在这里按分类浏览。

; + const links = page?.navigation ?? [], shown = links.filter(link => link.title.toLocaleLowerCase().includes(filter.trim().toLocaleLowerCase())); + return <> +
+ ({ value: source.id, label: source.name + (source.isDefault ? '(默认)' : '') }))} onChange={setSourceId} /> +
+
+ + {error && setError('')}>{error}} + {busy && !page &&

正在加载…

} + {links.length > 16 && setFilter(event.currentTarget.value)} />} + {links.length > 0 &&
{shown.map(link => )} + {!shown.length &&

没有匹配的项

}
} + {page && page.items.length > 0 &&
{page.items.map(entry => { const option = entry.options?.[0], bookId = acquired[entry.ref]; return
+ +
{entry.title}{entry.authors?.join(' / ') || '作者未知'}{entry.latestChapter ? ` · ${entry.latestChapter}` : ''}{entry.description &&

{entry.description}

}
+
{bookId + ? + : }
+
; })}
} + {page && !busy && !links.length && !page.items.length &&
这里还没有内容

换一个分类或书源试试。

} + {page?.nextCursor &&
} +
+ {detail && setDetail(null)} />} + ; +} diff --git a/web/src/features/sources/pages/SourcesPage.tsx b/web/src/features/sources/pages/SourcesPage.tsx index 2b4b71d..d3ef3f3 100644 --- a/web/src/features/sources/pages/SourcesPage.tsx +++ b/web/src/features/sources/pages/SourcesPage.tsx @@ -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