Spaces:
Running
Running
| import React, { useState, useRef } from 'react'; | |
| import { Moon, Sun, Upload, FileText, Download, Share2, MessageSquare, AlertCircle, LayoutGrid, List, Grid, ChevronLeft, ArrowRight, X, BrainCircuit } from 'lucide-react'; | |
| import Background from './components/Background'; | |
| import BentoCard from './components/BentoCard'; | |
| import ChatBot from './components/ChatBot'; | |
| import { BentoCardData, ChatMessage, AppSettings, ProcessingStatus } from './types'; | |
| import { generateBentoCards, expandBentoCard, chatWithDocument } from './services/geminiService'; | |
| const App: React.FC = () => { | |
| // Settings | |
| const [settings, setSettings] = useState<AppSettings>({ | |
| apiKey: '', | |
| model: 'gemini-flash-latest', | |
| theme: 'light', | |
| layoutMode: 'auto', | |
| useThinking: false | |
| }); | |
| // Inputs | |
| const [file, setFile] = useState<File | null>(null); | |
| // State | |
| const [view, setView] = useState<'input' | 'results'>('input'); | |
| const [cards, setCards] = useState<BentoCardData[]>([]); | |
| const [status, setStatus] = useState<ProcessingStatus>({ state: 'idle' }); | |
| const [paperContext, setPaperContext] = useState<string>(''); // Stores the raw text/base64 | |
| const [paperTitle, setPaperTitle] = useState<string>(''); | |
| // Chat | |
| const [isChatOpen, setIsChatOpen] = useState(false); | |
| const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]); | |
| const [isChatProcessing, setIsChatProcessing] = useState(false); | |
| const gridRef = useRef<HTMLDivElement>(null); | |
| const toggleTheme = () => { | |
| const newTheme = settings.theme === 'dark' ? 'light' : 'dark'; | |
| setSettings(prev => ({ ...prev, theme: newTheme })); | |
| if (newTheme === 'dark') { | |
| document.documentElement.classList.add('dark'); | |
| } else { | |
| document.documentElement.classList.remove('dark'); | |
| } | |
| }; | |
| const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| if (e.target.files && e.target.files[0]) { | |
| setFile(e.target.files[0]); | |
| } | |
| }; | |
| const handleProcess = async () => { | |
| if (!settings.apiKey) { | |
| setStatus({ state: 'error', message: "Please enter your Gemini API Key." }); | |
| return; | |
| } | |
| if (!file) { | |
| setStatus({ state: 'error', message: "Please upload a PDF file." }); | |
| return; | |
| } | |
| setStatus({ state: 'reading', message: 'Reading PDF file...' }); | |
| try { | |
| let contentToProcess = ""; | |
| const contextTitle = file.name; | |
| // PDF UPLOAD FLOW | |
| contentToProcess = await new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => { | |
| const result = reader.result as string; | |
| const base64 = result.split(',')[1]; | |
| resolve(base64); | |
| }; | |
| reader.onerror = reject; | |
| reader.readAsDataURL(file); | |
| }); | |
| if (file.type !== 'application/pdf') { | |
| throw new Error("File must be a PDF."); | |
| } | |
| setPaperContext(contentToProcess); | |
| setPaperTitle(contextTitle); | |
| setStatus({ state: 'analyzing', message: settings.useThinking ? 'Thinking deeply about paper structure...' : 'Analyzing paper structure...' }); | |
| const generatedCards = await generateBentoCards( | |
| settings.apiKey, | |
| settings.model, | |
| contentToProcess, | |
| true, | |
| settings.useThinking | |
| ); | |
| setCards(generatedCards); | |
| setStatus({ state: 'complete', message: 'Visualization ready.' }); | |
| setView('results'); | |
| } catch (error: any) { | |
| console.error(error); | |
| setStatus({ state: 'error', message: error.message || "An error occurred processing the paper." }); | |
| } | |
| }; | |
| const handleReset = () => { | |
| setView('input'); | |
| setCards([]); | |
| setStatus({ state: 'idle' }); | |
| setChatHistory([]); | |
| setIsChatOpen(false); | |
| setPaperTitle(''); | |
| setPaperContext(''); | |
| setFile(null); | |
| }; | |
| const handleExpandCard = async (card: BentoCardData) => { | |
| if (card.expandedContent || card.isLoadingDetails) return; | |
| setCards(prev => prev.map(c => c.id === card.id ? { ...c, isLoadingDetails: true } : c)); | |
| try { | |
| const details = await expandBentoCard( | |
| settings.apiKey, | |
| settings.model, | |
| card.title, | |
| card.detailPrompt, | |
| paperContext, | |
| settings.useThinking | |
| ); | |
| setCards(prev => prev.map(c => c.id === card.id ? { ...c, expandedContent: details, isLoadingDetails: false } : c)); | |
| } catch (error) { | |
| setCards(prev => prev.map(c => c.id === card.id ? { ...c, isLoadingDetails: false, expandedContent: "Failed to load details." } : c)); | |
| } | |
| }; | |
| const handleResizeCard = (id: string, deltaCol: number, deltaRow: number) => { | |
| setCards(prev => prev.map(c => { | |
| if (c.id === id) { | |
| const newCol = Math.max(1, Math.min(4, c.colSpan + deltaCol)); | |
| const newRow = Math.max(1, Math.min(3, c.rowSpan + deltaRow)); | |
| return { ...c, colSpan: newCol, rowSpan: newRow }; | |
| } | |
| return c; | |
| })); | |
| }; | |
| const handleRateCard = (id: string, rating: number) => { | |
| setCards(prev => prev.map(c => c.id === id ? { ...c, rating } : c)); | |
| console.log(`Rated card ${id}: ${rating}`); | |
| }; | |
| const handleExport = async () => { | |
| if (!gridRef.current) return; | |
| // @ts-ignore | |
| if (window.html2canvas) { | |
| // @ts-ignore | |
| const canvas = await window.html2canvas(gridRef.current, { | |
| backgroundColor: settings.theme === 'dark' ? '#0f172a' : '#f8fafc', | |
| scale: 2, | |
| useCORS: true, | |
| logging: false | |
| }); | |
| const link = document.createElement('a'); | |
| link.download = 'bento-summary.png'; | |
| link.href = canvas.toDataURL(); | |
| link.click(); | |
| } else { | |
| alert("Export module not loaded yet."); | |
| } | |
| }; | |
| const handleShare = async () => { | |
| if (navigator.share) { | |
| try { | |
| await navigator.share({ | |
| title: 'Paper Summary', | |
| text: 'Check out this visual summary generated by PaperStack!', | |
| url: window.location.href | |
| }); | |
| } catch (err) { | |
| console.error("Share failed", err); | |
| } | |
| } else { | |
| alert("Sharing is not supported on this browser."); | |
| } | |
| }; | |
| const handleSendMessage = async (text: string) => { | |
| const newUserMsg: ChatMessage = { id: Date.now().toString(), role: 'user', text, timestamp: Date.now() }; | |
| setChatHistory(prev => [...prev, newUserMsg]); | |
| setIsChatProcessing(true); | |
| try { | |
| const responseText = await chatWithDocument(settings.apiKey, settings.model, chatHistory, text, paperContext || JSON.stringify(cards)); | |
| const newBotMsg: ChatMessage = { id: (Date.now()+1).toString(), role: 'model', text: responseText, timestamp: Date.now() }; | |
| setChatHistory(prev => [...prev, newBotMsg]); | |
| } catch (error) { | |
| const errorMsg: ChatMessage = { id: (Date.now()+1).toString(), role: 'system', text: "Failed to get response.", timestamp: Date.now() }; | |
| setChatHistory(prev => [...prev, errorMsg]); | |
| } finally { | |
| setIsChatProcessing(false); | |
| } | |
| }; | |
| return ( | |
| <div className={`min-h-screen w-full text-gray-900 dark:text-gray-100 overflow-x-hidden transition-colors duration-500 ${settings.theme}`}> | |
| <Background theme={settings.theme} /> | |
| {/* Navbar */} | |
| <nav className="fixed top-0 w-full z-50 px-6 py-4 flex justify-between items-center glass-panel border-b border-gray-200 dark:border-white/5"> | |
| <div className="flex items-center gap-4"> | |
| {view === 'results' && ( | |
| <button | |
| onClick={handleReset} | |
| className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 transition-colors" | |
| title="Start Over" | |
| > | |
| <ChevronLeft size={20} /> | |
| </button> | |
| )} | |
| <div className="flex items-center gap-2"> | |
| <div className="w-8 h-8 bg-gradient-to-br from-brand-500 to-purple-600 rounded-lg flex items-center justify-center text-white font-bold shadow-lg"> | |
| P | |
| </div> | |
| <span className="text-xl font-display font-bold tracking-tight hidden md:block">PaperStack</span> | |
| </div> | |
| </div> | |
| <div className="flex gap-4 items-center"> | |
| {view === 'results' && ( | |
| <div className="hidden md:flex items-center gap-2 px-4 py-1.5 bg-gray-100 dark:bg-gray-800 rounded-full border border-gray-200 dark:border-gray-700"> | |
| <FileText size={14} className="text-gray-500" /> | |
| <span className="text-sm font-medium max-w-[200px] truncate opacity-80">{paperTitle || 'Analysis'}</span> | |
| </div> | |
| )} | |
| <button onClick={toggleTheme} className="p-2 rounded-full bg-gray-100 dark:bg-white/10 hover:bg-gray-200 dark:hover:bg-white/20 transition-colors shadow-sm"> | |
| {settings.theme === 'dark' ? <Sun size={20} /> : <Moon size={20} />} | |
| </button> | |
| </div> | |
| </nav> | |
| <main className="pt-32 pb-20 px-4 md:px-8 max-w-7xl mx-auto relative z-10 min-h-[80vh]"> | |
| {/* Input View */} | |
| {view === 'input' && ( | |
| <section className="flex flex-col gap-8 animate-in fade-in zoom-in-95 duration-700 items-center justify-center min-h-[60vh]"> | |
| <div className="text-center space-y-6 mb-4 max-w-3xl"> | |
| <h1 className="text-5xl md:text-7xl font-display font-bold bg-clip-text text-transparent bg-gradient-to-r from-gray-900 via-brand-600 to-purple-600 dark:from-white dark:via-brand-400 dark:to-purple-400 leading-tight pb-2"> | |
| Visual Research Summaries | |
| </h1> | |
| <p className="text-lg md:text-xl text-gray-600 dark:text-gray-300 max-w-2xl mx-auto font-light"> | |
| Upload your research paper (PDF) and instantly transform it into a rich, interactive Bento grid. | |
| <br className="hidden md:block" /> | |
| Powered by <span className="font-semibold text-brand-600 dark:text-brand-400">Gemini 3.0 Pro</span>. | |
| </p> | |
| </div> | |
| <div className="w-full max-w-2xl space-y-6 p-8 glass-panel rounded-3xl shadow-2xl border border-gray-200 dark:border-white/10 backdrop-blur-xl bg-white/80 dark:bg-black/40"> | |
| {/* API Key */} | |
| <div className="relative"> | |
| <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none"> | |
| <span className="text-xs font-bold bg-gray-200 dark:bg-gray-800 px-2 py-0.5 rounded text-gray-600 dark:text-gray-400">KEY</span> | |
| </div> | |
| <input | |
| type="password" | |
| placeholder="Enter Gemini API Key" | |
| value={settings.apiKey} | |
| onChange={(e) => setSettings({...settings, apiKey: e.target.value})} | |
| className="w-full bg-white dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-xl py-4 pl-16 pr-4 focus:ring-2 focus:ring-brand-500 outline-none transition-all text-sm font-mono" | |
| /> | |
| </div> | |
| {/* Model Select & Thinking Toggle */} | |
| <div className="flex flex-col md:flex-row gap-3"> | |
| <div className="grid grid-cols-2 gap-3 flex-grow"> | |
| <button | |
| onClick={() => setSettings({...settings, model: 'gemini-flash-latest'})} | |
| className={`py-3 px-4 rounded-xl text-sm font-semibold transition-all flex flex-col md:flex-row items-center justify-center gap-2 border ${settings.model === 'gemini-flash-latest' ? 'bg-brand-50 dark:bg-brand-900/20 border-brand-500 text-brand-700 dark:text-brand-400' : 'bg-gray-50 dark:bg-gray-800 border-transparent hover:bg-gray-100 dark:hover:bg-gray-700'}`} | |
| > | |
| <span>⚡ Flash</span> | |
| <span className="text-xs opacity-60 font-normal">(Fast)</span> | |
| </button> | |
| <button | |
| onClick={() => setSettings({...settings, model: 'gemini-3-pro-preview'})} | |
| className={`py-3 px-4 rounded-xl text-sm font-semibold transition-all flex flex-col md:flex-row items-center justify-center gap-2 border ${settings.model === 'gemini-3-pro-preview' ? 'bg-purple-50 dark:bg-purple-900/20 border-purple-500 text-purple-700 dark:text-purple-400' : 'bg-gray-50 dark:bg-gray-800 border-transparent hover:bg-gray-100 dark:hover:bg-gray-700'}`} | |
| > | |
| <span>🧠 Pro</span> | |
| <span className="text-xs opacity-60 font-normal">(Smart)</span> | |
| </button> | |
| </div> | |
| {/* Thinking Toggle Switch */} | |
| <div | |
| className={` | |
| flex items-center justify-between px-4 py-2 rounded-xl border transition-all md:min-w-[180px] | |
| ${settings.useThinking ? 'bg-indigo-50 dark:bg-indigo-900/20 border-indigo-200 dark:border-indigo-800' : 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700'} | |
| `} | |
| > | |
| <div className="flex items-center gap-3 mr-4"> | |
| <BrainCircuit size={20} className={settings.useThinking ? 'text-indigo-500 animate-pulse' : 'text-gray-400'} /> | |
| <div className="flex flex-col leading-none"> | |
| <span className={`text-sm font-bold ${settings.useThinking ? 'text-indigo-700 dark:text-indigo-300' : 'text-gray-600 dark:text-gray-400'}`}>Thinking</span> | |
| <span className="text-[10px] opacity-60">32k Budget</span> | |
| </div> | |
| </div> | |
| <button | |
| onClick={() => setSettings(s => ({ ...s, useThinking: !s.useThinking }))} | |
| className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${settings.useThinking ? 'bg-indigo-600' : 'bg-gray-300 dark:bg-gray-600'}`} | |
| > | |
| <span | |
| className={`${settings.useThinking ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-200 ease-in-out`} | |
| /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* PDF Upload - Main Action */} | |
| <div className="relative"> | |
| <input | |
| type="file" | |
| accept=".pdf" | |
| onChange={handleFileChange} | |
| className="hidden" | |
| id="file-upload" | |
| /> | |
| <label | |
| htmlFor="file-upload" | |
| className={` | |
| flex flex-col items-center justify-center gap-3 cursor-pointer rounded-2xl border-2 border-dashed transition-all py-12 | |
| ${file | |
| ? 'bg-brand-50 dark:bg-brand-900/10 border-brand-500 text-brand-600 dark:text-brand-400' | |
| : 'bg-gray-50/50 dark:bg-white/5 border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-white/10 text-gray-500 hover:border-brand-400 dark:hover:border-brand-400'} | |
| `} | |
| > | |
| {file ? ( | |
| <> | |
| <div className="p-3 bg-brand-100 dark:bg-brand-900/30 rounded-full"> | |
| <FileText size={32} className="text-brand-600 dark:text-brand-400" /> | |
| </div> | |
| <div className="text-center"> | |
| <span className="font-bold text-lg">{file.name}</span> | |
| <p className="text-sm opacity-70 mt-1">{(file.size / 1024 / 1024).toFixed(2)} MB</p> | |
| </div> | |
| <div className="mt-2 text-xs bg-brand-200 dark:bg-brand-800/40 px-2 py-1 rounded"> | |
| Click to change file | |
| </div> | |
| </> | |
| ) : ( | |
| <> | |
| <div className="p-3 bg-gray-100 dark:bg-gray-800 rounded-full"> | |
| <Upload size={32} /> | |
| </div> | |
| <div className="text-center"> | |
| <span className="font-bold text-lg">Upload PDF Paper</span> | |
| <p className="text-sm opacity-70 mt-1">Max size 10MB</p> | |
| </div> | |
| </> | |
| )} | |
| </label> | |
| </div> | |
| {/* Generate Button */} | |
| <button | |
| onClick={handleProcess} | |
| disabled={status.state !== 'idle' && status.state !== 'error'} | |
| className={` | |
| w-full py-4 rounded-xl font-bold text-lg shadow-xl hover:shadow-2xl hover:scale-[1.01] active:scale-[0.99] transition-all disabled:opacity-70 disabled:cursor-not-allowed flex items-center justify-center gap-3 | |
| ${settings.useThinking | |
| ? 'bg-gradient-to-r from-indigo-600 to-purple-600 text-white' | |
| : 'bg-gradient-to-r from-gray-900 to-gray-800 dark:from-brand-600 dark:to-purple-600 text-white' | |
| } | |
| `} | |
| > | |
| {status.state !== 'idle' && status.state !== 'error' && status.state !== 'complete' ? ( | |
| <> | |
| <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> | |
| <span>{status.message}</span> | |
| </> | |
| ) : ( | |
| <> | |
| <span>{settings.useThinking ? 'Deep Analyze' : 'Generate Bento Grid'}</span> | |
| <ArrowRight size={20} /> | |
| </> | |
| )} | |
| </button> | |
| {status.state === 'error' && ( | |
| <div className="flex items-center gap-2 text-red-500 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg text-sm animate-in fade-in slide-in-from-top-2"> | |
| <AlertCircle size={16} /> | |
| {status.message} | |
| </div> | |
| )} | |
| </div> | |
| </section> | |
| )} | |
| {/* Results View */} | |
| {view === 'results' && ( | |
| <div className="animate-in fade-in slide-in-from-bottom-8 duration-700"> | |
| {/* Controls */} | |
| <div className="flex justify-between items-center mb-6"> | |
| <h2 className="text-2xl font-display font-bold flex items-center gap-3"> | |
| Summary Grid | |
| {settings.useThinking && ( | |
| <span className="text-xs font-medium bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 px-2 py-1 rounded-full border border-indigo-200 dark:border-indigo-800 flex items-center gap-1"> | |
| <BrainCircuit size={12} /> Deep Thought | |
| </span> | |
| )} | |
| </h2> | |
| <div className="flex gap-2"> | |
| <button onClick={handleShare} className="p-2 rounded-lg bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 transition-colors text-gray-600 dark:text-gray-300"> | |
| <Share2 size={18} /> | |
| </button> | |
| <button onClick={handleExport} className="p-2 rounded-lg bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 transition-colors text-gray-600 dark:text-gray-300"> | |
| <Download size={18} /> | |
| </button> | |
| <div className="w-px h-8 bg-gray-200 dark:bg-gray-700 mx-2"></div> | |
| <button onClick={() => setSettings({...settings, layoutMode: 'grid'})} className={`p-2 rounded-lg border transition-colors ${settings.layoutMode === 'grid' ? 'bg-brand-50 dark:bg-brand-900/30 border-brand-500 text-brand-600' : 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-500'}`}> | |
| <Grid size={18} /> | |
| </button> | |
| <button onClick={() => setSettings({...settings, layoutMode: 'list'})} className={`p-2 rounded-lg border transition-colors ${settings.layoutMode === 'list' ? 'bg-brand-50 dark:bg-brand-900/30 border-brand-500 text-brand-600' : 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-500'}`}> | |
| <List size={18} /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Grid */} | |
| <div | |
| ref={gridRef} | |
| className="grid grid-cols-1 md:grid-cols-4 auto-rows-[minmax(180px,auto)] gap-4 md:gap-6 p-1 grid-flow-dense" | |
| > | |
| {cards.map((card) => ( | |
| <BentoCard | |
| key={card.id} | |
| card={card} | |
| onExpand={handleExpandCard} | |
| onRate={handleRateCard} | |
| onResize={handleResizeCard} | |
| layoutMode={settings.layoutMode} | |
| /> | |
| ))} | |
| </div> | |
| {/* Floating Chat Trigger */} | |
| <button | |
| onClick={() => setIsChatOpen(true)} | |
| className="fixed bottom-8 right-8 p-4 bg-brand-600 hover:bg-brand-500 text-white rounded-full shadow-2xl hover:scale-110 transition-all z-40 group" | |
| > | |
| <MessageSquare size={24} /> | |
| <span className="absolute right-full mr-4 top-1/2 -translate-y-1/2 px-3 py-1 bg-gray-900 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none"> | |
| Chat with Paper | |
| </span> | |
| </button> | |
| </div> | |
| )} | |
| </main> | |
| <ChatBot | |
| isOpen={isChatOpen} | |
| onClose={() => setIsChatOpen(false)} | |
| messages={chatHistory} | |
| onSendMessage={handleSendMessage} | |
| isProcessing={isChatProcessing} | |
| /> | |
| </div> | |
| ); | |
| }; | |
| export default App; | |