Files
igny8/frontend/src/components/common/IntegrationCard.tsx
2025-11-09 10:27:02 +00:00

217 lines
9.5 KiB
TypeScript

import { ReactNode } from 'react';
import Switch from '../form/switch/Switch';
import Button from '../ui/button/Button';
import { usePersistentToggle } from '../../hooks/usePersistentToggle';
import { useToast } from '../ui/toast/ToastContainer';
type ValidationStatus = 'not_configured' | 'pending' | 'success' | 'error';
interface IntegrationCardProps {
icon: ReactNode;
title: string;
description: string;
enabled?: boolean; // Optional - if not provided, will use persistent toggle hook
validationStatus: ValidationStatus; // 'not_configured' | 'pending' | 'success' | 'error'
onToggle?: (enabled: boolean) => void; // Optional - if not provided, will use persistent toggle hook
onSettings: () => void;
onDetails: () => void;
// Optional props for built-in persistence
integrationId?: string; // If provided, enables built-in persistence
getEndpoint?: string; // API endpoint pattern for loading (default if integrationId provided)
saveEndpoint?: string; // API endpoint pattern for saving (default if integrationId provided)
onToggleSuccess?: (enabled: boolean, data?: any) => void; // Callback when toggle succeeds - receives enabled state and full config data
onToggleError?: (error: Error) => void; // Callback when toggle fails
modelName?: string; // For Runware: display model name instead of status circle
}
export default function IntegrationCard({
icon,
title,
description,
enabled: externalEnabled,
validationStatus,
onToggle: externalOnToggle,
onSettings,
onDetails,
integrationId,
getEndpoint,
saveEndpoint,
onToggleSuccess: externalOnToggleSuccess,
onToggleError: externalOnToggleError,
modelName,
}: IntegrationCardProps) {
const toast = useToast();
// Use built-in persistent toggle if integrationId is provided
// This hook automatically loads state on mount and saves on toggle
// When using built-in persistence, we IGNORE external enabled prop to avoid conflicts
const persistentToggle = integrationId ? usePersistentToggle({
resourceId: integrationId,
getEndpoint: getEndpoint || '/v1/system/settings/integrations/{id}/',
saveEndpoint: saveEndpoint || '/v1/system/settings/integrations/{id}/save/',
initialEnabled: false, // Always start with false, let hook load from API
onToggleSuccess: (enabled, data) => {
// Show success toast
toast.success(`${integrationId} ${enabled ? 'enabled' : 'disabled'}`);
// Call external callbacks if provided - pass both enabled state and full config data
if (externalOnToggleSuccess) {
externalOnToggleSuccess(enabled, data);
}
// Don't call external onToggle when using built-in persistence
// The hook manages its own state, parent should not interfere
},
onToggleError: (error) => {
toast.error(`Failed to update ${integrationId}: ${error.message}`);
if (externalOnToggleError) {
externalOnToggleError(error);
}
},
}) : null;
// Determine which enabled state and toggle function to use
// When integrationId is provided, hook is the SINGLE source of truth
// When not provided, use external prop (backwards compatible)
const enabled = persistentToggle
? persistentToggle.enabled
: (externalEnabled ?? false);
const handleToggle = persistentToggle
? (newEnabled: boolean) => {
// Built-in persistence - automatically saves to backend
persistentToggle.toggle(newEnabled);
}
: (newEnabled: boolean) => {
// External handler mode - parent manages state
if (externalOnToggle) {
externalOnToggle(newEnabled);
}
};
const isToggling = persistentToggle ? persistentToggle.loading : false;
// Determine status circle color
const getStatusColor = () => {
if (!enabled || validationStatus === 'not_configured') {
return 'bg-gray-400 dark:bg-gray-500'; // Grey for disabled or not configured
}
if (validationStatus === 'pending') {
return 'bg-gray-400 dark:bg-gray-500 animate-pulse'; // Grey while validating (with pulse)
}
if (validationStatus === 'success') {
return 'bg-green-500 dark:bg-green-600'; // Green for success
}
if (validationStatus === 'error') {
return 'bg-red-500 dark:bg-red-600'; // Red for error
}
return 'bg-gray-400 dark:bg-gray-500'; // Default grey
};
// Get status text and color
const getStatusText = () => {
if (!enabled || validationStatus === 'not_configured') {
return { text: 'Disabled', color: 'text-gray-400 dark:text-gray-500', bold: false };
}
if (validationStatus === 'pending') {
return { text: 'Pending', color: 'text-gray-400 dark:text-gray-500', bold: false };
}
if (validationStatus === 'success') {
return { text: 'Enabled', color: 'text-gray-800 dark:text-white', bold: true };
}
if (validationStatus === 'error') {
return { text: 'Error', color: 'text-red-600 dark:text-red-400', bold: false };
}
return { text: 'Disabled', color: 'text-gray-400 dark:text-gray-500', bold: false };
};
const statusText = getStatusText();
return (
<article className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="relative p-5 pb-9">
<div className="mb-5 inline-flex h-10 w-10 items-center justify-center">
{icon}
</div>
<h3 className="mb-3 text-lg font-semibold text-gray-800 dark:text-white/90">
{title}
</h3>
<p className="max-w-xs text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
{/* Status Text and Circle - Same row */}
{/* For Runware: Show model name instead of circle */}
{integrationId === 'runware' ? (
<div className="absolute top-5 right-5">
<span className={`text-sm font-semibold ${modelName ? 'text-gray-800 dark:text-white' : 'text-gray-400 dark:text-gray-500'}`}>
{modelName || 'Disabled'}
</span>
</div>
) : (
<div className="absolute top-5 right-5 flex items-center gap-2">
<span className={`text-sm ${statusText.color} ${statusText.bold ? 'font-bold' : ''} transition-colors duration-200`}>
{statusText.text}
</span>
<div className={`w-[25px] h-[25px] rounded-full ${getStatusColor()} transition-colors duration-200`}
title={
validationStatus === 'not_configured' ? 'Not configured' :
validationStatus === 'pending' ? 'Validating...' :
validationStatus === 'success' ? 'Validated successfully' :
validationStatus === 'error' ? 'Validation failed' : 'Unknown status'
}
/>
</div>
)}
</div>
<div className="flex items-center justify-between border-t border-gray-200 p-5 dark:border-gray-800">
<div className="flex gap-3">
<Button
variant="outline"
size="md"
onClick={onSettings}
className="shadow-theme-xs inline-flex h-11 w-11 items-center justify-center rounded-lg border border-gray-300 text-gray-700 dark:border-gray-700 dark:text-gray-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path
d="M5.64615 4.59906C5.05459 4.25752 4.29808 4.46015 3.95654 5.05171L2.69321 7.23986C2.35175 7.83128 2.5544 8.58754 3.14582 8.92899C3.97016 9.40493 3.97017 10.5948 3.14583 11.0707C2.55441 11.4122 2.35178 12.1684 2.69323 12.7598L3.95657 14.948C4.2981 15.5395 5.05461 15.7422 5.64617 15.4006C6.4706 14.9247 7.50129 15.5196 7.50129 16.4715C7.50129 17.1545 8.05496 17.7082 8.73794 17.7082H11.2649C11.9478 17.7082 12.5013 17.1545 12.5013 16.4717C12.5013 15.5201 13.5315 14.9251 14.3556 15.401C14.9469 15.7423 15.7029 15.5397 16.0443 14.9485L17.3079 12.7598C17.6494 12.1684 17.4467 11.4121 16.8553 11.0707C16.031 10.5948 16.031 9.40494 16.8554 8.92902C17.4468 8.58757 17.6494 7.83133 17.3079 7.23992L16.0443 5.05123C15.7029 4.45996 14.9469 4.25737 14.3556 4.59874C13.5315 5.07456 12.5013 4.47961 12.5013 3.52798C12.5013 2.84515 11.9477 2.2915 11.2649 2.2915L8.73795 2.2915C8.05496 2.2915 7.50129 2.84518 7.50129 3.52816C7.50129 4.48015 6.47059 5.07505 5.64615 4.59906Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.5714 9.99977C12.5714 11.4196 11.4204 12.5706 10.0005 12.5706C8.58069 12.5706 7.42969 11.4196 7.42969 9.99977C7.42969 8.57994 8.58069 7.42894 10.0005 7.42894C11.4204 7.42894 12.5714 8.57994 12.5714 9.99977Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Button>
<Button
variant="outline"
size="md"
onClick={onDetails}
className="shadow-theme-xs inline-flex h-11 items-center justify-center rounded-lg border border-gray-300 px-4 py-3 text-sm font-medium text-gray-700 dark:border-gray-700 dark:text-gray-400"
>
Details
</Button>
</div>
<Switch
label=""
checked={enabled}
disabled={isToggling}
onChange={handleToggle}
/>
</div>
</article>
);
}