Files
Momento/memento-note/components/agents/agent-card.tsx

321 lines
12 KiB
TypeScript

'use client'
/**
* Agent Card Component
* Compact card matching the reference design — with a "Next Run / Status" footer.
*/
import { useState, useEffect, useRef } from 'react'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import {
Play,
Trash2,
Loader2,
Globe,
Search,
Eye,
Settings,
CheckCircle2,
XCircle,
Clock,
Pencil,
Presentation,
} from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { getNotebookIcon } from '@/lib/notebook-icon'
// --- Types ---
interface AgentCardProps {
agent: {
id: string
name: string
description?: string | null
type?: string | null
isEnabled: boolean
frequency: string
lastRun: string | Date | null
nextRun?: string | Date | null
createdAt: string | Date
updatedAt: string | Date
_count: { actions: number }
actions: { id: string; status: string; createdAt: string | Date }[]
notebook?: { id: string; name: string; icon?: string | null } | null
}
onEdit: (id: string) => void
onRefresh: () => void
onToggle: (id: string, isEnabled: boolean) => void
}
// --- Config ---
/** Icône par type — tons neutres alignés sur le thème (encre / papier). */
const ICON_BOX = 'bg-primary/10 dark:bg-primary/15'
const ICON_MARK = 'text-primary'
const typeConfig: Record<string, { icon: typeof Globe; color: string; bgColor: string }> = {
scraper: { icon: Globe, color: ICON_MARK, bgColor: ICON_BOX },
researcher: { icon: Search, color: ICON_MARK, bgColor: ICON_BOX },
monitor: { icon: Eye, color: ICON_MARK, bgColor: ICON_BOX },
custom: { icon: Settings, color: ICON_MARK, bgColor: ICON_BOX },
'slide-generator': { icon: Presentation, color: ICON_MARK, bgColor: ICON_BOX },
'excalidraw-generator': { icon: Pencil, color: ICON_MARK, bgColor: ICON_BOX },
}
const frequencyKeys: Record<string, string> = {
manual: 'agents.frequencies.manual',
hourly: 'agents.frequencies.hourly',
daily: 'agents.frequencies.daily',
weekly: 'agents.frequencies.weekly',
monthly: 'agents.frequencies.monthly',
}
// --- Component ---
export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps) {
const { t, language } = useLanguage()
const [isRunning, setIsRunning] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isToggling, setIsToggling] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
const config = typeConfig[agent.type || 'scraper'] || typeConfig.custom
const Icon = config.icon
const lastAction = agent.actions[0]
const dateLocale = language === 'fr' ? fr : enUS
const isNew = Date.now() - new Date(agent.createdAt).getTime() < 5 * 60 * 1000
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Cleanup polling on unmount
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
const handleRun = async () => {
setIsRunning(true)
const toastId = toast.loading(
t('agents.toasts.running') || `Agent "${agent.name}" en cours…`,
{ description: t('agents.toasts.runningDesc') || 'La génération peut prendre quelques minutes.', duration: Infinity }
)
try {
const { runAgent } = await import('@/app/actions/agent-actions')
const result = await runAgent(agent.id)
if (!result.success) {
toast.error(t('agents.toasts.runError', { error: result.error || t('agents.toasts.runFailed') }), { id: toastId })
setIsRunning(false)
return
}
// Poll status every 3 s until terminal state
if (pollRef.current) clearInterval(pollRef.current)
pollRef.current = setInterval(async () => {
try {
const res = await fetch(`/api/agents/run-for-note?agentId=${agent.id}`)
const data = await res.json()
if (data.status === 'success') {
clearInterval(pollRef.current!)
pollRef.current = null
setIsRunning(false)
toast.success(t('agents.toasts.runSuccess', { name: agent.name }), { id: toastId, duration: 6000 })
onRefresh()
} else if (data.status === 'failure') {
clearInterval(pollRef.current!)
pollRef.current = null
setIsRunning(false)
toast.error(t('agents.toasts.runError', { error: data.error || t('agents.toasts.runFailed') }), { id: toastId })
onRefresh()
}
} catch { /* network error — keep polling */ }
}, 3000)
} catch {
toast.error(t('agents.toasts.runGenericError'), { id: toastId })
setIsRunning(false)
}
}
const handleDelete = async () => {
if (!confirm(t('agents.actions.deleteConfirm', { name: agent.name }))) return
setIsDeleting(true)
try {
const { deleteAgent } = await import('@/app/actions/agent-actions')
await deleteAgent(agent.id)
toast.success(t('agents.toasts.deleted', { name: agent.name }))
} catch {
toast.error(t('agents.toasts.deleteError'))
} finally {
setIsDeleting(false)
onRefresh()
}
}
const handleToggle = async () => {
const newEnabled = !agent.isEnabled
setIsToggling(true)
onToggle(agent.id, newEnabled)
try {
const { toggleAgent } = await import('@/app/actions/agent-actions')
await toggleAgent(agent.id, newEnabled)
toast.success(newEnabled ? t('agents.actions.toggleOn') : t('agents.actions.toggleOff'))
} catch {
onToggle(agent.id, !newEnabled)
toast.error(t('agents.toasts.toggleError'))
} finally {
setIsToggling(false)
}
}
// Derive "Next Run" label
const nextRunLabel = (() => {
if (!agent.isEnabled) return '—'
if (agent.frequency === 'manual') return t('agents.frequencies.manual')
if (agent.nextRun && new Date(agent.nextRun) > new Date()) {
if (!mounted) return '...'
return formatDistanceToNow(new Date(agent.nextRun), { addSuffix: true, locale: dateLocale })
}
return t(frequencyKeys[agent.frequency] || 'agents.frequencies.manual')
})()
return (
<div className={`
font-display group flex flex-col bg-card rounded-lg border transition-all duration-200
${agent.isEnabled
? 'border-border/40 hover:border-primary/25 hover:shadow-[0_2px_12px_color-mix(in_oklab,var(--foreground)_7%,transparent)]'
: 'border-border/30 opacity-60'
}
`}>
{/* Card body */}
<div className="p-4 flex flex-col gap-3 flex-1">
{/* Header row: icon + name/type + toggle */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${config.bgColor}`}>
<Icon className={`w-5 h-5 ${config.color}`} />
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<h3 className="font-semibold text-sm text-card-foreground truncate leading-tight">{agent.name}</h3>
{mounted && isNew && (
<span className="flex-shrink-0 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider bg-muted text-muted-foreground rounded border border-border/60">
{t('agents.newBadge')}
</span>
)}
</div>
<span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
{t(`agents.types.${agent.type || 'custom'}`)}
</span>
</div>
</div>
{/* Toggle */}
<button
onClick={handleToggle}
disabled={isToggling}
className="flex-shrink-0 disabled:opacity-50"
title={agent.isEnabled ? t('agents.actions.toggleOff') : t('agents.actions.toggleOn')}
>
<div className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
agent.isEnabled ? 'bg-primary' : 'bg-muted-foreground/30'
}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
agent.isEnabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`} />
</div>
</button>
</div>
{/* Description */}
{agent.description && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">{agent.description}</p>
)}
{/* Meta: frequency + executions */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{t(frequencyKeys[agent.frequency] || 'agents.frequencies.manual')}
</span>
<span>·</span>
<span>{t('agents.metadata.executions', { count: agent._count.actions })}</span>
{agent.notebook && (
<>
<span>·</span>
<span className="flex items-center gap-1">
{(() => {
const NbIcon = getNotebookIcon(agent.notebook!.icon)
return <NbIcon className="w-3 h-3" />
})()}
{agent.notebook.name}
</span>
</>
)}
</div>
</div>
{/* Footer: Next Run + Last Status */}
<div className="border-t border-border/30 grid grid-cols-2 divide-x divide-border/30">
<div className="px-4 py-2.5">
<p className="text-[10px] text-muted-foreground font-medium mb-0.5">{t('agents.status.nextRun')}</p>
<p className="text-xs font-semibold text-foreground flex items-center gap-1">
<Clock className="w-3 h-3 text-muted-foreground/60" />
{nextRunLabel}
</p>
</div>
<div className="px-4 py-2.5">
<p className="text-[10px] text-muted-foreground font-medium mb-0.5">{t('agents.status.lastStatus')}</p>
{lastAction ? (
<span className={`inline-flex items-center gap-1.5 text-xs font-semibold ${
lastAction.status === 'success' ? 'text-primary' :
lastAction.status === 'failure' ? 'text-destructive' :
lastAction.status === 'running' ? 'text-primary' :
'text-muted-foreground'
}`}>
{lastAction.status === 'success' && <CheckCircle2 className="w-3 h-3" />}
{lastAction.status === 'failure' && <XCircle className="w-3 h-3" />}
{lastAction.status === 'running' && <Loader2 className="w-3 h-3 animate-spin" />}
{lastAction.status === 'success' && t('agents.status.success')}
{lastAction.status === 'failure' && t('agents.status.failure')}
{lastAction.status === 'running' && t('agents.status.running')}
{lastAction.status === 'pending' && t('agents.status.pending')}
</span>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
</div>
{/* Actions row */}
<div className="border-t border-border/30 flex items-center px-4 py-2 gap-2">
<button
onClick={() => onEdit(agent.id)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium text-muted-foreground bg-muted/40 hover:bg-muted/80 rounded-md transition-colors"
>
<Pencil className="w-3 h-3" />
{t('agents.actions.edit')}
</button>
<button
onClick={handleRun}
disabled={isRunning || !agent.isEnabled}
className="p-1.5 text-primary bg-primary/10 rounded-md hover:bg-primary/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title={t('agents.actions.run')}
>
{isRunning ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
</button>
<button
onClick={handleDelete}
disabled={isDeleting}
className="p-1.5 text-destructive bg-destructive/10 rounded-md hover:bg-destructive/20 transition-colors disabled:opacity-40"
title={t('agents.actions.delete')}
>
{isDeleting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
</button>
</div>
</div>
)
}