fix(deploy): pin distinct image tags per compose (booklib/{api,web}:{dev,prod}); same project name shared default tags so dev up without --build ran the prod image
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { apiRaw } from "../api/client";
|
||||
import { btn } from "../components/ui";
|
||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||
|
||||
export default function TextReader({ book, initialLocator }: ReaderProps) {
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const saver = useProgressSaver(book.id);
|
||||
const restored = useRef(false);
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
const [nonce, setNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let dead = false;
|
||||
setText(null);
|
||||
setErr("");
|
||||
apiRaw(book.file_url ?? "")
|
||||
.then((r) => r.text())
|
||||
.then((t) => !dead && setText(t))
|
||||
.catch((e) => !dead && setErr(e instanceof Error ? e.message : "读取失败"));
|
||||
return () => {
|
||||
dead = true;
|
||||
};
|
||||
}, [book.file_url, nonce]);
|
||||
|
||||
const html = useMemo(() => {
|
||||
if (text == null || book.format !== "md") return null;
|
||||
return DOMPurify.sanitize(marked.parse(text, { async: false }));
|
||||
}, [text, book.format]);
|
||||
|
||||
useEffect(() => {
|
||||
if (restored.current || text == null || !boxRef.current) return;
|
||||
restored.current = true;
|
||||
const f = Number(initialLocator?.scrollFraction);
|
||||
if (Number.isFinite(f) && f > 0 && f <= 1) {
|
||||
const el = boxRef.current;
|
||||
el.scrollTop = f * (el.scrollHeight - el.clientHeight);
|
||||
}
|
||||
}, [text, initialLocator]);
|
||||
|
||||
function onScroll() {
|
||||
const el = boxRef.current;
|
||||
if (!el) return;
|
||||
const max = el.scrollHeight - el.clientHeight;
|
||||
const frac = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
|
||||
saver.report({ scrollFraction: frac }, frac);
|
||||
}
|
||||
|
||||
if (err)
|
||||
return (
|
||||
<div className="grid h-full place-items-center p-8 text-center">
|
||||
<div>
|
||||
<p className="mb-3 text-red-400">{err}</p>
|
||||
<button className={btn} onClick={() => setNonce((n) => n + 1)}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (text == null) return <div className="grid h-full place-items-center text-zinc-500">正文加载中…</div>;
|
||||
|
||||
return (
|
||||
<div ref={boxRef} onScroll={onScroll} className="h-full overflow-y-auto p-6">
|
||||
{html !== null ? (
|
||||
<article className="md-body mx-auto max-w-2xl text-[15px] leading-7" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
) : (
|
||||
<pre className="mx-auto max-w-2xl font-sans text-[15px] leading-7 break-words whitespace-pre-wrap">{text}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user