Spaces:
Sleeping
Sleeping
| // web/src/App.tsx | |
| import React, { useState, useEffect } from 'react'; | |
| import { Header } from './components/Header'; | |
| import { LeftSidebar } from './components/LeftSidebar'; | |
| import { ChatArea } from './components/ChatArea'; | |
| import { RightPanel } from './components/RightPanel'; | |
| import { FloatingActionButtons } from './components/FloatingActionButtons'; | |
| import { LoginScreen } from './components/LoginScreen'; | |
| import { ProfileEditor } from './components/ProfileEditor'; | |
| import { X } from 'lucide-react'; | |
| import { Button } from './components/ui/button'; | |
| import { Toaster } from './components/ui/sonner'; | |
| import { ChevronLeft, ChevronRight } from 'lucide-react'; | |
| import { toast } from 'sonner'; | |
| import { | |
| apiLogin, | |
| apiChat, | |
| apiUpload, | |
| apiExport, | |
| apiSummary, | |
| type LearningMode, | |
| type Language, | |
| type FileType, | |
| type User as ApiUser, | |
| } from './lib/api'; | |
| export interface Message { | |
| id: string; | |
| role: 'user' | 'assistant'; | |
| content: string; | |
| timestamp: Date; | |
| references?: string[]; | |
| sender?: GroupMember; // For group chat | |
| } | |
| export interface User { | |
| name: string; | |
| email: string; | |
| } | |
| export interface GroupMember { | |
| id: string; | |
| name: string; | |
| email: string; | |
| avatar?: string; | |
| isAI?: boolean; | |
| } | |
| export type SpaceType = 'individual' | 'group'; | |
| export interface Workspace { | |
| id: string; | |
| name: string; | |
| type: SpaceType; | |
| avatar: string; | |
| members?: GroupMember[]; | |
| } | |
| export interface UploadedFile { | |
| file: File; | |
| type: FileType; | |
| } | |
| function App() { | |
| const [isDarkMode, setIsDarkMode] = useState(() => { | |
| const saved = localStorage.getItem('theme'); | |
| return saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches); | |
| }); | |
| const [user, setUser] = useState<User | null>(null); | |
| const [messages, setMessages] = useState<Message[]>([ | |
| { | |
| id: '1', | |
| role: 'assistant', | |
| content: | |
| "👋 Hi! I'm Clare, your AI teaching assistant. I'm here to help you learn through personalized tutoring. Feel free to ask me anything about the course materials, or upload your documents to get started!", | |
| timestamp: new Date(), | |
| }, | |
| ]); | |
| const [learningMode, setLearningMode] = useState<LearningMode>('concept'); | |
| const [language, setLanguage] = useState<Language>('auto'); | |
| const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]); | |
| // You can later wire this to /api/memoryline | |
| const [memoryProgress] = useState(36); | |
| const [leftSidebarOpen, setLeftSidebarOpen] = useState(false); | |
| const [leftPanelVisible, setLeftPanelVisible] = useState(true); | |
| const [rightPanelOpen, setRightPanelOpen] = useState(false); | |
| const [rightPanelVisible, setRightPanelVisible] = useState(true); | |
| const [showProfileEditor, setShowProfileEditor] = useState(false); | |
| const [exportResult, setExportResult] = useState(''); | |
| const [resultType, setResultType] = useState<'export' | 'quiz' | 'summary' | null>(null); | |
| // Mock group members (still fine; AI responder uses backend now) | |
| const [groupMembers] = useState<GroupMember[]>([ | |
| { id: 'clare', name: 'Clare AI', email: 'clare@ai.assistant', isAI: true }, | |
| { id: '1', name: 'Sarah Johnson', email: 'sarah.j@university.edu' }, | |
| { id: '2', name: 'Michael Chen', email: 'michael.c@university.edu' }, | |
| { id: '3', name: 'Emma Williams', email: 'emma.w@university.edu' }, | |
| ]); | |
| const [workspaces, setWorkspaces] = useState<Workspace[]>([]); | |
| const [currentWorkspaceId, setCurrentWorkspaceId] = useState<string>('individual'); | |
| useEffect(() => { | |
| if (user) { | |
| const userAvatar = `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`; | |
| setWorkspaces([ | |
| { | |
| id: 'individual', | |
| name: 'My Space', | |
| type: 'individual', | |
| avatar: userAvatar, | |
| }, | |
| { | |
| id: 'group-1', | |
| name: 'CS 101 Study Group', | |
| type: 'group', | |
| avatar: 'https://api.dicebear.com/7.x/shapes/svg?seed=cs101group', | |
| members: groupMembers, | |
| }, | |
| { | |
| id: 'group-2', | |
| name: 'AI Ethics Team', | |
| type: 'group', | |
| avatar: 'https://api.dicebear.com/7.x/shapes/svg?seed=aiethicsteam', | |
| members: groupMembers, | |
| }, | |
| ]); | |
| } | |
| }, [user, groupMembers]); | |
| const currentWorkspace = workspaces.find((w) => w.id === currentWorkspaceId) || workspaces[0]; | |
| const spaceType: SpaceType = currentWorkspace?.type || 'individual'; | |
| useEffect(() => { | |
| document.documentElement.classList.toggle('dark', isDarkMode); | |
| localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); | |
| }, [isDarkMode]); | |
| const asApiUser = (u: User): ApiUser => ({ name: u.name, email: u.email }); | |
| const handleSendMessage = async (content: string) => { | |
| if (!content.trim() || !user) return; | |
| const sender: GroupMember | undefined = | |
| spaceType === 'group' | |
| ? { id: user.email, name: user.name, email: user.email } | |
| : undefined; | |
| const userMessage: Message = { | |
| id: crypto.randomUUID(), | |
| role: 'user', | |
| content, | |
| timestamp: new Date(), | |
| sender, | |
| }; | |
| setMessages((prev) => [...prev, userMessage]); | |
| const shouldAIRespond = spaceType === 'individual' || content.toLowerCase().includes('@clare'); | |
| if (!shouldAIRespond) return; | |
| const assistantId = crypto.randomUUID(); | |
| const assistantPlaceholder: Message = { | |
| id: assistantId, | |
| role: 'assistant', | |
| content: 'Thinking...', | |
| timestamp: new Date(), | |
| sender: spaceType === 'group' ? groupMembers.find((m) => m.isAI) : undefined, | |
| }; | |
| setMessages((prev) => [...prev, assistantPlaceholder]); | |
| try { | |
| const data = await apiChat({ | |
| user: asApiUser(user), | |
| message: content, | |
| learningMode, | |
| language, | |
| docType: 'Syllabus', | |
| }); | |
| const references = | |
| (data.refs || []) | |
| .map((r) => [r.source_file, r.section].filter(Boolean).join(' — ')) | |
| .filter(Boolean); | |
| setMessages((prev) => | |
| prev.map((m) => | |
| m.id === assistantId | |
| ? { | |
| ...m, | |
| content: data.reply || '', | |
| references: references.length ? references : undefined, | |
| } | |
| : m | |
| ) | |
| ); | |
| } catch (err: any) { | |
| setMessages((prev) => | |
| prev.map((m) => | |
| m.id === assistantId | |
| ? { ...m, content: `Sorry — request failed.\n${err?.message ?? String(err)}` } | |
| : m | |
| ) | |
| ); | |
| } | |
| }; | |
| const handleFileUpload = async (files: File[]) => { | |
| if (!user) return; | |
| const newFiles: UploadedFile[] = files.map((file) => ({ | |
| file, | |
| type: 'other', | |
| })); | |
| setUploadedFiles((prev) => [...prev, ...newFiles]); | |
| for (const f of files) { | |
| try { | |
| const r = await apiUpload({ user: asApiUser(user), file: f, fileType: 'other' }); | |
| toast.success(r.status_md || `Uploaded: ${f.name}`); | |
| } catch (e: any) { | |
| toast.error(e?.message ?? `Upload failed: ${f.name}`); | |
| } | |
| } | |
| }; | |
| const handleRemoveFile = (index: number) => { | |
| setUploadedFiles((prev) => prev.filter((_, i) => i !== index)); | |
| }; | |
| const handleFileTypeChange = async (index: number, type: FileType) => { | |
| setUploadedFiles((prev) => prev.map((f, i) => (i === index ? { ...f, type } : f))); | |
| if (!user) return; | |
| const target = uploadedFiles[index]; | |
| if (!target) return; | |
| try { | |
| const r = await apiUpload({ | |
| user: asApiUser(user), | |
| file: target.file, | |
| fileType: type, | |
| }); | |
| toast.success(r.status_md || `Updated type: ${target.file.name}`); | |
| } catch (e: any) { | |
| toast.error(e?.message ?? `Failed to update type: ${target.file.name}`); | |
| } | |
| }; | |
| const handleClearConversation = () => { | |
| setMessages([ | |
| { | |
| id: '1', | |
| role: 'assistant', | |
| content: | |
| "👋 Hi! I'm Clare, your AI teaching assistant. I'm here to help you learn through personalized tutoring. Feel free to ask me anything about the course materials, or upload your documents to get started!", | |
| timestamp: new Date(), | |
| }, | |
| ]); | |
| toast.success('Conversation cleared'); | |
| }; | |
| const handleExport = async () => { | |
| if (!user) return; | |
| try { | |
| const r = await apiExport({ user: asApiUser(user), learningMode }); | |
| setExportResult(r.markdown || ''); | |
| setResultType('export'); | |
| toast.success('Conversation exported!'); | |
| } catch (e: any) { | |
| toast.error(e?.message ?? 'Export failed'); | |
| } | |
| }; | |
| const handleSummary = async () => { | |
| if (!user) return; | |
| try { | |
| const r = await apiSummary({ user: asApiUser(user), learningMode, language }); | |
| setExportResult(r.markdown || ''); | |
| setResultType('summary'); | |
| toast.success('Summary generated!'); | |
| } catch (e: any) { | |
| toast.error(e?.message ?? 'Summary failed'); | |
| } | |
| }; | |
| if (!user) { | |
| return ( | |
| <LoginScreen | |
| onLogin={async (u) => { | |
| setUser(u); | |
| try { | |
| await apiLogin(asApiUser(u)); | |
| } catch (e: any) { | |
| toast.error(e?.message ?? 'Login sync failed'); | |
| } | |
| }} | |
| /> | |
| ); | |
| } | |
| return ( | |
| <div className="min-h-screen bg-background flex flex-col"> | |
| <Toaster /> | |
| <Header | |
| user={user} | |
| onMenuClick={() => setLeftSidebarOpen(!leftSidebarOpen)} | |
| onUserClick={() => setRightPanelOpen(!rightPanelOpen)} | |
| isDarkMode={isDarkMode} | |
| onToggleDarkMode={() => setIsDarkMode(!isDarkMode)} | |
| language={language} | |
| onLanguageChange={setLanguage} | |
| workspaces={workspaces} | |
| currentWorkspace={currentWorkspace} | |
| onWorkspaceChange={setCurrentWorkspaceId} | |
| /> | |
| {showProfileEditor && user && ( | |
| <ProfileEditor user={user} onSave={setUser} onClose={() => setShowProfileEditor(false)} /> | |
| )} | |
| <div | |
| className="flex-1 flex overflow-hidden" | |
| onWheel={(e) => e.stopPropagation()} | |
| style={{ overscrollBehavior: 'none' }} | |
| > | |
| {/* Mobile Sidebar Toggle - Left */} | |
| {leftSidebarOpen && ( | |
| <div | |
| className="fixed inset-0 bg-black/50 z-40 lg:hidden" | |
| onClick={() => setLeftSidebarOpen(false)} | |
| /> | |
| )} | |
| {/* Left Sidebar */} | |
| {leftPanelVisible ? ( | |
| <aside className="hidden lg:flex w-80 bg-card border-r border-border flex-col h-full min-h-0 relative"> | |
| <Button | |
| variant="secondary" | |
| size="icon" | |
| onClick={() => setLeftPanelVisible(false)} | |
| className="absolute top-4 z-[70] h-8 w-5 shadow-lg rounded-full bg-card border border-border" | |
| style={{ right: '-10px' }} | |
| title="Close panel" | |
| > | |
| <ChevronLeft className="h-3 w-3" /> | |
| </Button> | |
| <LeftSidebar | |
| learningMode={learningMode} | |
| language={language} | |
| onLearningModeChange={setLearningMode} | |
| onLanguageChange={setLanguage} | |
| spaceType={spaceType} | |
| groupMembers={groupMembers} | |
| user={user} | |
| onLogin={setUser} | |
| onLogout={() => setUser(null)} | |
| isLoggedIn={!!user} | |
| onEditProfile={() => setShowProfileEditor(true)} | |
| /> | |
| </aside> | |
| ) : ( | |
| <Button | |
| variant="secondary" | |
| size="icon" | |
| onClick={() => setLeftPanelVisible(true)} | |
| className="hidden lg:flex fixed top-20 left-0 z-[70] h-8 w-5 shadow-lg rounded-full bg-card border border-border" | |
| title="Open panel" | |
| > | |
| <ChevronRight className="h-3 w-3" /> | |
| </Button> | |
| )} | |
| {/* Left Sidebar - Mobile */} | |
| <aside | |
| className={` | |
| fixed lg:hidden inset-y-0 left-0 z-50 | |
| w-80 bg-card border-r border-border | |
| transform transition-transform duration-300 ease-in-out | |
| ${leftSidebarOpen ? 'translate-x-0' : '-translate-x-full'} | |
| flex flex-col | |
| mt-16 | |
| h-[calc(100vh-4rem)] | |
| min-h-0 | |
| `} | |
| > | |
| <div className="p-4 border-b border-border flex justify-between items-center"> | |
| <h3>Settings & Guide</h3> | |
| <Button variant="ghost" size="icon" onClick={() => setLeftSidebarOpen(false)}> | |
| <X className="h-5 w-5" /> | |
| </Button> | |
| </div> | |
| <LeftSidebar | |
| learningMode={learningMode} | |
| language={language} | |
| onLearningModeChange={setLearningMode} | |
| onLanguageChange={setLanguage} | |
| spaceType={spaceType} | |
| groupMembers={groupMembers} | |
| user={user} | |
| onLogin={setUser} | |
| onLogout={() => setUser(null)} | |
| isLoggedIn={!!user} | |
| onEditProfile={() => setShowProfileEditor(true)} | |
| /> | |
| </aside> | |
| {/* Main Chat Area */} | |
| <main className="flex-1 flex flex-col min-w-0 min-h-0 h-full"> | |
| <ChatArea | |
| // ✅ NEW: pass ApiUser down so Message can submit feedback | |
| user={asApiUser(user)} | |
| messages={messages} | |
| onSendMessage={handleSendMessage} | |
| uploadedFiles={uploadedFiles} | |
| onFileUpload={handleFileUpload} | |
| onRemoveFile={handleRemoveFile} | |
| onFileTypeChange={handleFileTypeChange} | |
| memoryProgress={memoryProgress} | |
| isLoggedIn={!!user} | |
| learningMode={learningMode} | |
| onClearConversation={handleClearConversation} | |
| onLearningModeChange={setLearningMode} | |
| spaceType={spaceType} | |
| /> | |
| </main> | |
| {/* Mobile Sidebar Toggle - Right */} | |
| {rightPanelOpen && ( | |
| <div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setRightPanelOpen(false)} /> | |
| )} | |
| {/* Right Panel */} | |
| {rightPanelVisible ? ( | |
| <aside className="hidden lg:flex w-80 bg-card border-l border-border flex-col min-h-0 relative" style={{ height: 'calc(100vh - 4rem)' }}> | |
| <Button | |
| variant="secondary" | |
| size="icon" | |
| onClick={() => setRightPanelVisible(false)} | |
| className="absolute top-4 z-[70] h-8 w-5 shadow-lg rounded-full bg-card border border-border" | |
| style={{ left: '-10px' }} | |
| title="Close panel" | |
| > | |
| <ChevronRight className="h-3 w-3" /> | |
| </Button> | |
| <RightPanel | |
| user={user} | |
| onLogin={setUser} | |
| onLogout={() => setUser(null)} | |
| isLoggedIn={!!user} | |
| onClose={() => setRightPanelVisible(false)} | |
| exportResult={exportResult} | |
| setExportResult={setExportResult} | |
| resultType={resultType} | |
| setResultType={setResultType} | |
| onExport={handleExport} | |
| onSummary={handleSummary} | |
| /> | |
| </aside> | |
| ) : ( | |
| <Button | |
| variant="secondary" | |
| size="icon" | |
| onClick={() => setRightPanelVisible(true)} | |
| className="hidden lg:flex fixed top-20 right-0 z-[70] h-8 w-5 shadow-lg rounded-full bg-card border border-border" | |
| title="Open panel" | |
| > | |
| <ChevronLeft className="h-3 w-3" /> | |
| </Button> | |
| )} | |
| {/* Right Panel - Mobile */} | |
| <aside | |
| className={` | |
| fixed lg:hidden inset-y-0 right-0 z-50 | |
| w-80 bg-card border-l border-border | |
| transform transition-transform duration-300 ease-in-out | |
| ${rightPanelOpen ? 'translate-x-0' : 'translate-x-full'} | |
| flex flex-col | |
| mt-16 | |
| h-[calc(100vh-4rem)] | |
| min-h-0 | |
| `} | |
| > | |
| <div className="p-4 border-b border-border flex justify-between items-center"> | |
| <h3>Account & Actions</h3> | |
| <Button variant="ghost" size="icon" onClick={() => setRightPanelOpen(false)}> | |
| <X className="h-5 w-5" /> | |
| </Button> | |
| </div> | |
| <RightPanel | |
| user={user} | |
| onLogin={setUser} | |
| onLogout={() => setUser(null)} | |
| isLoggedIn={!!user} | |
| onClose={() => setRightPanelVisible(false)} | |
| exportResult={exportResult} | |
| setExportResult={setExportResult} | |
| resultType={resultType} | |
| setResultType={setResultType} | |
| onExport={handleExport} | |
| onSummary={handleSummary} | |
| /> | |
| </aside> | |
| {/* Floating Action Buttons - Desktop only, when panel is closed */} | |
| {!rightPanelVisible && ( | |
| <FloatingActionButtons | |
| user={user} | |
| isLoggedIn={!!user} | |
| onOpenPanel={() => setRightPanelVisible(true)} | |
| onExport={handleExport} | |
| onSummary={handleSummary} | |
| /> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default App; | |