Files
contexta_fe/src/pages/DashboardPage.tsx
belviskhoremk cec36ee298 fixed bugs
2026-02-23 16:46:51 +00:00

324 lines
13 KiB
TypeScript

import React, { useState, useCallback } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { chatbotsAPI } from '@/services/api'
import { useAuthStore } from '@/store/authStore'
import { Button, Card, Badge, StatusDot, EmptyState, Modal, Spinner } from '@/components/ui'
import { formatDate, getFileIcon, cn } from '@/lib/utils'
import type { Chatbot } from '@/types'
import {
Bot, Plus, MoreHorizontal, Globe, Lock, Trash2,
Settings, Upload, Eye, ExternalLink, Download, BarChart2
} from 'lucide-react'
// BUG-05 FIX: Toast queue system using array + auto-dismiss
interface ToastItem {
id: string
message: string
}
export const DashboardPage: React.FC = () => {
const { user } = useAuthStore()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [deleteId, setDeleteId] = useState<string | null>(null)
const [toasts, setToasts] = useState<ToastItem[]>([])
// BUG-05 FIX: Queue-based toast - no overwrites
const showToast = useCallback((message: string) => {
const id = crypto.randomUUID()
setToasts(prev => [...prev, { id, message }])
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 3000)
}, [])
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
const { data: chatbots = [], isLoading } = useQuery({
queryKey: ['chatbots'],
queryFn: chatbotsAPI.list,
})
const deleteMutation = useMutation({
mutationFn: chatbotsAPI.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
setDeleteId(null)
showToast('Chatbot deleted')
},
})
const publishMutation = useMutation({
mutationFn: chatbotsAPI.publish,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
showToast('Chatbot published to marketplace!')
},
})
const unpublishMutation = useMutation({
mutationFn: chatbotsAPI.unpublish,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
showToast('Chatbot unpublished')
},
})
// IMP-11: Confirmation before publish/unpublish
const [confirmAction, setConfirmAction] = useState<{ type: 'publish' | 'unpublish'; id: string } | null>(null)
const handleConfirmAction = () => {
if (!confirmAction) return
if (confirmAction.type === 'publish') {
publishMutation.mutate(confirmAction.id)
} else {
unpublishMutation.mutate(confirmAction.id)
}
setConfirmAction(null)
}
return (
<div className="p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-3">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-sm text-gray-500 mt-0.5">Manage your AI chatbots</p>
</div>
<Button onClick={() => navigate('/chatbots/new')}>
<Plus className="w-4 h-4" />
New Chatbot
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Spinner className="text-primary-600" />
</div>
) : chatbots.length === 0 ? (
<EmptyState
icon={<Bot className="w-8 h-8" />}
title="No chatbots yet"
description="Create your first AI chatbot powered by your documents. It's free to build and test."
action={
<Button onClick={() => navigate('/chatbots/new')} size="lg">
<Plus className="w-4 h-4" />
Create your first chatbot
</Button>
}
/>
) : (
// R-02 FIX: Better responsive grid breakpoints
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{chatbots.map((chatbot) => (
<ChatbotCard
key={chatbot.id}
chatbot={chatbot}
onEdit={() => navigate(`/chatbots/${chatbot.id}/edit`)}
onPreview={() => navigate(`/chatbots/${chatbot.id}/preview`)}
onPublish={() => setConfirmAction({ type: 'publish', id: chatbot.id })}
onUnpublish={() => setConfirmAction({ type: 'unpublish', id: chatbot.id })}
onDelete={() => setDeleteId(chatbot.id)}
onAnalytics={() => navigate(`/chatbots/${chatbot.id}/analytics`)}
/>
))}
{/* Add new card */}
<Card
className="border-dashed hover:border-primary-400 hover:bg-primary-50 flex items-center justify-center min-h-[200px] cursor-pointer transition-colors"
onClick={() => navigate('/chatbots/new')}
>
<div className="text-center">
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-3">
<Plus className="w-6 h-6 text-gray-400" />
</div>
<p className="text-sm font-medium text-gray-500">New Chatbot</p>
</div>
</Card>
</div>
)}
{/* Delete confirmation */}
<Modal
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
title="Delete Chatbot"
>
<p className="text-gray-600 mb-6">
Are you sure you want to delete this chatbot? All documents and conversation history will be permanently removed.
</p>
<div className="flex gap-3">
<Button variant="outline" onClick={() => setDeleteId(null)} className="flex-1">
Cancel
</Button>
<Button
variant="danger"
onClick={() => deleteId && deleteMutation.mutate(deleteId)}
loading={deleteMutation.isPending}
className="flex-1"
>
Delete
</Button>
</div>
</Modal>
{/* IMP-11: Publish/Unpublish confirmation */}
<Modal
isOpen={!!confirmAction}
onClose={() => setConfirmAction(null)}
title={confirmAction?.type === 'publish' ? 'Publish Chatbot' : 'Unpublish Chatbot'}
>
<p className="text-gray-600 mb-6">
{confirmAction?.type === 'publish'
? 'This will make your chatbot publicly visible on the marketplace. Are you sure?'
: 'This will remove your chatbot from the marketplace. Users will no longer be able to access it.'}
</p>
<div className="flex gap-3">
<Button variant="outline" onClick={() => setConfirmAction(null)} className="flex-1">
Cancel
</Button>
<Button
onClick={handleConfirmAction}
loading={publishMutation.isPending || unpublishMutation.isPending}
className="flex-1"
>
{confirmAction?.type === 'publish' ? 'Publish' : 'Unpublish'}
</Button>
</div>
</Modal>
{/* BUG-05 FIX: Toast queue - renders all active toasts */}
<div className="fixed bottom-4 right-4 z-50 space-y-2">
{toasts.map((toast) => (
<div
key={toast.id}
className="bg-gray-900 text-white px-4 py-2 rounded-lg text-sm shadow-lg animate-fade-in-up flex items-center gap-2"
>
{toast.message}
<button onClick={() => removeToast(toast.id)} className="opacity-60 hover:opacity-100">&times;</button>
</div>
))}
</div>
</div>
)
}
const ChatbotCard: React.FC<{
chatbot: Chatbot
onEdit: () => void
onPreview: () => void
onPublish: () => void
onUnpublish: () => void
onDelete: () => void
onAnalytics: () => void
}> = ({ chatbot, onEdit, onPreview, onPublish, onUnpublish, onDelete, onAnalytics }) => {
const [menuOpen, setMenuOpen] = useState(false)
return (
<Card className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
{chatbot.logo_url ? (
<img
src={chatbot.logo_url}
alt={chatbot.name}
className="w-10 h-10 rounded-xl object-cover flex-shrink-0"
/>
) : (
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg flex-shrink-0"
style={{ background: chatbot.primary_color }}
>
<Bot className="w-5 h-5" />
</div>
)}
<div>
<h3 className="font-semibold text-gray-900 text-sm">{chatbot.name}</h3>
<div className="flex items-center gap-1.5 mt-0.5">
<StatusDot status={chatbot.is_published ? 'published' : 'preview'} />
<span className="text-xs text-gray-500">
{chatbot.is_published ? 'Published' : 'Preview'}
</span>
</div>
</div>
</div>
{/* R-02 FIX: Menu dropdown with viewport-aware positioning */}
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-400"
>
<MoreHorizontal className="w-4 h-4" />
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 mt-1 w-44 bg-white border border-gray-200 rounded-xl shadow-lg z-20 overflow-hidden text-sm max-h-64 overflow-y-auto">
<button onClick={() => { onEdit(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left">
<Settings className="w-3.5 h-3.5" /> Edit Settings
</button>
<button onClick={() => { onPreview(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left">
<Eye className="w-3.5 h-3.5" /> Preview
</button>
<button onClick={() => { onAnalytics(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-50 text-left">
<BarChart2 className="w-3.5 h-3.5" /> Analytics
</button>
<div className="h-px bg-gray-100" />
{chatbot.is_published ? (
<button onClick={() => { onUnpublish(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-orange-50 text-orange-600 text-left">
<Lock className="w-3.5 h-3.5" /> Unpublish
</button>
) : (
<button onClick={() => { onPublish(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-green-50 text-green-600 text-left">
<Globe className="w-3.5 h-3.5" /> Publish
</button>
)}
<div className="h-px bg-gray-100" />
<button onClick={() => { onDelete(); setMenuOpen(false) }} className="flex items-center gap-2 w-full px-3 py-2 hover:bg-red-50 text-red-600 text-left">
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
</div>
</>
)}
</div>
</div>
{chatbot.description && (
<p className="text-xs text-gray-500 mb-3 line-clamp-2">{chatbot.description}</p>
)}
{/* Stats */}
<div className="flex flex-wrap gap-3 mb-4 text-xs text-gray-500">
<span>📄 {chatbot.document_count} docs</span>
<span>💬 {chatbot.conversation_count} chats</span>
{chatbot.category && <span>🏷 {chatbot.category}</span>}
</div>
{/* Actions */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={onPreview}
className="flex-1"
>
<Eye className="w-3.5 h-3.5" />
Preview
</Button>
{chatbot.is_published ? (
<Button variant="outline" size="sm" onClick={onUnpublish} className="flex-1">
<Lock className="w-3.5 h-3.5" />
Unpublish
</Button>
) : (
<Button size="sm" onClick={onPublish} className="flex-1">
<Globe className="w-3.5 h-3.5" />
Publish
</Button>
)}
</div>
</Card>
)
}