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
+40
View File
@@ -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 ?? ""}`} />;
}
+26
View File
@@ -0,0 +1,26 @@
import { Component, type ReactNode } from "react";
export class ErrorBoundary extends Component<{ children: ReactNode }, { err: Error | null }> {
state: { err: Error | null } = { err: null };
static getDerivedStateFromError(err: Error) {
return { err };
}
render() {
if (this.state.err) {
return (
<div className="grid h-full place-items-center p-8 text-center">
<div>
<p className="mb-2 text-lg text-red-400">页面出错了</p>
<p className="mb-4 max-w-md text-sm break-all text-zinc-500">{String(this.state.err)}</p>
<button className="rounded-md bg-zinc-800 px-3 py-1.5 text-sm" onClick={() => this.setState({ err: null })}>
重试
</button>
</div>
</div>
);
}
return this.props.children;
}
}
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useState, type ReactNode } from "react";
type Toast = { id: number; kind: "ok" | "err"; text: ReactNode };
const items: Toast[] = [];
const subs = new Set<(t: Toast[]) => void>();
let nextId = 1;
export function toast(kind: Toast["kind"], text: ReactNode): void {
const t: Toast = { id: nextId++, kind, text };
items.push(t);
subs.forEach((f) => f([...items]));
setTimeout(() => {
const i = items.findIndex((x) => x.id === t.id);
if (i >= 0) items.splice(i, 1);
subs.forEach((f) => f([...items]));
}, 4000);
}
export function Toaster() {
const [list, setList] = useState<Toast[]>([]);
useEffect(() => {
subs.add(setList);
return () => {
subs.delete(setList);
};
}, []);
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{list.map((t) => (
<div
key={t.id}
role="status"
className={`max-w-sm rounded-lg px-4 py-2 text-sm shadow-lg ${
t.kind === "ok" ? "bg-emerald-900 text-emerald-100" : "bg-red-900 text-red-100"
}`}
>
{t.text}
</div>
))}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { btn } from "./ui";
export function TopBar({ right }: { right?: ReactNode }) {
const { user, isAdmin, logout } = useAuth();
return (
<header className="flex shrink-0 items-center gap-4 border-b border-zinc-800 px-4 py-3">
<Link to="/" className="font-semibold">
BookLib
</Link>
{isAdmin && (
<Link className="text-sm text-zinc-400 hover:text-zinc-100" to="/admin/users">
用户
</Link>
)}
{isAdmin && (
<Link className="text-sm text-zinc-400 hover:text-zinc-100" to="/admin/libraries">
库管理
</Link>
)}
<div className="ml-auto flex items-center gap-3">
{right}
<span className="text-sm text-zinc-400">
{user?.username}
{isAdmin ? "(admin)" : ""}
</span>
<button className={btn} onClick={logout}>
退出
</button>
</div>
</header>
);
}
+7
View File
@@ -0,0 +1,7 @@
export const btn =
"rounded-md bg-zinc-800 px-3 py-1.5 text-sm hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50";
export const btnPrimary =
"rounded-md bg-emerald-700 px-3 py-1.5 text-sm hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-50";
export const input =
"rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm outline-none focus:border-zinc-500";
export const card = "rounded-xl border border-zinc-800 bg-zinc-900";