import { useEffect, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { useLocation, useNavigate } from "react-router-dom"; import { ApiError } from "../api/client"; import type { Job } from "../api/types"; export function errorMessage(e: unknown, fallback = "Something went wrong. Try again."): string { return e instanceof ApiError ? e.message : e instanceof Error && e.message ? e.message : fallback; } /** Matches blog2video.NOT_RESPONDING on the backend: the video service is down or redeploying. */ export const VIDEO_UPDATING = "The video service is being updated"; /** * An inline error. The "video service is being updated" message is a status, not a mistake: it is shown centered * and in bold instead, and with `page` it fills the page under the header. */ export function ErrorText({ message, page = false }: { message: string; page?: boolean }) { if (message.startsWith(VIDEO_UPDATING)) { return (
{message}
); } return

{message}

; } /** Launch progress bar for a job. */ export function JobProgress({ job, compact = false }: { job: Job; compact?: boolean }) { const pct = Math.round((job.status === "done" ? 1 : job.progress) * 100); const failed = job.status === "failed"; const label = failed ? job.error ?? "Failed" : job.status === "queued" ? job.message ?? "Queued for launch" : job.message ?? "Working"; return (
{label} {!failed && {pct}%}
); } export function Modal({ title, onClose, children, wide = false, actions, }: { title: string; onClose: () => void; children: ReactNode; wide?: boolean; /** Buttons for the header's right side, in place of the close button. */ actions?: ReactNode; }) { const ref = useRef(null); // The latest onClose, so a parent passing a new function each render neither re-binds Escape nor (worse) pulls focus // back to the dialog, away from whatever is being typed in. const close = useRef(onClose); close.current = onClose; useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; // A modal opened from a modal (a picker over the composer): Escape closes only the top one. const open = document.querySelectorAll(".modal-backdrop"); if (open[open.length - 1] === ref.current?.parentElement) close.current(); }; document.addEventListener("keydown", onKey); ref.current?.focus(); // once, on opening return () => document.removeEventListener("keydown", onKey); }, []); // Rendered into : a parent with backdrop-filter or transform (cards have one) would otherwise become the // containing block of this fixed backdrop and trap the modal inside that card. return createPortal(
e.target === e.currentTarget && onClose()}>

{title}

{actions ?? ( )}
{children}
, document.body, ); } export function Drawer({ onClose, children, label }: { onClose: () => void; children: ReactNode; label: string }) { useEffect(() => { const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [onClose]); return (
e.target === e.currentTarget && onClose()}>
); } export function Tabs({ tabs, value, onChange, }: { tabs: { id: T; label: string; count?: number }[]; value: T; onChange: (id: T) => void; }) { return (
{tabs.map((t) => ( ))}
); } export function CopyButton({ text, label = "Copy" }: { text: string; label?: string }) { const [copied, setCopied] = useState(false); return ( ); } /** Two step destructive button: first click arms it, second confirms. No browser dialogs. */ /** "Delete this?": deletes only on confirm, shows what went wrong if it fails, and closes once it is gone. */ export function ConfirmDeleteModal({ heading, name, onCancel, onConfirm }: { heading: string; name: string; onCancel: () => void; onConfirm: () => Promise; }) { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const confirm = async () => { setBusy(true); setError(null); try { await onConfirm(); onCancel(); // gone: close the warning } catch (e) { setError(errorMessage(e)); setBusy(false); } }; return ( undefined : onCancel}>

{name} will be deleted for good. This cannot be undone.

{error &&

{error}

}
); } export function ConfirmButton({ onConfirm, children, confirmLabel = "Click again to confirm", className = "btn btn-small btn-danger", label, busyLabel = "Working...", }: { /** What the button says while the confirmed action runs. */ busyLabel?: string; onConfirm: () => unknown; // the result (e.g. act's success flag) is awaited, not used children: ReactNode; confirmLabel?: string; className?: string; /** Accessible name and tooltip, for an icon-only button. */ label?: string; }) { const [armed, setArmed] = useState(false); const [busy, setBusy] = useState(false); useEffect(() => { if (!armed) return; const t = setTimeout(() => setArmed(false), 4000); return () => clearTimeout(t); }, [armed]); return ( ); } export function EmptyState({ title, body, action }: { title: string; body?: string; action?: ReactNode }) { return (

{title}

{body &&

{body}

} {action}
); } /** * Spinner with its label underneath, centered. `center="page"` puts it in the middle of the space under a page * header; `center="full"` in the middle of the window, for a page that has no header yet. */ export function Loading({ label = "Loading", center }: { label?: string; center?: "page" | "full" }) { const loader = (
{label}...
); return center ?
{loader}
: loader; } export function StatusPill({ status }: { status: string }) { const tone = ["ready", "ok", "done", "posted", "reminded", "connected"].includes(status) ? "ok" : ["failed", "error"].includes(status) ? "bad" : ["paused", "expired", "revoked", "disconnected"].includes(status) ? "warn" : "busy"; return {status}; } /** An arrow back to exactly the page before (browser history); `fallback` when the page was opened directly. */ export function BackArrow({ fallback, label = "Back" }: { fallback: string; label?: string }) { const navigate = useNavigate(); const location = useLocation(); return ( ); } export function PageHeader({ eyebrow, title, children }: { eyebrow: string; title: string; children?: ReactNode }) { return (

{eyebrow}

{title}

{children &&
{children}
}
); } export function formatDate(iso: string | null | undefined, withTime = false): string { if (!iso) return "Undated"; const d = new Date(iso); return withTime ? d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); } export function formatDuration(seconds: number | undefined): string { if (!seconds) return ""; const m = Math.floor(seconds / 60); const s = Math.round(seconds % 60); return `${m}:${String(s).padStart(2, "0")}`; }