feat(web): authed blob image pipeline with LRU, cover card, dir grouping
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Book } from "../api/types";
|
||||
import { fetchObjectUrl } from "../lib/authImage";
|
||||
import { btn } from "./ui";
|
||||
|
||||
export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } {
|
||||
const [state, setState] = useState<{ key: string; src: string; failed: boolean }>({ key: "", src: "", failed: false });
|
||||
const [nonce, setNonce] = useState(0);
|
||||
const key = (url ?? "") + "#" + nonce;
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
let dead = false;
|
||||
const k = url + "#" + nonce;
|
||||
setState({ key: k, src: "", failed: false });
|
||||
fetchObjectUrl(url)
|
||||
.then((src) => !dead && setState({ key: k, src, failed: false }))
|
||||
.catch(() => !dead && setState({ key: k, src: "", failed: true }));
|
||||
return () => {
|
||||
dead = true;
|
||||
};
|
||||
}, [url, nonce]);
|
||||
|
||||
const same = state.key === key;
|
||||
return { src: same ? state.src : "", failed: same && state.failed, retry: () => setNonce((n) => n + 1) };
|
||||
}
|
||||
|
||||
export function Cover({ book, className }: { book: Book; className?: string }) {
|
||||
const { src, failed, retry } = useAuthedImage(book.cover_url);
|
||||
if (failed)
|
||||
return (
|
||||
<div className={`grid place-items-center bg-zinc-900 ${className ?? ""}`}>
|
||||
<button className={btn} onClick={retry} aria-label="封面加载失败,重试">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
if (!src) return <div className={`animate-pulse bg-zinc-800 ${className ?? ""}`} />;
|
||||
return <img src={src} alt="" loading="lazy" className={`bg-zinc-900 object-cover ${className ?? ""}`} />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { apiRaw } from "../api/client";
|
||||
|
||||
const MAX = 600;
|
||||
const cache = new Map<string, Promise<string>>(); // 插入序 ≈ LRU(命中时重插)
|
||||
|
||||
// 带鉴权取图 → objectURL。后端 Bearer-only,<img>/<iframe> 直挂 /api 必 401,
|
||||
// 故图片一律走此通道;URL 含内容 hash 不可变,会话内可安全长驻缓存。
|
||||
export async function fetchObjectUrl(url: string, signal?: AbortSignal): Promise<string> {
|
||||
const hit = cache.get(url);
|
||||
if (hit) {
|
||||
cache.delete(url);
|
||||
cache.set(url, hit);
|
||||
return hit;
|
||||
}
|
||||
const p = (async () => {
|
||||
const res = await apiRaw(url, { signal });
|
||||
return URL.createObjectURL(await res.blob());
|
||||
})();
|
||||
p.catch(() => cache.delete(url)); // 失败出缓存,下次调用重试
|
||||
cache.set(url, p);
|
||||
while (cache.size > MAX) {
|
||||
const oldest = cache.keys().next().value as string;
|
||||
cache.delete(oldest); // 不 revoke:可能仍被在屏 <img> 引用;≤600 条会话内 blob 可接受
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
export function __resetImageCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Book } from "../api/types";
|
||||
|
||||
export interface Group {
|
||||
dir: string; // path 去掉文件名即分组键(spec §4);根目录为 ""
|
||||
books: Book[];
|
||||
}
|
||||
|
||||
export function groupByDir(books: Book[]): Group[] {
|
||||
const m = new Map<string, Book[]>();
|
||||
for (const bk of books) {
|
||||
const i = bk.path.lastIndexOf("/");
|
||||
const dir = i < 0 ? "" : bk.path.slice(0, i);
|
||||
let arr = m.get(dir);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
m.set(dir, arr);
|
||||
}
|
||||
arr.push(bk);
|
||||
}
|
||||
return [...m.entries()]
|
||||
.sort((a, b) => (a[0] === "" ? -1 : b[0] === "" ? 1 : a[0].localeCompare(b[0])))
|
||||
.map(([dir, bs]) => ({ dir, books: bs }));
|
||||
}
|
||||
Reference in New Issue
Block a user