Spaces:
Running
Running
| import React, { useState, useEffect } from 'react'; | |
| import { ChevronDown, Plus, Info, MessageSquare, BookOpen, LogOut, PanelLeftClose, MessageCircle, AlertTriangle, Menu, PanelRightClose, RefreshCw } from 'lucide-react'; | |
| import SimulationGraph from './SimulationGraph'; | |
| import { GradioService } from '../services/gradioService'; | |
| interface SimulationPageProps { | |
| onBack: () => void; | |
| onOpenConversation: () => void; | |
| onOpenChat: () => void; | |
| user?: any; | |
| onLogin?: () => void; | |
| simulationResult: any; | |
| setSimulationResult: (res: any) => void; | |
| } | |
| // Define the data structure for filters | |
| const VIEW_FILTERS: Record<string, Array<{ label: string; color: string }>> = { | |
| 'Country': [ | |
| { label: "United States", color: "bg-blue-600" }, | |
| { label: "United Kingdom", color: "bg-purple-600" }, | |
| { label: "Netherlands", color: "bg-teal-600" }, | |
| { label: "France", color: "bg-orange-600" }, | |
| { label: "India", color: "bg-pink-600" } | |
| ], | |
| 'Job Title': [ | |
| { label: "Founder", color: "bg-indigo-500" }, | |
| { label: "Product Manager", color: "bg-emerald-500" }, | |
| { label: "Engineer", color: "bg-rose-500" }, | |
| { label: "Investor", color: "bg-amber-500" }, | |
| { label: "Designer", color: "bg-fuchsia-500" } | |
| ], | |
| 'Sentiment': [ | |
| { label: "Positive", color: "bg-green-500" }, | |
| { label: "Neutral", color: "bg-gray-500" }, | |
| { label: "Negative", color: "bg-red-500" }, | |
| { label: "Mixed", color: "bg-yellow-500" } | |
| ], | |
| 'Activity Level': [ | |
| { label: "Power User", color: "bg-red-600" }, | |
| { label: "Daily Active", color: "bg-orange-500" }, | |
| { label: "Weekly Active", color: "bg-blue-500" }, | |
| { label: "Lurker", color: "bg-slate-600" } | |
| ] | |
| }; | |
| const SimulationPage: React.FC<SimulationPageProps> = ({ | |
| onBack, onOpenConversation, onOpenChat, user, onLogin, simulationResult, setSimulationResult | |
| }) => { | |
| const [society, setSociety] = useState(''); | |
| const [societies, setSocieties] = useState<string[]>([]); | |
| const [viewMode, setViewMode] = useState('Job Title'); | |
| const [isRefreshing, setIsRefreshing] = useState(false); | |
| const [isBuilding, setIsBuilding] = useState(false); | |
| const [isRightPanelOpen, setIsRightPanelOpen] = useState(window.innerWidth > 1200); | |
| const [isLeftPanelOpen, setIsLeftPanelOpen] = useState(window.innerWidth > 768); | |
| // Handle window resize for mobile responsiveness | |
| useEffect(() => { | |
| const handleResize = () => { | |
| if (window.innerWidth < 768) { | |
| setIsLeftPanelOpen(false); | |
| setIsRightPanelOpen(false); | |
| } | |
| }; | |
| window.addEventListener('resize', handleResize); | |
| // Fetch real focus groups | |
| const fetchSocieties = async () => { | |
| try { | |
| const result = await GradioService.listSimulations(); | |
| // Handle both direct array and Gradio data wrap | |
| const list = Array.isArray(result) ? result : (result?.data?.[0] || []); | |
| if (Array.isArray(list)) { | |
| const names = list | |
| .map((s: any) => { | |
| if (typeof s === 'string') return s; | |
| if (typeof s === 'object' && s !== null) return s.id || s.name || ''; | |
| return ''; | |
| }) | |
| .filter(name => name.length > 0 && !name.toLowerCase().includes('default') && !name.toLowerCase().includes('template')); | |
| setSocieties(names); | |
| if (names.length > 0 && (!society || !names.includes(society))) { | |
| setSociety(names[0]); | |
| } | |
| } | |
| } catch (e) { | |
| console.error("Failed to fetch focus groups", e); | |
| } | |
| }; | |
| fetchSocieties(); | |
| return () => window.removeEventListener('resize', handleResize); | |
| }, []); | |
| // Function to simulate rebuilding the graph when settings change | |
| const handleSettingChange = (setter: (val: string) => void, value: string) => { | |
| if (value === society || (value === viewMode && setter === setViewMode)) return; // No change | |
| setter(value); | |
| setIsBuilding(true); | |
| // Simulate network delay | |
| setTimeout(() => { | |
| setIsBuilding(false); | |
| }, 1500); | |
| }; | |
| const currentFilters = VIEW_FILTERS[viewMode] || VIEW_FILTERS['Country']; | |
| return ( | |
| <div className="flex h-screen w-screen overflow-hidden bg-black text-white font-sans relative"> | |
| {/* Sidebar */} | |
| <aside className={`fixed md:relative w-[300px] h-full flex-shrink-0 border-r border-gray-800 flex flex-col bg-[#0a0a0a] z-40 transition-all duration-300 ${isLeftPanelOpen ? 'translate-x-0' : '-translate-x-full md:-ml-[300px]'}`}> | |
| {/* Header */} | |
| <div className="p-4 h-16 border-b border-gray-800 flex items-center justify-between"> | |
| <div className="flex items-center gap-2 cursor-pointer" onClick={onBack}> | |
| <div className="w-6 h-6 flex items-center justify-center font-bold text-white">Λ</div> | |
| <span className="font-semibold tracking-tight text-xs">Branding Content Testing</span> | |
| </div> | |
| <button | |
| onClick={() => setIsLeftPanelOpen(false)} | |
| className="text-gray-500 hover:text-white" | |
| > | |
| <PanelLeftClose size={18}/> | |
| </button> | |
| </div> | |
| {/* Scrollable Content */} | |
| <div className="flex-1 overflow-y-auto p-4 space-y-6"> | |
| {/* Focus Group Control */} | |
| <div className="space-y-2"> | |
| <label className="text-xs text-gray-500 font-medium uppercase tracking-wider">Focus Group</label> | |
| <div className="relative group"> | |
| <select | |
| value={society} | |
| onChange={(e) => handleSettingChange(setSociety, e.target.value)} | |
| className="w-full appearance-none bg-[#111] border border-gray-700 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-teal-500 cursor-pointer" | |
| > | |
| {societies.map(s => <option key={s} value={s}>{s}</option>)} | |
| </select> | |
| <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none w-4 h-4" /> | |
| </div> | |
| </div> | |
| {/* Current View Control */} | |
| <div className="space-y-2"> | |
| <label className="text-xs text-gray-500 font-medium uppercase tracking-wider">Current View</label> | |
| <div className="relative"> | |
| <select | |
| value={viewMode} | |
| onChange={(e) => handleSettingChange(setViewMode, e.target.value)} | |
| className="w-full appearance-none bg-[#111] border border-gray-700 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-teal-500 cursor-pointer" | |
| > | |
| <option>Country</option> | |
| <option>Job Title</option> | |
| <option>Sentiment</option> | |
| <option>Activity Level</option> | |
| </select> | |
| <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none w-4 h-4" /> | |
| </div> | |
| </div> | |
| <div className="h-px bg-gray-800 my-4" /> | |
| {/* Actions */} | |
| <button | |
| onClick={onOpenConversation} | |
| className="w-full flex items-center justify-between text-left text-sm text-gray-300 hover:text-white group py-2 border-b border-gray-800/50 mb-1" | |
| > | |
| <span>Assemble new group</span> | |
| <Plus size={18} className="text-gray-500 group-hover:text-white" /> | |
| </button> | |
| <button | |
| onClick={onOpenConversation} | |
| className="w-full flex items-center justify-between text-left text-sm text-gray-300 hover:text-white group py-2" | |
| > | |
| <span>Create a new test</span> | |
| <Plus size={18} className="text-gray-500 group-hover:text-white" /> | |
| </button> | |
| {/* Global Chat Button (Sidebar) */} | |
| <button | |
| onClick={onOpenChat} | |
| className="w-full flex items-center gap-3 px-4 py-3 bg-gray-800 hover:bg-gray-700 text-white rounded-lg transition-colors border border-gray-700" | |
| > | |
| <MessageCircle size={18} /> | |
| <span className="font-medium text-sm">Open Global Chat</span> | |
| </button> | |
| {/* Setup Warning / Info Box */} | |
| <div className="bg-blue-900/30 border border-blue-700/50 rounded-xl p-4 mt-4"> | |
| <div className="flex items-center gap-2 text-blue-200 font-bold text-xs mb-1"> | |
| <Info size={14}/> | |
| <span>Configuration Required</span> | |
| </div> | |
| <p className="text-blue-200/70 text-[10px] leading-relaxed"> | |
| Assemble new group and create a new test are required to be configured first before using any chat. | |
| </p> | |
| </div> | |
| {/* History List */} | |
| <div className="space-y-1 pt-4"> | |
| <label className="text-xs text-gray-500 font-medium uppercase tracking-wider mb-2 block">Recent Tests</label> | |
| <div className="text-sm text-gray-400 py-2 px-2 hover:bg-gray-800/50 rounded cursor-pointer truncate"> | |
| Sustainable Luxury Narrative | |
| </div> | |
| <div className="text-sm text-gray-400 py-2 px-2 hover:bg-gray-800/50 rounded cursor-pointer truncate"> | |
| Radical Transparency Voice | |
| </div> | |
| <div className="text-sm text-gray-400 py-2 px-2 hover:bg-gray-800/50 rounded cursor-pointer truncate"> | |
| Gen-Z Greenwash Perception | |
| </div> | |
| </div> | |
| </div> | |
| {/* Footer */} | |
| <div className="border-t border-gray-800 p-4 space-y-1 bg-[#0a0a0a]"> | |
| {user ? ( | |
| <div className="flex items-center gap-3 py-3 border-b border-gray-800 mb-2"> | |
| {user.avatarUrl && <img src={user.avatarUrl} alt={user.preferred_username} className="w-8 h-8 rounded-full border border-gray-700" />} | |
| <div className="flex flex-col"> | |
| <span className="text-xs font-semibold text-gray-200">{user.preferred_username}</span> | |
| <span className="text-[10px] text-gray-500">Credits: 0</span> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="py-2 border-b border-gray-800 mb-2"> | |
| <button | |
| onClick={onLogin} | |
| className="w-full py-2 bg-white text-black rounded-lg text-xs font-bold hover:bg-gray-200 transition-colors" | |
| > | |
| Sign in with Hugging Face | |
| </button> | |
| </div> | |
| )} | |
| <MenuItem icon={<Plus size={16}/>} label="Start Free Trial" highlight /> | |
| <MenuItem icon={<MessageSquare size={16}/>} label="Leave Feedback" /> | |
| <MenuItem icon={<BookOpen size={16}/>} label="Product Guide" /> | |
| <MenuItem icon={<LogOut size={16}/>} label="Log Out" /> | |
| <div className="pt-4 text-[10px] text-gray-600">Version 2.1</div> | |
| </div> | |
| </aside> | |
| {/* Main Content Area */} | |
| <main className="flex-1 flex flex-col relative bg-black overflow-hidden"> | |
| {/* Top Navigation Overlay */} | |
| <div className="absolute top-4 left-4 right-4 z-30 flex justify-between items-center pointer-events-none"> | |
| {/* Left Toggle (when sidebar closed) */} | |
| <button | |
| onClick={() => setIsLeftPanelOpen(true)} | |
| className={`pointer-events-auto p-2 bg-gray-900/80 border border-gray-700 rounded-lg text-gray-400 hover:text-white transition-opacity ${isLeftPanelOpen ? 'opacity-0 pointer-events-none' : 'opacity-100'}`} | |
| > | |
| <Menu size={20} /> | |
| </button> | |
| {/* Right Toggle (when output closed) */} | |
| <button | |
| onClick={() => setIsRightPanelOpen(true)} | |
| className={`pointer-events-auto p-2 bg-gray-900/80 border border-gray-700 rounded-lg text-gray-400 hover:text-white transition-opacity ml-auto ${isRightPanelOpen ? 'opacity-0 pointer-events-none' : 'opacity-100'}`} | |
| > | |
| <PanelRightClose size={20} className="rotate-180" /> | |
| </button> | |
| </div> | |
| <div className="absolute top-6 left-6 right-6 z-10 flex justify-center pointer-events-none"> | |
| {/* Legend / Filter Chips */} | |
| <div className="flex flex-wrap justify-center gap-2 pointer-events-auto max-w-[60%]"> | |
| {currentFilters.map((filter, idx) => ( | |
| <FilterChip key={idx} color={filter.color} label={filter.label} /> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Graph Container */} | |
| <div className="flex-1 w-full h-full"> | |
| <SimulationGraph isBuilding={isBuilding} societyType={society} onStartChat={onOpenChat} /> | |
| </div> | |
| {/* Floating Chat Button (Bottom) */} | |
| <div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-30"> | |
| <button | |
| onClick={onOpenChat} | |
| className="flex items-center gap-2 bg-black/80 backdrop-blur-md border border-gray-700 text-white px-6 py-3 rounded-full shadow-2xl hover:bg-gray-900 transition-all hover:scale-105" | |
| > | |
| <MessageCircle size={20} /> | |
| <span className="font-medium">Open Simulation Chat</span> | |
| </button> | |
| </div> | |
| </main> | |
| {/* Right Sidebar (Output) */} | |
| <aside className={`fixed right-0 md:relative w-[300px] h-full flex-shrink-0 border-l border-gray-800 flex flex-col bg-[#0a0a0a] z-40 transition-all duration-300 ${isRightPanelOpen ? 'translate-x-0' : 'translate-x-full md:-mr-[300px]'}`}> | |
| <div className="p-4 h-16 border-b border-gray-800 flex items-center justify-between"> | |
| <span className="font-semibold tracking-tight uppercase text-xs text-gray-500">Output</span> | |
| <button onClick={() => setIsRightPanelOpen(false)} className="text-gray-500 hover:text-white"><PanelRightClose size={18}/></button> | |
| </div> | |
| <div className="flex-1 overflow-y-auto p-4 space-y-4"> | |
| <div className="bg-gray-900/50 border border-gray-800 rounded-xl p-4"> | |
| <div className="flex items-center justify-between mb-2"> | |
| <p className="text-xs text-gray-500">Simulation Results</p> | |
| {simulationResult && ( | |
| <button | |
| onClick={async () => { | |
| setIsRefreshing(true); | |
| try { | |
| const status = await GradioService.getSimulationStatus(society); | |
| setSimulationResult({ | |
| status: "Updated", | |
| message: "Latest status gathered from API.", | |
| data: status | |
| }); | |
| } catch (e) { | |
| console.error(e); | |
| } finally { | |
| setIsRefreshing(false); | |
| } | |
| }} | |
| className="p-1 hover:bg-gray-800 rounded text-gray-500 hover:text-white" | |
| title="Refresh Status" | |
| > | |
| <RefreshCw size={12} className={isRefreshing ? "animate-spin" : ""} /> | |
| </button> | |
| )} | |
| </div> | |
| {!simulationResult ? ( | |
| <div className="text-sm text-gray-400 italic text-center py-8"> | |
| Results will appear here after running a simulation. | |
| </div> | |
| ) : ( | |
| <div className="space-y-3"> | |
| <div className="flex items-center gap-2 text-xs font-medium text-green-400"> | |
| <div className="w-1.5 h-1.5 rounded-full bg-green-500"></div> | |
| {simulationResult.status} | |
| </div> | |
| <p className="text-[11px] text-gray-400">{simulationResult.message}</p> | |
| {simulationResult.data && ( | |
| <pre className="text-[10px] bg-black/50 p-2 rounded max-h-96 overflow-y-auto custom-scrollbar whitespace-pre-wrap text-gray-300 border border-gray-800"> | |
| {JSON.stringify(simulationResult.data, null, 2)} | |
| </pre> | |
| )} | |
| <p className="text-[10px] text-gray-600 mt-4 italic"> | |
| Note: Complete simulation results can take up to 30 minutes to be fully processed by the API. | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </aside> | |
| </div> | |
| ); | |
| }; | |
| // Helper Components | |
| interface MenuItemProps { | |
| icon: React.ReactNode; | |
| label: string; | |
| highlight?: boolean; | |
| } | |
| const MenuItem: React.FC<MenuItemProps> = ({ icon, label, highlight = false }) => ( | |
| <button className={`w-full flex items-center gap-3 px-2 py-2.5 rounded-md text-sm transition-colors ${highlight ? 'text-teal-400 hover:bg-teal-950/30' : 'text-gray-400 hover:bg-gray-800 hover:text-white'}`}> | |
| {icon} | |
| <span>{label}</span> | |
| </button> | |
| ); | |
| interface FilterChipProps { | |
| color: string; | |
| label: string; | |
| } | |
| const FilterChip: React.FC<FilterChipProps> = ({ color, label }) => ( | |
| <button className="flex items-center gap-2 bg-gray-900/80 backdrop-blur border border-gray-700 rounded-full pl-2 pr-4 py-1.5 hover:border-gray-500 transition-colors"> | |
| <span className={`w-2.5 h-2.5 rounded-full ${color}`}></span> | |
| <span className="text-xs font-medium text-gray-300">{label}</span> | |
| </button> | |
| ); | |
| export default SimulationPage; |