Les boutons suivent la couleur d’apparence, les libellés trop petits ou trop techniques sont clarifiés, et le catalogue des fournisseurs se met à jour tout seul. Co-authored-by: Cursor <cursoragent@cursor.com>
250 lines
7.7 KiB
TypeScript
250 lines
7.7 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useRef } from 'react'
|
|
import { motion, AnimatePresence } from 'motion/react'
|
|
import { Search, Bot, ChevronLeft, ChevronRight, Loader2, Check } from 'lucide-react'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
|
|
|
export interface AgentSuggestion {
|
|
id: string
|
|
topic: string
|
|
reason: string
|
|
relatedNoteCount: number
|
|
suggestedFrequency: string
|
|
}
|
|
|
|
export interface CreatedAgentNotice {
|
|
id: string | null
|
|
topic: string
|
|
}
|
|
|
|
export interface DashboardAgentCarouselProps {
|
|
suggestions: AgentSuggestion[]
|
|
loading?: boolean
|
|
actingId: string | null
|
|
formatFrequency: (f: string) => string
|
|
onAccept: (id: string) => void
|
|
onDismiss: (id: string) => void
|
|
createdAgent?: CreatedAgentNotice | null
|
|
onOpenCreated?: () => void
|
|
onClearCreated?: () => void
|
|
prefersReducedMotion?: boolean
|
|
}
|
|
|
|
export function DashboardAgentCarousel({
|
|
suggestions,
|
|
loading,
|
|
actingId,
|
|
formatFrequency,
|
|
onAccept,
|
|
onDismiss,
|
|
createdAgent,
|
|
onOpenCreated,
|
|
onClearCreated,
|
|
prefersReducedMotion,
|
|
}: DashboardAgentCarouselProps) {
|
|
const { t } = useLanguage()
|
|
const [idx, setIdx] = useState(0)
|
|
const directionRef = useRef(1)
|
|
const safeIdx = suggestions.length === 0 ? 0 : Math.min(idx, suggestions.length - 1)
|
|
|
|
const goPrev = () => {
|
|
directionRef.current = -1
|
|
setIdx(i => Math.max(0, i - 1))
|
|
}
|
|
const goNext = () => {
|
|
directionRef.current = 1
|
|
setIdx(i => Math.min(suggestions.length - 1, i + 1))
|
|
}
|
|
|
|
const showCreated = !!createdAgent
|
|
const showNav = !showCreated && suggestions.length > 1
|
|
|
|
const navActions = showNav ? (
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={goPrev}
|
|
disabled={safeIdx === 0}
|
|
className="p-1 rounded border border-border/30 disabled:opacity-25"
|
|
aria-label={t('homeDashboard.intelPrev')}
|
|
>
|
|
<ChevronLeft size={12} />
|
|
</button>
|
|
<span className="text-[8px] font-mono text-concrete px-1">
|
|
{safeIdx + 1}/{suggestions.length}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={goNext}
|
|
disabled={safeIdx >= suggestions.length - 1}
|
|
className="p-1 rounded border border-border/30 disabled:opacity-25"
|
|
aria-label={t('homeDashboard.intelNext')}
|
|
>
|
|
<ChevronRight size={12} />
|
|
</button>
|
|
</div>
|
|
) : null
|
|
|
|
if (loading) {
|
|
return <div className="h-[140px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4">
|
|
<DashboardWidgetTitleRow
|
|
widgetId="agents"
|
|
icon={<Bot size={12} className="text-brand-accent" />}
|
|
title={t('homeDashboard.suggestedResearch')}
|
|
actions={navActions}
|
|
wrapTitle
|
|
/>
|
|
|
|
{showCreated && createdAgent ? (
|
|
<CreatedAgentPanel
|
|
topic={createdAgent.topic}
|
|
hasMore={suggestions.length > 0}
|
|
onOpen={onOpenCreated}
|
|
onSeeNext={onClearCreated}
|
|
t={t}
|
|
/>
|
|
) : suggestions.length === 0 ? (
|
|
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-3">
|
|
<p className="text-[11px] text-concrete leading-relaxed">
|
|
{t('homeDashboard.agentsEmpty')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<AgentSlide
|
|
current={suggestions[safeIdx]}
|
|
direction={directionRef.current}
|
|
actingId={actingId}
|
|
formatFrequency={formatFrequency}
|
|
onAccept={onAccept}
|
|
onDismiss={onDismiss}
|
|
prefersReducedMotion={prefersReducedMotion}
|
|
t={t}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CreatedAgentPanel({
|
|
topic,
|
|
hasMore,
|
|
onOpen,
|
|
onSeeNext,
|
|
t,
|
|
}: {
|
|
topic: string
|
|
hasMore: boolean
|
|
onOpen?: () => void
|
|
onSeeNext?: () => void
|
|
t: (key: string) => string
|
|
}) {
|
|
return (
|
|
<div className="p-3.5 rounded-xl border border-border/25 bg-stone-50/60 dark:bg-zinc-950/40">
|
|
<div className="flex items-start gap-2 mb-2">
|
|
<Check size={14} className="text-brand-accent shrink-0 mt-0.5" />
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-semibold text-ink dark:text-dark-ink leading-snug">
|
|
{t('homeDashboard.agentCreatedInCard')}
|
|
</p>
|
|
{topic ? (
|
|
<p className="text-[11px] text-concrete leading-relaxed mt-1">{topic}</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 mt-3">
|
|
{hasMore && onSeeNext ? (
|
|
<button
|
|
type="button"
|
|
onClick={onSeeNext}
|
|
className="text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg border border-border/40 text-concrete hover:text-ink transition-colors"
|
|
>
|
|
{t('homeDashboard.agentSeeNextSuggestion')}
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
onClick={onOpen}
|
|
className="flex-1 inline-flex items-center justify-center gap-1 text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg bg-brand-accent text-white font-bold hover:bg-brand-accent/90"
|
|
>
|
|
{t('homeDashboard.agentOpenCreated')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AgentSlide({
|
|
current,
|
|
direction,
|
|
actingId,
|
|
formatFrequency,
|
|
onAccept,
|
|
onDismiss,
|
|
prefersReducedMotion,
|
|
t,
|
|
}: {
|
|
current: AgentSuggestion
|
|
direction: number
|
|
actingId: string | null
|
|
formatFrequency: (f: string) => string
|
|
onAccept: (id: string) => void
|
|
onDismiss: (id: string) => void
|
|
prefersReducedMotion?: boolean
|
|
t: (key: string) => string
|
|
}) {
|
|
const slide = prefersReducedMotion
|
|
? { initial: {}, animate: {}, exit: {} }
|
|
: {
|
|
initial: { opacity: 0, x: 14 * direction },
|
|
animate: { opacity: 1, x: 0 },
|
|
exit: { opacity: 0, x: -14 * direction },
|
|
}
|
|
|
|
return (
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={current.id}
|
|
initial={slide.initial}
|
|
animate={slide.animate}
|
|
exit={slide.exit}
|
|
transition={{ duration: prefersReducedMotion ? 0 : 0.18 }}
|
|
className="p-3.5 rounded-xl border border-border/25 bg-stone-50/60 dark:bg-zinc-950/40"
|
|
>
|
|
<div className="flex items-start gap-2 mb-2">
|
|
<Search size={12} className="text-brand-accent shrink-0 mt-0.5" />
|
|
<p className="text-sm font-semibold text-ink dark:text-dark-ink leading-snug">{current.topic}</p>
|
|
</div>
|
|
<p className="text-[11px] text-concrete leading-relaxed line-clamp-2 mb-3">{current.reason}</p>
|
|
<p className="text-[8px] font-mono uppercase text-concrete mb-3">
|
|
{current.relatedNoteCount} {t('homeDashboard.notes')} · {formatFrequency(current.suggestedFrequency)}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
disabled={actingId === current.id}
|
|
onClick={() => onDismiss(current.id)}
|
|
className="text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg border border-border/40 text-concrete hover:text-ink transition-colors disabled:opacity-40"
|
|
>
|
|
{t('homeDashboard.dismiss')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={actingId === current.id}
|
|
onClick={() => onAccept(current.id)}
|
|
className="flex-1 inline-flex items-center justify-center gap-1 text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg bg-brand-accent text-white font-bold hover:bg-brand-accent/90 disabled:opacity-40"
|
|
>
|
|
{actingId === current.id ? <Loader2 size={10} className="animate-spin" /> : null}
|
|
{t('homeDashboard.createAgent')}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
)
|
|
}
|