mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 13:31:09 +08:00
feat(web): placeholder covers take a stable per-title tint
A shelf of TXT books (or a media library before scraping) was a wall of identical panels, so finding one meant reading every placeholder. Titles now hash to one of twelve hues 30° apart (ui/cover-tint.ts), mixed 10-20% into the theme surface, so the tint follows light/dark and the text on it keeps its contrast. Shelf, library and media covers share it. ui.md §2.7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c5125792d7
commit
7e8b0ea278
7 files changed
+47
-3
No files matched your search
+10
@@ -100,6 +100,16 @@
|
||||
**胶囊要写 `pill`,不要写半高。** `height: 26px; border-radius: 13px` 在高度改成 28px 的那天
|
||||
就不再是胶囊了;`--radius-pill` 与高度无关。
|
||||
|
||||
### 2.7 无封面的占位
|
||||
|
||||
没有封面的书、影片按标题取一个色相(`web/src/ui/cover-tint.ts`,12 档、每档 30°),
|
||||
写进 `--cover-hue`,样式表把 `hsl(var(--cover-hue) 55% 55%)` 以 10–20% 混进当前主题的
|
||||
surface。这是「组件不写死颜色」的唯一例外,理由与封面图片相同:它是内容的颜色,不是界面的颜色。
|
||||
|
||||
- **按标题取,不按位置取**:同一本书在书架、书库、搜索里颜色一致,刷新也不变。
|
||||
- **只混少量**:占位上的文字仍用 ink/muted,对比度由主题保证;深色模式下自然变成暗底微染。
|
||||
- 不加色相之前,一屏全是同一个色块,只能逐个读字找书。
|
||||
|
||||
---
|
||||
|
||||
## 3. 布局
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BookOpen, FileText, Folder, Plus, Search, Upload } from 'lucide-react';
|
||||
import { coverTint } from '../../../ui/cover-tint.ts';
|
||||
import { ActionMenu } from '../../../ui/action-menu.tsx';
|
||||
import { Callout, Icon } from '../../../ui/toolkit.tsx';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -25,7 +26,7 @@ function Cover({ book }: { book: Book }) {
|
||||
}).catch(() => { if (live) setSrc(null); });
|
||||
return () => { live = false; if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
||||
}, [runtime.api, book.coverUrl]);
|
||||
return <div className="cover">{src ? <img src={src} alt="" loading="lazy" /> : <div className="placeholder">{book.title || '无封面'}</div>}<span className="format-badge">{book.format || '书籍'}</span></div>;
|
||||
return <div className="cover">{src ? <img src={src} alt="" loading="lazy" /> : <div className="placeholder" style={coverTint(book.title || '')}>{book.title || '无封面'}</div>}<span className="format-badge">{book.format || '书籍'}</span></div>;
|
||||
}
|
||||
|
||||
function CollectionHeader({ title, subtitle, backTo, actions, className, footer }: { title: string; subtitle: string; backTo?: string; actions?: React.ReactNode; className?: string; footer?: React.ReactNode }) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import type { Item, MediaApi } from '../api/media-api.ts';
|
||||
import { ApiError } from '../../../api/errors.ts';
|
||||
import {personColor} from './person-portrait.tsx';
|
||||
import {coverTint} from '../../../ui/cover-tint.ts';
|
||||
|
||||
import { createStores } from '../../../store/idb.ts';
|
||||
const coverStores=createStores().catch(()=>null);
|
||||
@@ -50,7 +51,7 @@ export function MediaCover({ api, item, square, retryable=false, loadWithoutMeta
|
||||
} else void load();
|
||||
return () => { controller.abort(); observer?.disconnect(); if (blobUrl) URL.revokeObjectURL(blobUrl); };
|
||||
}, [api, item.id, item.metadata.coverRef, item.metadata.tmdbPosterPath, item.metadata.embeddedCoverAssetId,item.metadata.musicBrainzCoverGroupId,attempt,loadWithoutMetadata]);
|
||||
return <span ref={host} style={item.kind==='artist'?{backgroundColor:personColor(item.title)}:undefined} className={'media-cover ' + (square ? 'square ' : '') + (item.kind==='artist'?'media-portrait '+(!url||failed?'media-person-placeholder ':''):'') + (failed&&retryable?'media-cover-failed ':'') + (url && !failed ? 'has-image' : '')}>
|
||||
return <span ref={host} style={item.kind==='artist'?{backgroundColor:personColor(item.title)}:url&&!failed?undefined:coverTint(item.title)} className={'media-cover ' + (square ? 'square ' : '') + (item.kind==='artist'?'media-portrait '+(!url||failed?'media-person-placeholder ':''):'') + (failed&&retryable?'media-cover-failed ':'') + (url && !failed ? 'has-image' : '')}>
|
||||
{url && !failed ? <img src={url} alt="" decoding="async" onError={() => setFailed('封面图片无法解码')}/> : <span className="media-cover-title" aria-label={item.title}>{item.kind==='artist'?Array.from(item.title)[0]:({movie:'电影',series:'剧集',season:'剧集',episode:'单集',album:'专辑',track:'音乐',audiobook:'有声书'} as Record<string,string>)[item.kind]||'影音'}</span>}
|
||||
{failed&&retryable&&<span className="media-cover-retry"><small role="status">{failed}</small><button type="button" onClick={()=>setAttempt(value=>value+1)}>重试封面</button></span>}
|
||||
</span>;
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
.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:var(--radius-sm);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)}
|
||||
/* Per-title tint, as on the shelf (ui/cover-tint.ts, ui.md §2.7). */
|
||||
.media-screen .media-cover:not(.has-image):not(.media-portrait)[style*='--cover-hue'] {background:linear-gradient(150deg,color-mix(in oklab,hsl(var(--cover-hue) 55% 55%) 18%,var(--media-paper)),color-mix(in oklab,hsl(var(--cover-hue) 55% 55%) 10%,var(--surface)));border-color:color-mix(in oklab,hsl(var(--cover-hue) 55% 55%) 14%,var(--border))}
|
||||
.media-screen .media-cover:not(.has-image):not(.media-portrait) .media-cover-title {font-family:inherit;font-size:13px;font-weight:500;line-height:1.5;letter-spacing:.08em;writing-mode:horizontal-tb}
|
||||
.media-screen .media-detail-page {padding-top:26px}
|
||||
.media-detail-page .media-hero {align-items:center;gap:28px;margin:0}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { coverTint } from '../../../ui/cover-tint.ts';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { BookOpen, LibraryBig, MoreHorizontal, Search } from 'lucide-react';
|
||||
@@ -38,7 +39,7 @@ export function BookCard({ book, showAuthor, showProgress, onRemove }: { book: B
|
||||
return <div className="book-card">
|
||||
<Link className="book-open" aria-label={`${book.title} ${book.author ?? ''}`.trim()} to={`/book/${encodeURIComponent(book.id)}`}>
|
||||
<div className="cover">
|
||||
{src ? <img src={src} alt="" loading="lazy" /> : <div className="placeholder">{book.title || '无封面'}</div>}
|
||||
{src ? <img src={src} alt="" loading="lazy" /> : <div className="placeholder" style={coverTint(book.title || '')}>{book.title || '无封面'}</div>}
|
||||
<span className="format-badge">{book.format || '书籍'}</span>
|
||||
{showProgress && progress !== undefined && progress > 0 && <div className="cover-progress"><span style={{ width: `${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%` }} /></div>}
|
||||
</div>
|
||||
|
||||
@@ -1636,6 +1636,17 @@ book-content {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Per-title tint (ui/cover-tint.ts, ui.md §2.7): mixed into the theme surface, so
|
||||
it stays a tint in every theme and mode and the title on it keeps its contrast. */
|
||||
.book-card .cover .placeholder[style*='--cover-hue'] {
|
||||
background:
|
||||
linear-gradient(
|
||||
150deg,
|
||||
color-mix(in oklab, hsl(var(--cover-hue) 55% 55%) 20%, var(--reader-surface)) 0%,
|
||||
color-mix(in oklab, hsl(var(--cover-hue) 55% 55%) 12%, var(--reader-line)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.book-card .format-badge {
|
||||
position: absolute;
|
||||
inset-block-end: 0.3rem;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
/**
|
||||
* A stable hue for a cover that has no art.
|
||||
*
|
||||
* A shelf of TXT files used to be a wall of identical panels, so the only way to find
|
||||
* a book was to read every placeholder. The hue comes from the title, so the same book
|
||||
* keeps its colour across screens and reloads; twelve steps 30° apart keep neighbours
|
||||
* distinguishable. The stylesheet mixes the hue into the theme's own surface at a low
|
||||
* strength (ui.md §2.7), so it tints rather than paints and follows light/dark.
|
||||
*/
|
||||
export function coverHue(title: string): number {
|
||||
let hash = 0;
|
||||
for (const character of title) hash = (hash * 31 + character.codePointAt(0)!) >>> 0;
|
||||
return (hash % 12) * 30;
|
||||
}
|
||||
|
||||
export const coverTint = (title: string): CSSProperties => ({ '--cover-hue': coverHue(title) });
|
||||
Reference in new issue
Block a user