Initial release v1.0.0
This commit is contained in:
214
frontend/components/ui/InlineStatus.tsx
Normal file
214
frontend/components/ui/InlineStatus.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user