mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 21:41:02 +08:00
fix(web): UI review round 1 — duplicate headers, overlays, empty states
- Library editor / permissions: hide the frame header while the form (which guards unsaved changes) draws its own; back labels say 返回媒体库管理. - Media catalog: empty state renders below the library toolbar it describes. - Video player: keep "elapsed / total" on phones instead of a dangling "/". - Library browse: the count line is a static footer, not a sticky overlay. - History: unplayable rows dim their play button and title. - Shelf: drop the stale .shelf-status footer rule that indented the filter chips; the subtitle no longer repeats the toolbar's count. - Media browse settings: switches instead of checkboxes; labels keep one line. - Sources: empty source picker links to 书源管理. - Side nav: hover only on fine pointers, lighter than the selected state. - Media tile titles match the shelf's weight. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dca100425a
commit
90e01fa518
11 files changed
+36
-21
No files matched your search
@@ -46,7 +46,11 @@
|
||||
.app-nav { order: -1; display: flex; flex-direction: column; width: var(--app-nav-width); padding: 24px 14px; border-right: 1px solid var(--nav-line); overflow-y: auto; }
|
||||
.app-nav-brand { padding: 0 14px 22px; font-family: 'Songti SC', 'Noto Serif SC', 'Source Han Serif SC', serif; font-size: 26px; font-weight: 700; color: var(--nav-accent); }
|
||||
.app-nav-item { display: flex; align-items: center; gap: 12px; height: 44px; padding: 0 14px; border-radius: 12px; font-size: 15px; color: var(--nav-ink); }
|
||||
.app-nav-item:hover { background: var(--color-surface-2); }
|
||||
/* ui.md §2.5: hover only where a pointer can hover, or a tap leaves the item looking selected. */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.app-nav-item:hover:not([aria-current='page']),
|
||||
.app-nav-subitem:hover:not([aria-current='page']) { background: color-mix(in srgb, var(--color-surface-2) 60%, transparent); }
|
||||
}
|
||||
.app-nav-item[aria-current='page'] { background: var(--nav-soft); color: var(--nav-accent); font-weight: 600; }
|
||||
.app-nav-sub { margin-top: 2px; }
|
||||
.app-nav-subitem { display: flex; align-items: center; gap: 12px; height: 38px; padding: 0 14px 0 46px; border-radius: 10px; font-size: 14px; color: var(--nav-muted); }
|
||||
|
||||
@@ -48,7 +48,7 @@ export function MediaLibraryEditor({ api, library, onSaved, onCancel }: {
|
||||
}
|
||||
};
|
||||
|
||||
return <section className="media-library-workspace"><header className="media-settings-heading"><button className="media-back-button" data-media-back aria-label="返回媒体库" disabled={saving} onClick={close}><ChevronLeft size={20} aria-hidden="true"/></button><h1>编辑媒体库</h1></header>
|
||||
return <section className="media-library-workspace"><header className="media-settings-heading"><button className="media-back-button" data-media-back aria-label="返回媒体库管理" title="返回媒体库管理" disabled={saving} onClick={close}><ChevronLeft size={20} aria-hidden="true"/></button><h1>编辑媒体库</h1></header>
|
||||
{leaving&&<ConfirmSheet danger title="放弃媒体库修改?" text="媒体库修改尚未保存,离开后将丢弃本次修改。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel();}}/>}
|
||||
<form className="media-form media-library-editor" aria-label="媒体库配置" onSubmit={event => { event.preventDefault(); void save(); }}>
|
||||
{error && <div role="alert" className="media-error">{error}{!configuration && <button type="button" onClick={() => setAttempt(value => value + 1)}>重试读取配置</button>}</div>}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function MediaPermissions({api,library,onSaved,onCancel}:{api:MediaApi;li
|
||||
catch(error){if(!controller.signal.aborted)setError(error instanceof ApiError&&error.kind==='offline'?'无法连接服务器,请检查连接。当前选择已保留,可以重新保存。':error instanceof ApiError&&error.code==='ADMIN_REQUIRED'?'需要管理员权限才能保存。当前选择已保留。':error instanceof Error?error.message:'保存失败');}
|
||||
finally {if(pending.current===controller)pending.current=null;if(!controller.signal.aborted)setSaving(false);}
|
||||
};
|
||||
return <section className="media-permissions-page"><header className="media-settings-heading"><button className="media-back-button" data-media-back aria-label="返回媒体库" disabled={saving} onClick={close}><ChevronLeft size={20} aria-hidden="true"/></button><h1>访问权限</h1></header>
|
||||
return <section className="media-permissions-page"><header className="media-settings-heading"><button className="media-back-button" data-media-back aria-label="返回媒体库管理" title="返回媒体库管理" disabled={saving} onClick={close}><ChevronLeft size={20} aria-hidden="true"/></button><h1>访问权限</h1></header>
|
||||
{leaving&&<ConfirmSheet danger title="放弃访问权限修改?" text="访问权限尚未保存,离开后将丢弃本次选择。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel();}}/>}
|
||||
<form className="media-form media-permissions-form" aria-label="媒体库访问权限" onSubmit={event=>{event.preventDefault();if(ready&&!saving)void save();}}>
|
||||
<div className="media-permissions-intro"><h2>{library.name}</h2><p>允许访问的用户共享作品资料,收藏与进度各自独立。</p></div>
|
||||
|
||||
@@ -88,8 +88,8 @@ export function MediaSettings({ scope, preferences, player, account, onSaved, on
|
||||
<option value="title-desc">名称降序</option>
|
||||
</MediaSelect>
|
||||
</label>
|
||||
<label className="media-settings-check"><span>首页显示最近续播</span><input type="checkbox" checked={draft.showContinue} onChange={event => change({ showContinue: event.currentTarget.checked })} /></label>
|
||||
<label className="media-settings-check"><span>列表工具栏显示来源库</span><input type="checkbox" checked={draft.showLibraryName} onChange={event => change({ showLibraryName: event.currentTarget.checked })} /></label>
|
||||
<label className="media-settings-check"><span>首页显示最近续播</span><input type="checkbox" role="switch" checked={draft.showContinue} onChange={event => change({ showContinue: event.currentTarget.checked })} /></label>
|
||||
<label className="media-settings-check"><span>列表工具栏显示来源库</span><input type="checkbox" role="switch" checked={draft.showLibraryName} onChange={event => change({ showLibraryName: event.currentTarget.checked })} /></label>
|
||||
<div className="media-settings-footer">
|
||||
<button type="button" onClick={() => { setDraft({ ...defaultMediaPreferences }); setSaved(false); }}>恢复默认值</button>
|
||||
<button className="media-primary" type="submit">保存偏好</button>
|
||||
|
||||
@@ -54,7 +54,6 @@ export function MediaCatalogPage() {
|
||||
})}</nav>
|
||||
</>}
|
||||
<QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />
|
||||
{emptyCatalog && <EmptyMediaLibrary channel={channel} hasLibraries={hasLibraries} admin={admin} category={current.label} onCreate={() => navigate(`/media/${channel}/settings/libraries/new`)} onManage={() => navigate(`/media/${channel}/settings/libraries`)} />}
|
||||
{!emptyCatalog && !playerActive && offset === 0 && (current.kind === 'video' || current.kind === 'album' || current.kind === 'audiobook') && <ContinuePlaying
|
||||
api={runtime.mediaApi}
|
||||
libraryId={selectedLibrary}
|
||||
@@ -80,6 +79,8 @@ export function MediaCatalogPage() {
|
||||
</details>
|
||||
</div>
|
||||
</div>}
|
||||
{/* After the toolbar: the empty state answers "this library/category has nothing", so the selector it refers to sits above it. */}
|
||||
{emptyCatalog && <EmptyMediaLibrary channel={channel} hasLibraries={hasLibraries} admin={admin} category={current.label} onCreate={() => navigate(`/media/${channel}/settings/libraries/new`)} onManage={() => navigate(`/media/${channel}/settings/libraries`)} />}
|
||||
{!emptyCatalog && query.data && <><MediaItemGrid items={query.data.items} channel={channel} />{query.data.total > 60 && <nav className="media-toolbar media-pagination" aria-label="分页">
|
||||
<button type="button" disabled={offset === 0 || query.isFetching} onClick={() => patch({ offset: offset >= 60 ? String(offset - 60) : '' })}>上一页</button>
|
||||
<span>{Math.floor(offset / 60) + 1} / {Math.ceil(query.data.total / 60)}</span>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function MediaLibraryEditPage() {
|
||||
const query = useQuery(librariesQuery(runtime));
|
||||
const library = query.data?.items.find(item => item.id === libraryId);
|
||||
const back = `/media/${channel}/settings/libraries`;
|
||||
return <AdminOnly title="编辑媒体库"><MediaPageFrame className="media-manager-workspace" title="编辑媒体库" backTo={back} backLabel="返回媒体库管理"><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{library && <MediaLibraryEditor api={runtime.mediaApi} library={library} onSaved={updated => { queryClient.setQueryData(librariesQuery(runtime).queryKey, (value: { items: Library[] } | undefined) => value ? { items: value.items.map(item => item.id === updated.id ? updated : item) } : value); navigate(back); }} onCancel={() => navigate(back)} />}{!query.isPending && !query.error && !library && <Callout tone="error">媒体库不存在。</Callout>}</MediaPageFrame></AdminOnly>;
|
||||
return <AdminOnly title="编辑媒体库"><MediaPageFrame className="media-manager-workspace" title="编辑媒体库" backTo={back} backLabel="返回媒体库管理" hideHeader={Boolean(library)}><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{library && <MediaLibraryEditor api={runtime.mediaApi} library={library} onSaved={updated => { queryClient.setQueryData(librariesQuery(runtime).queryKey, (value: { items: Library[] } | undefined) => value ? { items: value.items.map(item => item.id === updated.id ? updated : item) } : value); navigate(back); }} onCancel={() => navigate(back)} />}{!query.isPending && !query.error && !library && <Callout tone="error">媒体库不存在。</Callout>}</MediaPageFrame></AdminOnly>;
|
||||
}
|
||||
|
||||
export function MediaLibraryPermissionsPage() {
|
||||
@@ -43,7 +43,7 @@ export function MediaLibraryPermissionsPage() {
|
||||
const query = useQuery(librariesQuery(runtime));
|
||||
const library = query.data?.items.find(item => item.id === libraryId);
|
||||
const back = `/media/${channel}/settings/libraries`;
|
||||
return <AdminOnly title="访问权限"><MediaPageFrame className="media-manager-workspace" title="访问权限" backTo={back} backLabel="返回媒体库管理"><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{library && <MediaPermissions api={runtime.mediaApi} library={library} onSaved={access => { queryClient.setQueryData(librariesQuery(runtime).queryKey, (value: { items: Library[] } | undefined) => value ? { items: value.items.map(item => item.id === library.id ? { ...item, access } : item) } : value); navigate(back); }} onCancel={() => navigate(back)} />}{!query.isPending && !query.error && !library && <Callout tone="error">媒体库不存在。</Callout>}</MediaPageFrame></AdminOnly>;
|
||||
return <AdminOnly title="访问权限"><MediaPageFrame className="media-manager-workspace" title="访问权限" backTo={back} backLabel="返回媒体库管理" hideHeader={Boolean(library)}><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{library && <MediaPermissions api={runtime.mediaApi} library={library} onSaved={access => { queryClient.setQueryData(librariesQuery(runtime).queryKey, (value: { items: Library[] } | undefined) => value ? { items: value.items.map(item => item.id === library.id ? { ...item, access } : item) } : value); navigate(back); }} onCancel={() => navigate(back)} />}{!query.isPending && !query.error && !library && <Callout tone="error">媒体库不存在。</Callout>}</MediaPageFrame></AdminOnly>;
|
||||
}
|
||||
|
||||
|
||||
@@ -451,6 +451,9 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
|
||||
.media-history-row>button:first-child small {font-size:11px;color:var(--muted);opacity:1;margin-top:4px}
|
||||
.media-history-row>button:first-child .media-history-unavailable {color:var(--media-error-ink)}
|
||||
.media-screen .media-row>.media-history-play {display:grid;place-items:center;width:44px;min-width:44px;min-height:44px;padding:10px;background:transparent;color:var(--media-accent)}
|
||||
/* An unplayable row must not wear the accent: the disabled button reads as idle, not as "tap me". */
|
||||
.media-screen .media-row>.media-history-play:disabled {color:var(--muted);opacity:.35;cursor:not-allowed}
|
||||
.media-screen .media-history-row:has(.media-history-play:disabled) .media-personal-title {color:var(--muted)}
|
||||
|
||||
.media-screen .media-folder-row {margin:0;padding:0}
|
||||
.media-screen .media-folder-row>button {display:flex;align-items:center;gap:12px;width:100%;padding:14px 0;min-height:68px;background:transparent;border:0;border-radius:0}
|
||||
@@ -940,7 +943,8 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
|
||||
.media-video-stage .dplayer-host:fullscreen video,.media-video-stage .dplayer-host:-webkit-full-screen video {max-height:none;height:100%}
|
||||
.media-video-stage .dplayer-controller button {border:0;min-width:36px;min-height:36px}
|
||||
@media(max-width:600px){
|
||||
.media-video-stage .dplayer-host .dplayer-time .dplayer-dtime {display:none}
|
||||
/* Keep "elapsed / total": hiding only the total left a dangling "/". */
|
||||
.media-video-stage .dplayer-host .dplayer-time {font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.media-video-stage .dplayer-host .dplayer-volume {flex:0 0 auto;min-width:0;width:auto}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
.media-screen .media-browse-tools :is(button,select) {min-height:44px}
|
||||
.media-screen .media-grid {grid-template-columns:repeat(6,minmax(0,1fr));gap:20px}
|
||||
.media-screen .media-tile {padding:0;text-align:left;border-radius:0;min-width:0}
|
||||
.media-screen .media-tile strong {display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;font-size:13px;font-weight:550;line-height:1.5;margin-top:8px}
|
||||
/* Same weight as the shelf's .book-card .title, so a cover title reads the same in every channel. */
|
||||
.media-screen .media-tile strong {display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;font-size:13px;font-weight:520;line-height:1.5;margin-top:8px}
|
||||
.media-screen .media-tile small {display:block;font-size:11px;line-height:1.5;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.media-screen .media-cover {border-radius:7px;box-shadow:0 3px 8px #15211714}
|
||||
.media-screen .media-cover:not(.has-image):not(.media-portrait) {background:var(--surface);color:var(--muted);border:1px solid var(--border)}
|
||||
@@ -95,7 +96,15 @@
|
||||
.media-setting-row small {font-size:11px}
|
||||
.media-screen .media-settings-panel,.media-screen .media-form {max-width:760px;margin-inline:auto}
|
||||
.media-screen .media-setting-field,.media-screen .media-settings-check {min-height:56px;padding:12px 0;border-bottom:1px solid var(--border)}
|
||||
.media-screen .media-settings-check input {accent-color:var(--media-accent);width:20px;height:20px}
|
||||
/* Boolean preferences wear the same switch as the reader's sheets (.sheet .switch): one control for "on/off" app-wide. */
|
||||
.media-screen .media-settings-check input[role=switch] {appearance:none;-webkit-appearance:none;position:relative;flex:0 0 auto;width:2.7rem;height:1.6rem;min-height:1.6rem;margin:0;padding:0;border:0;border-radius:1rem;background:var(--color-line);cursor:pointer;transition:background var(--motion-duration) var(--motion-ease)}
|
||||
.media-screen .media-settings-check input[role=switch]::after {content:'';position:absolute;top:.2rem;inset-inline-start:.2rem;width:1.2rem;height:1.2rem;border-radius:50%;background:var(--color-surface);box-shadow:0 1px 2px rgb(0 0 0 / .25);transition:inset-inline-start var(--motion-duration) var(--motion-ease)}
|
||||
.media-screen .media-settings-check input[role=switch]:checked {background:var(--color-brand)}
|
||||
.media-screen .media-settings-check input[role=switch]:checked::after {inset-inline-start:1.3rem}
|
||||
.media-screen .media-settings-check input[role=switch]:focus-visible {outline:var(--focus-ring);outline-offset:var(--focus-offset)}
|
||||
/* The label is a few characters; it keeps its line and the select takes the rest. */
|
||||
.media-screen .media-settings .media-setting-field {white-space:nowrap}
|
||||
.media-screen .media-settings .media-setting-field>.media-select-control {flex:1;min-width:0;max-width:24rem;white-space:normal}
|
||||
.media-screen .media-library-list {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;padding:12px 0;}
|
||||
.media-screen .media-library-row {display:grid;grid-template-columns:24px minmax(0,1fr) 44px;gap:10px;padding:16px;margin:0;border:1px solid var(--border);border-radius:11px;background:var(--media-paper);align-items:start}
|
||||
.media-library-row>svg {margin-top:8px;color:var(--media-accent)}
|
||||
|
||||
@@ -110,7 +110,7 @@ export function ShelfPage() {
|
||||
];
|
||||
const groupLabel = series ? '系列 · ' + series : tag ? '标签 · ' + tag : '';
|
||||
const remove = async (book: Book | ContinueReadingItem) => { await runtime.api.browseBatchShelf({ bookIds: [book.id] }, 'remove'); await queryClient.invalidateQueries({ queryKey: ['shelf'] }); };
|
||||
const subtitle = queryText ? (query.data?.total ? `「${queryText}」匹配 ${query.data.total} 本` : `没有匹配「${queryText}」的书`) : query.data?.total ? (sort === 'recent' ? `最近阅读 · 共 ${query.data.total} 本` : `按${SHELF_SORTS.find(item => item.value === sort)?.label ?? ''}排列 · ${query.data.total} 本`) : '收藏好书,随时接着读';
|
||||
const subtitle = queryText ? (query.data?.total ? `「${queryText}」匹配 ${query.data.total} 本` : `没有匹配「${queryText}」的书`) : '收藏好书,随时接着读'; // count and sort live in the sticky toolbar below; saying them twice is noise
|
||||
const densityLabel = settings.shelfDensity === 'compact' ? '紧凑' : settings.shelfDensity === 'comfortable' ? '宽松' : '适中';
|
||||
return <div id="shelf-scroll" className="shelf shelf-screen">
|
||||
<header className="shelf-head">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Sheet } from '../../../ui/sheet.tsx';
|
||||
import { Button, Callout } from '../../../ui/toolkit.tsx';
|
||||
import { Button, Callout, IconTextButton } from '../../../ui/toolkit.tsx';
|
||||
import { SourcesHeader } from '../components/SourcesHeader.tsx';
|
||||
import { SourceBookDetailSheet } from '../components/SourceBookDetailSheet.tsx';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
@@ -117,6 +117,8 @@ export function SourcesPage() {
|
||||
<div><h2>搜书</h2><p className="muted">选择来源,发现想读的书。</p></div>
|
||||
<SearchableSelect label="选择来源" value={selectedSourceId} options={enabledSources.map(source => ({ value: source.id, label: source.name + (source.isDefault ? '(默认)' : '') }))} onChange={setSourceId} />
|
||||
{!selectedSourceId && <p className="muted">{enabledSources.length ? '选择来源后,即可搜索书籍或浏览目录。' : '暂无可用来源,请先添加或启用来源。'}</p>}
|
||||
{/* An empty state names its way out: the fix for "no source" lives on the next tab. */}
|
||||
{!selectedSourceId && !enabledSources.length && <IconTextButton className="primary source-picker-cta" icon="plus" label="前往书源管理" onClick={() => setTab('sources')} />}
|
||||
</section>
|
||||
{selectedSource && <section className="sources-card source-catalog"><h2>搜索与浏览</h2>
|
||||
{catalogError && <Callout tone="error" onDismiss={() => setCatalogError('')}>{catalogError}</Callout>}
|
||||
|
||||
@@ -1276,13 +1276,6 @@ book-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shelf-status {
|
||||
font-size: 0.78rem;
|
||||
text-align: center;
|
||||
color: var(--reader-muted);
|
||||
padding: 0.75rem 0.9rem calc(0.75rem + var(--safe-bottom));
|
||||
}
|
||||
|
||||
|
||||
/* ---- the pager ----
|
||||
*
|
||||
@@ -3784,6 +3777,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
|
||||
.source-picker > div p { margin:0; font-size:.85rem; }
|
||||
.source-picker > label { flex:1; margin:0; font-size:.85rem; color:var(--reader-muted); }
|
||||
.source-picker > p { flex-basis:100%; margin:0; }
|
||||
.source-picker > .source-picker-cta { margin-top: calc(var(--space-3) * -0.5); }
|
||||
}
|
||||
/* Collection pages share a quiet heading, labelled destinations and useful empty states. */
|
||||
.shelf-head { align-items: center; padding-top: calc(1.25rem + var(--safe-top)); padding-bottom: .85rem; }
|
||||
@@ -3816,7 +3810,8 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
|
||||
.collection-empty p.muted { max-width: 23rem; font-size: .875rem; line-height: 1.8; text-wrap: pretty; }
|
||||
.collection-empty .empty-actions { margin-top: .6rem; gap: .65rem; }
|
||||
.collection-empty .button { min-height: 44px; font-size: .875rem; }
|
||||
.library-browse-body > .manager-status { background: transparent; border: 0; margin: .75rem auto; }
|
||||
/* Browsing pages with a pager, so the count is a footer line, not a sticky overlay over the grid. */
|
||||
.library-browse-body > .manager-status { position: static; background: transparent; backdrop-filter: none; border: 0; margin: .75rem auto; }
|
||||
.file-manager-header .manager-crumbs { margin-top: .75rem; padding: .25rem 0; }
|
||||
.file-manager-header .manager-crumb { min-height: 44px; }
|
||||
.library-files-screen .manager-list { margin: .5rem 1rem; border: 1px solid var(--reader-line); border-radius: 1rem; overflow: hidden; }
|
||||
|
||||
Reference in new issue
Block a user