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 (
);
}
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(
);
}
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")}`;
}