PaperStack / App.tsx
Akhil-Theerthala's picture
Upload 14 files
a8a53c5 verified
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;