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:
2026-09-07 21:24:33 +08:00
parent 95317a0108
commit 961de429f9
45 changed files with 7 additions and 3 deletions
+74
View File
@@ -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>
);
}