505 lines
21 KiB
TypeScript
505 lines
21 KiB
TypeScript
import { useQuery } from "@tanstack/react-query";
|
||
import { BookOpenCheck, Check, ChevronLeft, ChevronRight, PanelLeft, Settings2 } from "lucide-react";
|
||
import { DropdownMenu } from "radix-ui";
|
||
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
||
import { api, formatPageUrl } from "../api/client";
|
||
import { btn } from "../components/ui";
|
||
import { BookmarksTab } from "../components/Bookmarks";
|
||
import { RdSlider } from "../components/rd-slider";
|
||
import { ReaderNav, type NavTab } from "../components/reader-nav";
|
||
import { fetchObjectUrl } from "../lib/authImage";
|
||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||
import { PageHeights, chapterIndexAt, chapterWin } from "../lib/virt";
|
||
|
||
const MAX_W = 720;
|
||
const EST_RATIO = 1.4; // 估高:宽 × 1.4(漫画常见竖幅)
|
||
|
||
function useStoredBool(key: string, def: boolean) {
|
||
const [v, setV] = useState(() => {
|
||
const s = localStorage.getItem(key);
|
||
return s === null ? def : s === "1";
|
||
});
|
||
useEffect(() => localStorage.setItem(key, v ? "1" : "0"), [key, v]);
|
||
return [v, setV] as const;
|
||
}
|
||
|
||
function useStoredPrefetch() {
|
||
const [n, setN] = useState(() => {
|
||
const s = localStorage.getItem("cbz-prefetch");
|
||
if (s === null || s === "") return 2; // 键缺失/空串 ≠ 0:新用户默认 2
|
||
const v = Number(s);
|
||
return Number.isInteger(v) && v >= 0 && v <= 3 ? v : 2;
|
||
});
|
||
useEffect(() => localStorage.setItem("cbz-prefetch", String(n)), [n]);
|
||
return [n, setN] as const;
|
||
}
|
||
|
||
function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoaded: (h: number) => void }) {
|
||
const [src, setSrc] = useState("");
|
||
const [failed, setFailed] = useState(false);
|
||
const [nonce, setNonce] = useState(0);
|
||
// url/nonce 变化时先落回占位态:React 官方推荐的「渲染期按下发 key 重置本地 state」,
|
||
// 替代原先 effect 内的同步 setState(级联渲染);换页/重试时的表现与原先一致
|
||
const key = url + "#" + nonce;
|
||
const [prevKey, setPrevKey] = useState(key);
|
||
if (prevKey !== key) {
|
||
setPrevKey(key);
|
||
setSrc("");
|
||
setFailed(false);
|
||
}
|
||
useEffect(() => {
|
||
let dead = false;
|
||
fetchObjectUrl(url)
|
||
.then((s) => !dead && setSrc(s))
|
||
.catch(() => !dead && setFailed(true));
|
||
return () => {
|
||
dead = true;
|
||
};
|
||
}, [url, nonce]);
|
||
if (failed)
|
||
return (
|
||
<div className="grid place-items-center gap-2 bg-stone-900 py-8 text-sm text-stone-500" style={{ width }}>
|
||
本页加载失败
|
||
<button className={btn} onClick={() => setNonce((n) => n + 1)}>
|
||
重试
|
||
</button>
|
||
</div>
|
||
);
|
||
if (!src) return <div className="animate-pulse bg-stone-800" style={{ width, height: width * EST_RATIO }} />;
|
||
return (
|
||
<img
|
||
src={src}
|
||
alt=""
|
||
style={{ width }}
|
||
className="block"
|
||
onLoad={(e) => {
|
||
const el = e.currentTarget;
|
||
if (el.naturalWidth > 0) onLoaded((el.naturalHeight / el.naturalWidth) * width);
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export default function CbzReader({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
||
const countQ = useQuery({
|
||
queryKey: ["pages", book.id],
|
||
queryFn: () => api.pageCount(book.pages_url ?? ""),
|
||
enabled: !!book.pages_url,
|
||
retry: 0,
|
||
});
|
||
const count = countQ.data?.count ?? 0;
|
||
const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷);null/单组=扁平整本一章
|
||
const [nav, setNav] = useState<null | NavTab>(null);
|
||
const activeRow = useRef<HTMLButtonElement>(null);
|
||
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||
const boxRef = useRef<HTMLDivElement>(null);
|
||
const [width, setWidth] = useState(480);
|
||
const [vh, setVh] = useState(600);
|
||
const [top, setTop] = useState(0);
|
||
const [scrub, setScrub] = useState<number | null>(null); // 页滑条拖拽中的临时值
|
||
const [, bump] = useState(0);
|
||
const restored = useRef(false);
|
||
const scrollRaf = useRef(0);
|
||
const idle = useRef<undefined | ReturnType<typeof setTimeout>>(undefined);
|
||
const curRef = useRef(0);
|
||
const shift = useRef(0); // 量高补偿按帧合并,避免一次加载多页各改一次 scrollTop
|
||
const shiftRaf = useRef(0);
|
||
const dragging = useRef(false); // 拖滚动条中:scrollTop 归浏览器管,JS 只补几何不抢方向盘
|
||
const estLearned = useRef(false); // 估高只学一次(见 measure)
|
||
const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定)
|
||
const [continuous, setContinuous] = useStoredBool("cbz-continuous", false); // 连读:滑动可跨章
|
||
const [prefetch, setPrefetch] = useStoredPrefetch(); // 锁章预读章数 0–3
|
||
const [ci, setCi] = useState(0); // 当前章窗口;锁章时只由显式跳页改,连读时随滚动同步
|
||
const pending = useRef<number | null>(null); // 跨章跳页:切窗后下一帧落位
|
||
|
||
const ph = useMemo(() => new PageHeights(count, width * ratio.current), [count, width]);
|
||
const w = useMemo(
|
||
() => chapterWin(chapters, count, ci, continuous, prefetch),
|
||
[chapters, count, ci, continuous, prefetch],
|
||
);
|
||
const locked = !!chapters && chapters.length >= 2 && !continuous;
|
||
// 锁章下窗外前缀页不渲染 → scrollTop/页 top 一律窗口相对;读 ph(全局前缀和)前先加回 anchor
|
||
const anchor = locked ? ph.offset(w.start) : 0;
|
||
|
||
// 容器尺寸自适应:回调 ref 在滚动盒真正挂载时才观测(等页数请求期间的早退渲染里没有这个节点)
|
||
const roRef = useRef<ResizeObserver | null>(null);
|
||
function attachBox(el: HTMLDivElement | null) {
|
||
boxRef.current = el;
|
||
roRef.current?.disconnect();
|
||
roRef.current = null;
|
||
if (!el) return;
|
||
const ro = new ResizeObserver(() => {
|
||
setWidth(Math.min(el.clientWidth, MAX_W));
|
||
setVh(el.clientHeight);
|
||
});
|
||
ro.observe(el);
|
||
roRef.current = ro;
|
||
}
|
||
|
||
// 首次拿到 count 后恢复进度页;目标页可能在他章 → goPage 走显式切章路径
|
||
useEffect(() => {
|
||
if (restored.current || !count || !boxRef.current) return;
|
||
restored.current = true;
|
||
const p = Number(initialLocator?.page);
|
||
if (Number.isInteger(p) && p > 0 && p < count) goPage(p, false, false);
|
||
}, [count, initialLocator, ph]);
|
||
|
||
// 跨章跳页:新窗口几何渲染后才能真正滚动(旧窗口 contentH 会把 scrollTop 夹断),故挂 pending 等 [ci] 生效
|
||
useEffect(() => {
|
||
if (pending.current === null) return;
|
||
const el = boxRef.current;
|
||
if (!el) return;
|
||
const t = pending.current;
|
||
pending.current = null;
|
||
el.scrollTop = ph.offset(t) - anchor;
|
||
}, [ci, ph]);
|
||
|
||
// 滚动事件按帧合并;预取等滚动停稳再做——拖拽途中逐事件拉图=带宽/解码风暴
|
||
function onScroll() {
|
||
if (scrollRaf.current) return;
|
||
scrollRaf.current = requestAnimationFrame(() => {
|
||
scrollRaf.current = 0;
|
||
const el = boxRef.current;
|
||
if (!el) return;
|
||
const t = el.scrollTop;
|
||
setTop(t);
|
||
if (count) {
|
||
curRef.current = ph.pageAt(t + anchor + vh / 2);
|
||
if (locked) curRef.current = Math.min(curRef.current, w.end - 1); // 末屏中心落在章末卡片/他章 → 钉在本章末页
|
||
if (!locked) setCi(chapterIndexAt(chapters, curRef.current)); // 连读/扁平:当前章随滚动走(目录计数高亮)
|
||
saver.report({ page: curRef.current }, (curRef.current + 1) / count);
|
||
}
|
||
clearTimeout(idle.current);
|
||
idle.current = setTimeout(() => {
|
||
for (let j = curRef.current + 1; j <= Math.min(count - 1, curRef.current + 5); j++)
|
||
fetchObjectUrl(formatPageUrl(book.page_url_fmt ?? "", j)).catch(() => {});
|
||
}, 250);
|
||
});
|
||
}
|
||
|
||
useEffect(
|
||
() => () => {
|
||
cancelAnimationFrame(scrollRaf.current);
|
||
cancelAnimationFrame(shiftRaf.current);
|
||
clearTimeout(idle.current);
|
||
},
|
||
[],
|
||
);
|
||
|
||
useEffect(() => {
|
||
const up = () => (dragging.current = false);
|
||
window.addEventListener("pointerup", up);
|
||
window.addEventListener("pointercancel", up);
|
||
return () => {
|
||
window.removeEventListener("pointerup", up);
|
||
window.removeEventListener("pointercancel", up);
|
||
};
|
||
}, []);
|
||
|
||
function applyShift(d: number) {
|
||
if (dragging.current) return; // 拖拽中 JS 写 scrollTop 会和浏览器"滑块位置→滚动"映射抢方向盘
|
||
shift.current += d;
|
||
if (shiftRaf.current) return;
|
||
shiftRaf.current = requestAnimationFrame(() => {
|
||
shiftRaf.current = 0;
|
||
const el = boxRef.current;
|
||
if (el && shift.current) el.scrollTop += shift.current;
|
||
shift.current = 0;
|
||
});
|
||
}
|
||
|
||
const curRaw = ph.pageAt(top + anchor + vh / 2);
|
||
const cur = locked ? Math.min(curRaw, w.end - 1) : curRaw; // 锁章:视口落在章末卡片上时读数不越章(HUD/滑条/进度)
|
||
|
||
useEffect(() => {
|
||
if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" });
|
||
}, [nav]);
|
||
|
||
function scrollToPage(i: number, smooth: boolean) {
|
||
const el = boxRef.current;
|
||
if (!el) return;
|
||
el.scrollTo({
|
||
top: ph.offset(Math.max(w.start, Math.min(w.end - 1, i))) - anchor,
|
||
behavior: smooth ? "smooth" : "auto",
|
||
});
|
||
}
|
||
|
||
// 统一定位入口:锁章下目标页在窗外 = 显式切章(滑条/书签/目录/章末卡片皆此一路径);恢复进度走 show=false 静默落位
|
||
function goPage(i: number, smooth = true, show = true) {
|
||
const t = Math.max(0, Math.min(count - 1, i));
|
||
if (show) chrome.show();
|
||
if (locked && (t < w.start || t >= w.end)) {
|
||
setCi(chapterIndexAt(chapters, t));
|
||
pending.current = t;
|
||
return;
|
||
}
|
||
scrollToPage(t, smooth);
|
||
}
|
||
|
||
// 翻一屏;锁章时滚动高度天然止于章末卡片,滚不过去
|
||
function turnScreen(d: number) {
|
||
boxRef.current?.scrollBy({ top: d * vh, behavior: "smooth" });
|
||
}
|
||
|
||
// 点按两侧翻屏,点中间唤出工具栏(微信读书/Mihon 式手势区)
|
||
function onZoneClick(e: MouseEvent<HTMLDivElement>) {
|
||
if ((e.target as HTMLElement).closest("button,input,nav,a")) return;
|
||
if (window.getSelection()?.toString()) return;
|
||
const r = e.currentTarget.getBoundingClientRect();
|
||
const x = (e.clientX - r.left) / r.width;
|
||
if (x < 0.28) turnScreen(-1);
|
||
else if (x > 0.72) turnScreen(1);
|
||
else chrome.toggle();
|
||
}
|
||
|
||
// pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态
|
||
if (countQ.isPending && !!book.pages_url)
|
||
return <div className="grid h-full place-items-center text-stone-500">页索引加载中…</div>;
|
||
if (countQ.isError || !book.page_url_fmt)
|
||
return (
|
||
<div className="grid h-full place-items-center p-8 text-center text-stone-500">
|
||
无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"}
|
||
</div>
|
||
);
|
||
|
||
const fmt: string = book.page_url_fmt;
|
||
const [a0, b0] = ph.range(top + anchor, vh, 2); // 视口位置换回全局空间再查页(锁章下 top 是窗口相对的)
|
||
const a = Math.max(a0, w.start);
|
||
const b = Math.min(b0, w.end); // 可见页永不超过章末;窗外页在隐藏预读层
|
||
const contentH = locked ? ph.offset(w.end) - anchor + vh : ph.total();
|
||
|
||
function measure(i: number, h: number) {
|
||
const el = boxRef.current;
|
||
// anchor 一律现取:setEst 会改写窗外未量页前缀的高度,渲染闭包里的 anchor 同帧内即过期,
|
||
// 拿它换算 topY → keep 页算错数十页 → 重锚把视口弹飞 → 新页量高再触发翻转 = 章尾震荡
|
||
const aNow = ph.offset(w.start);
|
||
const topY = el ? el.scrollTop + aNow : 0; // 与 ph.offset 同处全局空间比较
|
||
const topOfI = ph.offset(i);
|
||
const d = ph.set(i, h);
|
||
if (el && d !== 0 && topOfI < topY) applyShift(d); // 视口上方页高变化 → 补偿滚动
|
||
// 让未量高页的估高收敛到实测页高。重估瞬间改写 contentH,浏览器会先把 scrollTop 夹断,
|
||
// 增量补偿在已坏的基线上算 → 章尾大量未量页时"到底后被重置到顶部"。
|
||
// 改为绝对重锚:钉住当前视口顶页在原屏幕位置,不依赖任何修正量。
|
||
// 估高只学一次:混开本(横竖版混排)书里"按最近一页重估"会让 est 反复翻转,
|
||
// 每次翻转都改写全部未量页几何(contentH ±16k 呼吸),拖滑块到底就是在这上面震荡。
|
||
// 学一次钉死后,后续每页量高只动自己那一格(≤单页高),几何单调稳定。
|
||
if (width > 0 && !estLearned.current) {
|
||
estLearned.current = true;
|
||
const keep = el ? ph.pageAt(topY) : 0;
|
||
let keepIn = el ? topY - ph.offset(keep) : 0;
|
||
ph.setEst(h, topY);
|
||
if (el && !dragging.current) {
|
||
keepIn = Math.min(keepIn, Math.max(0, ph.offset(keep + 1) - ph.offset(keep)));
|
||
shift.current = 0; // 增量补偿作废,重锚已含全部几何变化
|
||
el.scrollTop = ph.offset(keep) - ph.offset(w.start) + keepIn;
|
||
}
|
||
ratio.current = h / width;
|
||
}
|
||
bump((x) => x + 1);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
tabIndex={0}
|
||
aria-label="漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏"
|
||
className="relative h-full"
|
||
onKeyDown={(e) => {
|
||
if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1);
|
||
else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1);
|
||
else return;
|
||
e.preventDefault();
|
||
}}
|
||
>
|
||
<div
|
||
ref={attachBox}
|
||
onScroll={onScroll}
|
||
onClick={onZoneClick}
|
||
onPointerDown={(e) => {
|
||
const el = e.currentTarget;
|
||
const r = el.getBoundingClientRect();
|
||
if (e.clientX - r.left >= el.clientWidth || e.clientY - r.top >= el.clientHeight) dragging.current = true; // 按在滚动条上
|
||
}}
|
||
className="h-full overflow-y-auto"
|
||
>
|
||
<div className="relative mx-auto" style={{ height: contentH, maxWidth: MAX_W }}>
|
||
{Array.from({ length: Math.max(0, b - a) }, (_, k) => {
|
||
const i = a + k;
|
||
return (
|
||
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) - anchor }}>
|
||
<PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
|
||
</div>
|
||
);
|
||
})}
|
||
{locked && (
|
||
<div
|
||
className="absolute left-0 grid w-full place-items-center"
|
||
style={{ top: ph.offset(w.end) - anchor, height: vh }}
|
||
>
|
||
{w.end >= count ? (
|
||
<span className="text-sm text-stone-500">— 全书完 —</span>
|
||
) : (
|
||
<button className={btn} onClick={() => goPage(chapters![ci + 1].start)}>
|
||
本章完 · 下一章 <ChevronRight size={14} className="inline" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
{locked && w.mountEnd > w.end && (
|
||
// 预读层:挂载+解码但不参与滚动几何(height:0 + overflow:hidden 裁掉全部子元素)
|
||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-0 overflow-hidden">
|
||
{Array.from({ length: w.mountEnd - w.end }, (_, k) => {
|
||
const i = w.end + k;
|
||
return (
|
||
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) - anchor }}>
|
||
<PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{chrome.on ? (
|
||
<div className="rd-sheet absolute inset-x-0 bottom-0 z-20 border-t border-stone-700/60 bg-stone-900/85 px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 text-stone-200 backdrop-blur">
|
||
<div className="flex items-center gap-3">
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={Math.max(0, count - 1)}
|
||
value={scrub ?? cur}
|
||
onChange={(e) => {
|
||
const v = Number(e.target.value);
|
||
setScrub(v);
|
||
goPage(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿
|
||
}}
|
||
onPointerUp={() => setScrub(null)}
|
||
onPointerCancel={() => setScrub(null)}
|
||
onBlur={() => setScrub(null)}
|
||
className="rd-range"
|
||
aria-label="页码跳转"
|
||
/>
|
||
<span className="w-20 shrink-0 text-right text-xs tabular-nums opacity-70">
|
||
{(scrub ?? cur) + 1}/{count} · {Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}%
|
||
</span>
|
||
</div>
|
||
<div className="mt-1 flex items-center gap-2 pb-1 text-sm">
|
||
<button
|
||
className="rd-btn inline-flex items-center gap-1.5"
|
||
aria-expanded={!!nav}
|
||
aria-controls="reader-nav"
|
||
onClick={() => setNav(nav ? null : chapters ? "toc" : "bm")}
|
||
>
|
||
<PanelLeft size={14} /> 导航
|
||
</button>
|
||
{chapters && (
|
||
<>
|
||
<span className="text-xs tabular-nums opacity-60">
|
||
{ci + 1}/{chapters.length}
|
||
</span>
|
||
<button
|
||
className="rd-btn inline-flex items-center gap-0.5"
|
||
disabled={ci === 0}
|
||
onClick={() => goPage(chapters[ci - 1].start)}
|
||
>
|
||
<ChevronLeft size={14} /> 上一章
|
||
</button>
|
||
<button
|
||
className="rd-btn inline-flex items-center gap-0.5"
|
||
disabled={ci === chapters.length - 1}
|
||
onClick={() => goPage(chapters[ci + 1].start)}
|
||
>
|
||
下一章 <ChevronRight size={14} />
|
||
</button>
|
||
</>
|
||
)}
|
||
{chapters && chapters.length >= 2 && (
|
||
<DropdownMenu.Root>
|
||
<DropdownMenu.Trigger
|
||
className="rd-btn ml-auto inline-flex items-center gap-1.5"
|
||
aria-label="更多阅读选项"
|
||
>
|
||
<Settings2 size={14} /> 更多
|
||
</DropdownMenu.Trigger>
|
||
<DropdownMenu.Portal>
|
||
<DropdownMenu.Content
|
||
align="end"
|
||
side="top"
|
||
className="rd-divider z-50 min-w-44 rounded-md border bg-[var(--rd-bg,#171512)] p-1 shadow-2xl"
|
||
style={{ color: "var(--rd-fg,#c6c0b6)" }}
|
||
>
|
||
<DropdownMenu.CheckboxItem
|
||
className="rd-row rd-highlight cursor-pointer"
|
||
style={{ display: "flex", alignItems: "center", gap: 8 }}
|
||
checked={continuous}
|
||
onCheckedChange={(v) => setContinuous(!!v)}
|
||
>
|
||
<BookOpenCheck size={14} /> 连读(滑动跨章)
|
||
<DropdownMenu.ItemIndicator style={{ marginLeft: "auto" }}>
|
||
<Check size={14} />
|
||
</DropdownMenu.ItemIndicator>
|
||
</DropdownMenu.CheckboxItem>
|
||
{locked && (
|
||
<RdSlider
|
||
label="预读:锁章末尾时提前挂载其后 N 章"
|
||
ariaLabel="预读章数"
|
||
min={0}
|
||
max={3}
|
||
value={prefetch}
|
||
onChange={setPrefetch}
|
||
/>
|
||
)}
|
||
</DropdownMenu.Content>
|
||
</DropdownMenu.Portal>
|
||
</DropdownMenu.Root>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-2 rounded-full bg-stone-900/85 px-3 py-1.5 text-xs tabular-nums text-stone-200 shadow-lg ring-1 ring-stone-700/60 backdrop-blur">
|
||
<span>
|
||
{cur + 1}/{count}
|
||
</span>
|
||
<span className="opacity-60">{Math.round(((cur + 1) / Math.max(1, count)) * 100)}%</span>
|
||
</div>
|
||
)}
|
||
{nav && (
|
||
<ReaderNav
|
||
tab={chapters ? nav : "bm"}
|
||
onTab={setNav}
|
||
onClose={() => setNav(null)}
|
||
bookmarks={
|
||
<BookmarksTab
|
||
book={book}
|
||
capture={saver.capture}
|
||
seek={(l) => {
|
||
goPage(Number(l.page) || 0);
|
||
setNav(null);
|
||
}}
|
||
/>
|
||
}
|
||
toc={
|
||
chapters && (
|
||
<ul className="space-y-0.5">
|
||
{chapters.map((c, i) => (
|
||
<li key={i}>
|
||
<button
|
||
ref={i === ci ? activeRow : undefined}
|
||
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
|
||
onClick={() => {
|
||
goPage(c.start);
|
||
setNav(null);
|
||
}}
|
||
>
|
||
{i + 1}. {c.title}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|