Files
lidify/frontend/components/ui/InlineStatus.tsx
2025-12-25 18:58:06 -06:00

215 lines
5.8 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { Check, X, Loader2, AlertCircle } from "lucide-react";
import { cn } from "@/utils/cn";
export type StatusType = "idle" | "loading" | "success" | "error";
interface InlineStatusProps {
status: StatusType;
message?: string;
className?: string;
showIcon?: boolean;
autoClear?: boolean; // Auto-clear success/error after delay
clearDelay?: number; // Delay in ms before clearing (default 3000)
onClear?: () => void;
}
/**
* Inline status indicator for form fields and buttons
* Shows success/error/loading states without overlay toasts
*/
export function InlineStatus({
status,
message,
className,
showIcon = true,
autoClear = true,
clearDelay = 3000,
onClear,
}: InlineStatusProps) {
const [visible, setVisible] = useState(status !== "idle");
useEffect(() => {
if (status === "success" || status === "error") {
setVisible(true);
if (autoClear) {
const timer = setTimeout(() => {
setVisible(false);
onClear?.();
}, clearDelay);
return () => clearTimeout(timer);
}
} else if (status === "loading") {
setVisible(true);
} else {
setVisible(false);
}
}, [status, autoClear, clearDelay, onClear]);
if (status === "idle" || !visible) return null;
return (
<span
className={cn(
"inline-flex items-center gap-1.5 text-sm",
status === "success" && "text-emerald-400",
status === "error" && "text-red-400",
status === "loading" && "text-white/60",
className
)}
aria-live="polite"
>
{showIcon && (
<>
{status === "success" && <Check className="w-4 h-4" />}
{status === "error" && <X className="w-4 h-4" />}
{status === "loading" && <Loader2 className="w-4 h-4 animate-spin" />}
</>
)}
{message && <span>{message}</span>}
</span>
);
}
/**
* Hook for managing inline status state
*/
export function useInlineStatus(initialStatus: StatusType = "idle") {
const [status, setStatus] = useState<StatusType>(initialStatus);
const [message, setMessage] = useState<string>("");
const setSuccess = (msg?: string) => {
setStatus("success");
setMessage(msg || "");
};
const setError = (msg?: string) => {
setStatus("error");
setMessage(msg || "");
};
const setLoading = (msg?: string) => {
setStatus("loading");
setMessage(msg || "");
};
const reset = () => {
setStatus("idle");
setMessage("");
};
return {
status,
message,
setSuccess,
setError,
setLoading,
reset,
props: { status, message, onClear: reset },
};
}
/**
* Connection test button with inline status
*/
interface ConnectionTestButtonProps {
label: string;
onTest: () => Promise<boolean | string | null>;
className?: string;
disabled?: boolean;
}
export function ConnectionTestButton({
label,
onTest,
className,
disabled,
}: ConnectionTestButtonProps) {
const { status, message, setSuccess, setError, setLoading, reset, props } = useInlineStatus();
const handleTest = async () => {
setLoading("Testing...");
try {
const result = await onTest();
if (result === false || result === null) {
setError("Failed");
} else if (typeof result === "string") {
setSuccess(result);
} else {
setSuccess("Connected");
}
} catch (err: any) {
setError(err.message || "Failed");
}
};
return (
<div className="flex items-center gap-3">
<button
onClick={handleTest}
disabled={disabled || status === "loading"}
className={cn(
"px-3 py-1.5 text-sm rounded-md transition-colors",
"bg-white/10 hover:bg-white/15 text-white/70 hover:text-white",
"disabled:opacity-50 disabled:cursor-not-allowed",
className
)}
>
{status === "loading" ? "Testing..." : label}
</button>
<InlineStatus {...props} showIcon={true} />
</div>
);
}
/**
* Save button with inline status
*/
interface SaveButtonProps {
onSave: () => Promise<void>;
label?: string;
className?: string;
disabled?: boolean;
}
export function SaveButton({
onSave,
label = "Save",
className,
disabled,
}: SaveButtonProps) {
const { status, setSuccess, setError, setLoading, props } = useInlineStatus();
const handleSave = async () => {
setLoading();
try {
await onSave();
setSuccess("Saved");
} catch (err: any) {
setError(err.message || "Failed");
}
};
return (
<div className="flex items-center gap-3">
<button
onClick={handleSave}
disabled={disabled || status === "loading"}
className={cn(
"px-4 py-2 rounded-lg font-medium transition-colors",
"bg-amber-500 hover:bg-amber-400 text-black",
"disabled:opacity-50 disabled:cursor-not-allowed",
className
)}
>
{status === "loading" ? "Saving..." : label}
</button>
<InlineStatus {...props} showIcon={true} autoClear={true} />
</div>
);
}