133 lines
5.6 KiB
TypeScript
133 lines
5.6 KiB
TypeScript
"use client";
|
||
import { useState, useTransition } from "react";
|
||
import Link from "next/link";
|
||
import { Ruler, Loader2, AlertTriangle, ExternalLink } from "lucide-react";
|
||
import { scanUndersizedCovers, type UndersizedCover } from "@/app/actions/maintenance";
|
||
import { thumbUrl } from "@/lib/assetUrls";
|
||
import { useSettingsPanel } from "./SettingsPanelProvider";
|
||
|
||
type State =
|
||
| { kind: "idle" }
|
||
| { kind: "scanning" }
|
||
| { kind: "result"; rows: UndersizedCover[] };
|
||
|
||
function fmtBytes(n: number): string {
|
||
if (n < 1024) return `${n} B`;
|
||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
||
}
|
||
|
||
export function UndersizedCoversButton() {
|
||
const [state, setState] = useState<State>({ kind: "idle" });
|
||
const [pending, start] = useTransition();
|
||
const { close: closeSettings } = useSettingsPanel();
|
||
|
||
const scan = () => {
|
||
setState({ kind: "scanning" });
|
||
start(async () => {
|
||
const rows = await scanUndersizedCovers();
|
||
setState({ kind: "result", rows });
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="py-2">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-medium">Find Undersized Covers</div>
|
||
<div className="text-xs text-[var(--color-fg-muted)] mt-0.5">
|
||
Scan top-level covers smaller than standard JAV size (default
|
||
floor is <code className="font-mono">750×500</code>; real covers are
|
||
usually <code className="font-mono">800×538</code>). Catches
|
||
thumbnails or web previews accidentally imported as covers.
|
||
</div>
|
||
{state.kind === "result" && state.rows.length === 0 && (
|
||
<div className="text-xs text-[var(--color-mint)] mt-2">
|
||
No undersized covers — all top-level covers meet the size threshold.
|
||
</div>
|
||
)}
|
||
{state.kind === "result" && state.rows.length > 0 && (
|
||
<div className="text-xs text-[var(--color-coral)] mt-2 flex items-center gap-1.5">
|
||
<AlertTriangle className="w-3.5 h-3.5" />
|
||
{state.rows.length} undersized cover{state.rows.length === 1 ? "" : "s"} found.
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex-shrink-0 flex items-center gap-2">
|
||
{state.kind === "idle" && (
|
||
<button
|
||
onClick={scan}
|
||
disabled={pending}
|
||
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
|
||
>
|
||
<Ruler className="w-3.5 h-3.5" /> Scan
|
||
</button>
|
||
)}
|
||
{state.kind === "scanning" && (
|
||
<span className="flex items-center gap-1.5 text-xs text-[var(--color-fg-dim)]">
|
||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Scanning…
|
||
</span>
|
||
)}
|
||
{state.kind === "result" && (
|
||
<>
|
||
<button
|
||
onClick={scan}
|
||
disabled={pending}
|
||
className="inline-flex items-center justify-center gap-1.5 min-w-[100px] text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] whitespace-nowrap"
|
||
>
|
||
Re-scan
|
||
</button>
|
||
<button
|
||
onClick={() => setState({ kind: "idle" })}
|
||
className="inline-flex items-center justify-center min-w-[80px] text-xs px-3 py-1.5 rounded-lg text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
|
||
>
|
||
Dismiss
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{state.kind === "result" && state.rows.length > 0 && (
|
||
<div className="mt-3 max-h-72 overflow-y-auto rounded-md border border-[var(--color-glass-border)] bg-[var(--color-bg-1)]/40">
|
||
{state.rows.map((r) => (
|
||
<Link
|
||
key={r.id}
|
||
href={`/image/${r.id}`}
|
||
onClick={closeSettings}
|
||
className="flex items-center gap-3 p-2 border-b border-[var(--color-glass-border)] last:border-b-0 hover:bg-[var(--color-glass)] transition-colors"
|
||
>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={thumbUrl({ thumbPath: r.thumbPath, code: r.code, id: r.id })}
|
||
alt=""
|
||
className="w-12 h-12 object-contain bg-black/40 rounded shrink-0"
|
||
loading="lazy"
|
||
/>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2 text-xs">
|
||
{r.code ? (
|
||
<span className="font-mono font-bold text-[var(--color-cyan)]">{r.code}</span>
|
||
) : (
|
||
<span className="font-mono text-[var(--color-fg-muted)] italic">no code</span>
|
||
)}
|
||
<span className="font-mono text-[var(--color-coral)] tabular-nums">
|
||
{r.width}×{r.height}
|
||
</span>
|
||
<span className="font-mono text-[var(--color-fg-muted)] tabular-nums">
|
||
{fmtBytes(r.bytes)}
|
||
</span>
|
||
</div>
|
||
<div className="text-[11px] text-[var(--color-fg-dim)] truncate font-mono mt-0.5">
|
||
{r.filename}
|
||
</div>
|
||
</div>
|
||
<ExternalLink className="w-3.5 h-3.5 text-[var(--color-fg-muted)] shrink-0" />
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|