Spaces:
Sleeping
Sleeping
| import React, { useEffect, useState, useRef } from 'react'; | |
| import { BlogSection as BlogSectionType, PaperStructure } from '../types'; | |
| import BlogSectionComponent from './BlogSection'; | |
| import Sidebar from './Sidebar'; | |
| import { Clock, BookOpen, FileText, Share2, Download, Sparkles, CheckCircle2, Loader2, AlertCircle, RefreshCw, RotateCcw } from 'lucide-react'; | |
| // Loading placeholder for sections being generated | |
| const SectionLoadingPlaceholder: React.FC<{ | |
| title: string; | |
| index: number; | |
| isCurrentlyGenerating: boolean; | |
| statusMessage?: string; | |
| }> = ({ | |
| title, | |
| index, | |
| isCurrentlyGenerating, | |
| statusMessage | |
| }) => ( | |
| <section className="relative scroll-mt-32 animate-in fade-in duration-500 mb-24"> | |
| <div className="flex flex-col md:flex-row gap-8"> | |
| <article className="flex-1 min-w-0"> | |
| <header className="mb-12"> | |
| <div className="flex items-start gap-6 mb-6"> | |
| <span className={` | |
| flex-shrink-0 w-12 h-12 rounded-2xl flex items-center justify-center text-white font-bold text-xl shadow-lg | |
| ${isCurrentlyGenerating | |
| ? 'bg-gradient-to-br from-brand-500 to-purple-600 animate-pulse shadow-brand-500/20' | |
| : 'bg-gray-200 dark:bg-gray-800 text-gray-400 dark:text-gray-600' | |
| } | |
| `}> | |
| {isCurrentlyGenerating ? ( | |
| <Loader2 size={24} className="animate-spin" /> | |
| ) : ( | |
| index + 1 | |
| )} | |
| </span> | |
| <div className="flex-1 pt-1"> | |
| <h2 className={`text-3xl md:text-4xl font-display font-bold leading-tight mb-4 ${ | |
| isCurrentlyGenerating ? 'text-gray-900 dark:text-gray-50' : 'text-gray-300 dark:text-gray-700' | |
| }`}> | |
| {title} | |
| </h2> | |
| <div className={`h-1 rounded-full max-w-[100px] ${ | |
| isCurrentlyGenerating | |
| ? 'bg-gradient-to-r from-brand-500 to-purple-500 animate-pulse' | |
| : 'bg-gray-100 dark:bg-gray-800' | |
| }`} /> | |
| </div> | |
| </div> | |
| </header> | |
| {/* Loading skeleton */} | |
| <div className="space-y-8 pl-0 md:pl-[4.5rem]"> | |
| {isCurrentlyGenerating ? ( | |
| <> | |
| <div className="flex items-center gap-3 text-sm font-medium text-brand-600 dark:text-brand-400 mb-6 px-4 py-2 rounded-lg bg-brand-50 dark:bg-brand-900/10 w-fit"> | |
| <Loader2 size={16} className="animate-spin" /> | |
| <span className="font-mono">{statusMessage || 'Generating content...'}</span> | |
| </div> | |
| <div className="space-y-4 max-w-3xl"> | |
| <div className="h-4 bg-gray-200 dark:bg-gray-800 rounded-lg w-full animate-pulse" /> | |
| <div className="h-4 bg-gray-200 dark:bg-gray-800 rounded-lg w-[98%] animate-pulse delay-75" /> | |
| <div className="h-4 bg-gray-200 dark:bg-gray-800 rounded-lg w-[95%] animate-pulse delay-100" /> | |
| <div className="h-4 bg-gray-200 dark:bg-gray-800 rounded-lg w-[90%] animate-pulse delay-150" /> | |
| <div className="h-4 bg-gray-200 dark:bg-gray-800 rounded-lg w-[92%] animate-pulse delay-200" /> | |
| </div> | |
| <div className="mt-8 p-8 rounded-2xl bg-gray-50 dark:bg-gray-900/50 border border-gray-100 dark:border-gray-800"> | |
| <div className="h-40 bg-gray-200 dark:bg-gray-800 rounded-xl animate-pulse" /> | |
| </div> | |
| </> | |
| ) : ( | |
| <div className="p-12 rounded-3xl border-2 border-dashed border-gray-200 dark:border-gray-800 text-center bg-gray-50/50 dark:bg-gray-900/20"> | |
| <p className="text-gray-400 dark:text-gray-600 font-medium"> | |
| Waiting to be analyzed... | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| </article> | |
| </div> | |
| </section> | |
| ); | |
| // Error state for failed sections | |
| const SectionErrorState: React.FC<{ | |
| title: string; | |
| error: string; | |
| index: number; | |
| onRetry?: () => void; | |
| isRetrying?: boolean; | |
| }> = ({ title, error, index, onRetry, isRetrying }) => ( | |
| <section className="relative scroll-mt-32 mb-24"> | |
| <div className="flex gap-8"> | |
| <article className="flex-1 min-w-0"> | |
| <header className="mb-8"> | |
| <div className="flex items-start gap-6 mb-4"> | |
| <span className="flex-shrink-0 w-12 h-12 rounded-2xl bg-red-500 flex items-center justify-center text-white font-bold text-lg shadow-lg"> | |
| {isRetrying ? <Loader2 size={24} className="animate-spin" /> : <AlertCircle size={24} />} | |
| </span> | |
| <div className="flex-1"> | |
| <h2 className="text-3xl md:text-4xl font-display font-bold text-gray-900 dark:text-gray-50 leading-tight mb-2"> | |
| {title} | |
| </h2> | |
| <div className="h-1 w-20 bg-red-500/30 rounded-full" /> | |
| </div> | |
| </div> | |
| </header> | |
| <div className="p-6 rounded-2xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"> | |
| <div className="flex items-start gap-4"> | |
| <AlertCircle size={24} className="text-red-500 flex-shrink-0 mt-0.5" /> | |
| <div className="flex-1"> | |
| <p className="font-semibold text-red-700 dark:text-red-300 text-lg"> | |
| Failed to generate this section | |
| </p> | |
| <p className="mt-2 text-sm text-red-600 dark:text-red-400 leading-relaxed"> | |
| {error} | |
| </p> | |
| {onRetry && ( | |
| <button | |
| onClick={onRetry} | |
| disabled={isRetrying} | |
| className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-800/40 text-red-700 dark:text-red-300 font-semibold text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| {isRetrying ? ( | |
| <> | |
| <Loader2 size={16} className="animate-spin" /> | |
| <span>Retrying...</span> | |
| </> | |
| ) : ( | |
| <> | |
| <RotateCcw size={16} /> | |
| <span>Try Again</span> | |
| </> | |
| )} | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </article> | |
| </div> | |
| <div className="my-16 flex items-center gap-4"> | |
| <div className="flex-1 h-px bg-gradient-to-r from-transparent via-gray-300 dark:via-gray-700 to-transparent" /> | |
| </div> | |
| </section> | |
| ); | |
| interface Props { | |
| sections: BlogSectionType[]; | |
| paperTitle: string; | |
| theme: 'light' | 'dark'; | |
| onExport: () => void; | |
| onShare: () => void; | |
| isLoading?: boolean; | |
| loadingStage?: 'idle' | 'analyzing' | 'generating' | 'validating'; | |
| currentSection?: number; | |
| paperStructure?: PaperStructure | null; | |
| sectionStatus?: string; | |
| onRetrySection?: (sectionIndex: number) => Promise<void>; | |
| retryingSectionIndex?: number; | |
| contentRef?: React.RefObject<HTMLDivElement>; | |
| } | |
| const BlogView: React.FC<Props> = ({ | |
| sections, | |
| paperTitle, | |
| theme, | |
| onExport, | |
| onShare, | |
| isLoading = false, | |
| loadingStage = 'idle', | |
| currentSection = -1, | |
| paperStructure = null, | |
| sectionStatus = '', | |
| onRetrySection, | |
| retryingSectionIndex = -1, | |
| contentRef | |
| }) => { | |
| const [activeSection, setActiveSection] = useState<string>(sections[0]?.id || ''); | |
| const [readProgress, setReadProgress] = useState(0); | |
| const internalContentRef = useRef<HTMLDivElement>(null); | |
| const effectiveContentRef = contentRef || internalContentRef; | |
| // Calculate reading time (rough estimate: 200 words per minute) | |
| const completedSections = sections.filter(s => !s.isLoading && s.content); | |
| const totalWords = completedSections.reduce((acc, section) => { | |
| return acc + (section.content?.split(/\s+/).length || 0); | |
| }, 0); | |
| const readingTime = Math.max(1, Math.ceil(totalWords / 200)); | |
| // Count completed sections | |
| const completedCount = sections.filter(s => !s.isLoading && !s.error).length; | |
| // Intersection Observer for active section tracking | |
| useEffect(() => { | |
| const options = { | |
| root: null, | |
| rootMargin: '-20% 0px -60% 0px', | |
| threshold: 0 | |
| }; | |
| const observer = new IntersectionObserver((entries) => { | |
| entries.forEach((entry) => { | |
| if (entry.isIntersecting) { | |
| const id = entry.target.id.replace('section-', ''); | |
| setActiveSection(id); | |
| } | |
| }); | |
| }, options); | |
| sections.forEach((section) => { | |
| const element = document.getElementById(`section-${section.id}`); | |
| if (element) observer.observe(element); | |
| }); | |
| return () => observer.disconnect(); | |
| }, [sections]); | |
| // Scroll progress tracking | |
| useEffect(() => { | |
| const handleScroll = () => { | |
| const winScroll = document.documentElement.scrollTop; | |
| const height = document.documentElement.scrollHeight - document.documentElement.clientHeight; | |
| const scrolled = (winScroll / height) * 100; | |
| setReadProgress(scrolled); | |
| }; | |
| window.addEventListener('scroll', handleScroll); | |
| return () => window.removeEventListener('scroll', handleScroll); | |
| }, []); | |
| return ( | |
| <div className="min-h-screen"> | |
| {/* Reading Progress Bar */} | |
| <div className="fixed top-0 left-0 right-0 h-1 bg-gray-200 dark:bg-gray-800 z-50"> | |
| <div | |
| className="h-full bg-gradient-to-r from-brand-500 via-purple-500 to-pink-500 transition-all duration-150" | |
| style={{ width: `${readProgress}%` }} | |
| /> | |
| </div> | |
| {/* Sidebar Navigation */} | |
| {sections.length > 0 && ( | |
| <Sidebar | |
| sections={sections} | |
| activeSection={activeSection} | |
| onSectionClick={setActiveSection} | |
| /> | |
| )} | |
| {/* Main Content */} | |
| <div className="lg:ml-72 xl:mr-8"> | |
| <div ref={effectiveContentRef} className="max-w-4xl mx-auto px-6 py-12 md:py-20"> | |
| {/* Loading State - Paper Analysis */} | |
| {loadingStage === 'analyzing' && ( | |
| <div className="flex flex-col items-center justify-center min-h-[60vh] animate-in fade-in duration-500"> | |
| <div className="relative"> | |
| <div className="w-24 h-24 rounded-full bg-gradient-to-r from-brand-500 to-purple-600 animate-pulse flex items-center justify-center shadow-2xl shadow-brand-500/30"> | |
| <Sparkles size={40} className="text-white animate-bounce" /> | |
| </div> | |
| <div className="absolute inset-0 rounded-full bg-gradient-to-r from-brand-500 to-purple-600 animate-ping opacity-20" /> | |
| </div> | |
| <h2 className="mt-8 text-3xl font-display font-bold text-gray-900 dark:text-white text-center"> | |
| Deconstructing Research | |
| </h2> | |
| <p className="mt-4 text-lg text-gray-500 dark:text-gray-400 text-center max-w-md font-light"> | |
| Analyzing the paper's structure to craft a comprehensive narrative... | |
| </p> | |
| </div> | |
| )} | |
| {/* Generation Progress Banner */} | |
| {(loadingStage === 'generating' || loadingStage === 'validating') && ( | |
| <div className={`mb-12 p-6 rounded-2xl border animate-in slide-in-from-top duration-500 backdrop-blur-sm ${ | |
| loadingStage === 'validating' | |
| ? 'bg-amber-50/80 dark:bg-amber-900/10 border-amber-200 dark:border-amber-800' | |
| : 'bg-brand-50/80 dark:bg-brand-900/10 border-brand-200 dark:border-brand-800' | |
| }`}> | |
| <div className="flex items-center gap-5"> | |
| <div className="flex-shrink-0"> | |
| <div className={`w-12 h-12 rounded-xl flex items-center justify-center shadow-sm ${ | |
| loadingStage === 'validating' ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-600' : 'bg-brand-100 dark:bg-brand-900/30 text-brand-600' | |
| }`}> | |
| {loadingStage === 'validating' ? ( | |
| <CheckCircle2 size={24} className="animate-pulse" /> | |
| ) : ( | |
| <Loader2 size={24} className="animate-spin" /> | |
| )} | |
| </div> | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center justify-between mb-2"> | |
| <span className={`text-base font-semibold ${ | |
| loadingStage === 'validating' | |
| ? 'text-amber-800 dark:text-amber-300' | |
| : 'text-brand-800 dark:text-brand-300' | |
| }`}> | |
| {loadingStage === 'validating' ? 'Validating scientific accuracy...' : 'Generating insights...'} | |
| </span> | |
| <span className={`text-sm font-medium font-mono ${ | |
| loadingStage === 'validating' | |
| ? 'text-amber-600 dark:text-amber-400' | |
| : 'text-brand-600 dark:text-brand-400' | |
| }`}> | |
| {completedCount} / {sections.length} | |
| </span> | |
| </div> | |
| <div className={`h-2 rounded-full overflow-hidden ${ | |
| loadingStage === 'validating' | |
| ? 'bg-amber-200 dark:bg-amber-900/30' | |
| : 'bg-brand-200 dark:bg-brand-900/30' | |
| }`}> | |
| <div | |
| className={`h-full rounded-full transition-all duration-500 ${ | |
| loadingStage === 'validating' | |
| ? 'bg-gradient-to-r from-amber-500 to-orange-500' | |
| : 'bg-gradient-to-r from-brand-500 to-purple-500' | |
| }`} | |
| style={{ width: `${(completedCount / sections.length) * 100}%` }} | |
| /> | |
| </div> | |
| {sectionStatus ? ( | |
| <p className="mt-2 text-sm text-gray-600 dark:text-gray-400 font-mono"> | |
| {sectionStatus} | |
| </p> | |
| ) : currentSection >= 0 && sections[currentSection] && ( | |
| <p className="mt-2 text-sm text-gray-600 dark:text-gray-400 truncate"> | |
| Currently analyzing: <span className="font-medium text-gray-900 dark:text-gray-200">{sections[currentSection].title}</span> | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Article Header */} | |
| {(sections.length > 0 || paperStructure) && ( | |
| <header className="mb-20 animate-in fade-in slide-in-from-bottom-8 duration-700"> | |
| {/* Paper Badge */} | |
| <div className="flex items-center gap-3 mb-8"> | |
| <span className="inline-flex items-center gap-2 px-3 py-1 rounded-md bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 text-xs font-bold uppercase tracking-widest border border-gray-200 dark:border-gray-700"> | |
| <FileText size={12} /> | |
| Research Paper | |
| </span> | |
| <span className="h-px w-8 bg-gray-200 dark:bg-gray-700"></span> | |
| <span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"> | |
| {new Date().toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })} | |
| </span> | |
| </div> | |
| {/* Title */} | |
| <h1 className="text-4xl md:text-6xl lg:text-7xl font-display font-bold text-gray-900 dark:text-white leading-[1.1] mb-10 tracking-tight"> | |
| {(paperStructure?.paperTitle || paperTitle).replace('.pdf', '')} | |
| </h1> | |
| {/* Abstract Preview */} | |
| {paperStructure?.paperAbstract && ( | |
| <div className="relative"> | |
| <div className="absolute left-0 top-0 bottom-0 w-1 bg-brand-500 rounded-full opacity-30"></div> | |
| <p className="text-xl md:text-2xl text-gray-600 dark:text-gray-300 leading-relaxed pl-8 font-serif italic text-opacity-90"> | |
| {paperStructure.paperAbstract} | |
| </p> | |
| </div> | |
| )} | |
| {/* Meta Info & Actions */} | |
| <div className="mt-12 flex flex-wrap items-center justify-between gap-6 pt-8 border-t border-gray-100 dark:border-gray-800"> | |
| <div className="flex items-center gap-8 text-sm font-medium text-gray-500 dark:text-gray-400"> | |
| <div className="flex items-center gap-2"> | |
| <Clock size={18} className="text-brand-500" /> | |
| <span>{readingTime} min read</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <BookOpen size={18} className="text-purple-500" /> | |
| <span>{sections.length} sections</span> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={onShare} | |
| className="flex items-center gap-2 px-4 py-2 rounded-full bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-sm font-medium text-gray-700 dark:text-gray-300" | |
| > | |
| <Share2 size={16} /> | |
| Share | |
| </button> | |
| <button | |
| onClick={onExport} | |
| className="flex items-center gap-2 px-4 py-2 rounded-full bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:opacity-90 transition-opacity text-sm font-medium" | |
| > | |
| <Download size={16} /> | |
| Export | |
| </button> | |
| </div> | |
| </div> | |
| </header> | |
| )} | |
| {/* Key Contribution Highlight */} | |
| {paperStructure?.mainContribution && ( | |
| <div className="mb-24 relative overflow-hidden rounded-3xl bg-slate-900 dark:bg-slate-900 text-white p-8 md:p-12 shadow-2xl shadow-slate-900/20 animate-in fade-in slide-in-from-bottom-4 duration-700 group"> | |
| <div className="absolute top-0 right-0 p-32 bg-brand-500/20 rounded-full blur-3xl -mr-16 -mt-16 group-hover:bg-brand-500/30 transition-colors duration-1000"></div> | |
| <div className="absolute bottom-0 left-0 p-24 bg-purple-500/20 rounded-full blur-3xl -ml-12 -mb-12 group-hover:bg-purple-500/30 transition-colors duration-1000"></div> | |
| <div className="relative z-10"> | |
| <div className="flex items-center gap-3 mb-6"> | |
| <div className="p-2 rounded-lg bg-white/10 backdrop-blur-md border border-white/10"> | |
| <Sparkles size={20} className="text-brand-300" /> | |
| </div> | |
| <span className="text-sm font-bold uppercase tracking-widest text-brand-200">Core Contribution</span> | |
| </div> | |
| <p className="text-2xl md:text-3xl leading-relaxed font-display font-medium text-slate-50"> | |
| {paperStructure.mainContribution} | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| {/* Sections */} | |
| <div className="space-y-4"> | |
| {sections.map((section, index) => ( | |
| <div key={section.id}> | |
| {section.isLoading ? ( | |
| <SectionLoadingPlaceholder | |
| title={section.title} | |
| index={index} | |
| isCurrentlyGenerating={index === currentSection} | |
| statusMessage={index === currentSection ? sectionStatus : undefined} | |
| /> | |
| ) : section.error ? ( | |
| <SectionErrorState | |
| title={section.title} | |
| error={section.error} | |
| index={index} | |
| onRetry={onRetrySection ? () => onRetrySection(index) : undefined} | |
| isRetrying={retryingSectionIndex === index} | |
| /> | |
| ) : ( | |
| <BlogSectionComponent | |
| section={section} | |
| theme={theme} | |
| index={index} | |
| /> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| {/* Footer */} | |
| <footer className="mt-20 pt-10 border-t border-gray-200 dark:border-gray-800"> | |
| <div className="text-center"> | |
| <p className="text-sm text-gray-500 dark:text-gray-400 mb-4"> | |
| Generated with PaperStack • Powered by Gemini AI | |
| </p> | |
| <div className="flex justify-center gap-4"> | |
| <button | |
| onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} | |
| className="px-6 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-sm font-semibold transition-colors" | |
| > | |
| Back to Top ↑ | |
| </button> | |
| </div> | |
| </div> | |
| </footer> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default BlogView; | |