Spaces:
Sleeping
Sleeping
checkpoint-2
Browse files- App.tsx +205 -43
- components/BlogSection.tsx +178 -0
- components/BlogView.tsx +399 -0
- components/Collapsible.tsx +88 -0
- components/EquationBlock.tsx +107 -0
- components/InteractiveChart.tsx +315 -0
- components/Sidebar.tsx +182 -0
- components/Tooltip.tsx +84 -0
- index.html +96 -2
- package-lock.json +0 -0
- services/geminiService.ts +413 -1
- types.ts +63 -0
App.tsx
CHANGED
|
@@ -1,11 +1,12 @@
|
|
| 1 |
|
| 2 |
import React, { useState, useRef } from 'react';
|
| 3 |
-
import { Moon, Sun, Upload, FileText, Download, Share2, MessageSquare, AlertCircle, LayoutGrid, List, Grid, ChevronLeft, ArrowRight, X, BrainCircuit } from 'lucide-react';
|
| 4 |
import Background from './components/Background';
|
| 5 |
import BentoCard from './components/BentoCard';
|
| 6 |
import ChatBot from './components/ChatBot';
|
| 7 |
-
import
|
| 8 |
-
import {
|
|
|
|
| 9 |
|
| 10 |
const App: React.FC = () => {
|
| 11 |
// Settings
|
|
@@ -22,7 +23,13 @@ const App: React.FC = () => {
|
|
| 22 |
|
| 23 |
// State
|
| 24 |
const [view, setView] = useState<'input' | 'results'>('input');
|
|
|
|
| 25 |
const [cards, setCards] = useState<BentoCardData[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
const [status, setStatus] = useState<ProcessingStatus>({ state: 'idle' });
|
| 27 |
const [paperContext, setPaperContext] = useState<string>(''); // Stores the raw text/base64
|
| 28 |
const [paperTitle, setPaperTitle] = useState<string>('');
|
|
@@ -108,7 +115,12 @@ const App: React.FC = () => {
|
|
| 108 |
|
| 109 |
const handleReset = () => {
|
| 110 |
setView('input');
|
|
|
|
| 111 |
setCards([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
setStatus({ state: 'idle' });
|
| 113 |
setChatHistory([]);
|
| 114 |
setIsChatOpen(false);
|
|
@@ -117,6 +129,93 @@ const App: React.FC = () => {
|
|
| 117 |
setFile(null);
|
| 118 |
};
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
const handleExpandCard = async (card: BentoCardData) => {
|
| 121 |
if (card.expandedContent || card.isLoadingDetails) return;
|
| 122 |
|
|
@@ -403,49 +502,112 @@ const App: React.FC = () => {
|
|
| 403 |
{/* Results View */}
|
| 404 |
{view === 'results' && (
|
| 405 |
<div className="animate-in fade-in slide-in-from-bottom-8 duration-700">
|
| 406 |
-
|
| 407 |
-
<div className="flex justify-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
{
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
)}
|
| 415 |
-
</
|
| 416 |
-
|
| 417 |
-
<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">
|
| 418 |
-
<Share2 size={18} />
|
| 419 |
-
</button>
|
| 420 |
-
<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">
|
| 421 |
-
<Download size={18} />
|
| 422 |
-
</button>
|
| 423 |
-
<div className="w-px h-8 bg-gray-200 dark:bg-gray-700 mx-2"></div>
|
| 424 |
-
<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'}`}>
|
| 425 |
-
<Grid size={18} />
|
| 426 |
-
</button>
|
| 427 |
-
<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'}`}>
|
| 428 |
-
<List size={18} />
|
| 429 |
-
</button>
|
| 430 |
-
</div>
|
| 431 |
</div>
|
| 432 |
|
| 433 |
-
{/* Grid */}
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
|
| 450 |
{/* Floating Chat Trigger */}
|
| 451 |
<button
|
|
|
|
| 1 |
|
| 2 |
import React, { useState, useRef } from 'react';
|
| 3 |
+
import { Moon, Sun, Upload, FileText, Download, Share2, MessageSquare, AlertCircle, LayoutGrid, List, Grid, ChevronLeft, ArrowRight, X, BrainCircuit, BookOpen, Layers } from 'lucide-react';
|
| 4 |
import Background from './components/Background';
|
| 5 |
import BentoCard from './components/BentoCard';
|
| 6 |
import ChatBot from './components/ChatBot';
|
| 7 |
+
import BlogView from './components/BlogView';
|
| 8 |
+
import { BentoCardData, BlogSection, ChatMessage, AppSettings, ProcessingStatus, ViewMode, PaperStructure } from './types';
|
| 9 |
+
import { generateBentoCards, expandBentoCard, chatWithDocument, analyzePaperStructure, generateSingleBlogSection } from './services/geminiService';
|
| 10 |
|
| 11 |
const App: React.FC = () => {
|
| 12 |
// Settings
|
|
|
|
| 23 |
|
| 24 |
// State
|
| 25 |
const [view, setView] = useState<'input' | 'results'>('input');
|
| 26 |
+
const [resultViewMode, setResultViewMode] = useState<ViewMode>('grid');
|
| 27 |
const [cards, setCards] = useState<BentoCardData[]>([]);
|
| 28 |
+
const [blogSections, setBlogSections] = useState<BlogSection[]>([]);
|
| 29 |
+
const [paperStructure, setPaperStructure] = useState<PaperStructure | null>(null);
|
| 30 |
+
const [isBlogLoading, setIsBlogLoading] = useState(false);
|
| 31 |
+
const [blogLoadingStage, setBlogLoadingStage] = useState<'idle' | 'analyzing' | 'generating'>('idle');
|
| 32 |
+
const [currentGeneratingSection, setCurrentGeneratingSection] = useState<number>(-1);
|
| 33 |
const [status, setStatus] = useState<ProcessingStatus>({ state: 'idle' });
|
| 34 |
const [paperContext, setPaperContext] = useState<string>(''); // Stores the raw text/base64
|
| 35 |
const [paperTitle, setPaperTitle] = useState<string>('');
|
|
|
|
| 115 |
|
| 116 |
const handleReset = () => {
|
| 117 |
setView('input');
|
| 118 |
+
setResultViewMode('grid');
|
| 119 |
setCards([]);
|
| 120 |
+
setBlogSections([]);
|
| 121 |
+
setPaperStructure(null);
|
| 122 |
+
setBlogLoadingStage('idle');
|
| 123 |
+
setCurrentGeneratingSection(-1);
|
| 124 |
setStatus({ state: 'idle' });
|
| 125 |
setChatHistory([]);
|
| 126 |
setIsChatOpen(false);
|
|
|
|
| 129 |
setFile(null);
|
| 130 |
};
|
| 131 |
|
| 132 |
+
const handleSwitchToBlogView = async () => {
|
| 133 |
+
// If we already have fully generated sections, just switch view
|
| 134 |
+
if (blogSections.length > 0 && blogSections.every(s => !s.isLoading)) {
|
| 135 |
+
setResultViewMode('blog');
|
| 136 |
+
return;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
setIsBlogLoading(true);
|
| 140 |
+
setResultViewMode('blog');
|
| 141 |
+
|
| 142 |
+
try {
|
| 143 |
+
// Step 1: Analyze paper structure
|
| 144 |
+
setBlogLoadingStage('analyzing');
|
| 145 |
+
const structure = await analyzePaperStructure(
|
| 146 |
+
settings.apiKey,
|
| 147 |
+
settings.model,
|
| 148 |
+
paperContext,
|
| 149 |
+
true,
|
| 150 |
+
settings.useThinking
|
| 151 |
+
);
|
| 152 |
+
setPaperStructure(structure);
|
| 153 |
+
setPaperTitle(structure.paperTitle || paperTitle);
|
| 154 |
+
|
| 155 |
+
// Create placeholder sections with loading state
|
| 156 |
+
const placeholderSections: BlogSection[] = structure.sections.map((plan, index) => ({
|
| 157 |
+
id: plan.id,
|
| 158 |
+
title: plan.title,
|
| 159 |
+
content: '',
|
| 160 |
+
isLoading: true,
|
| 161 |
+
visualizationType: plan.suggestedVisualization
|
| 162 |
+
}));
|
| 163 |
+
setBlogSections(placeholderSections);
|
| 164 |
+
|
| 165 |
+
// Step 2: Generate each section progressively
|
| 166 |
+
setBlogLoadingStage('generating');
|
| 167 |
+
const paperContextInfo = {
|
| 168 |
+
title: structure.paperTitle,
|
| 169 |
+
abstract: structure.paperAbstract,
|
| 170 |
+
mainContribution: structure.mainContribution,
|
| 171 |
+
keyTerms: structure.keyTerms
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
for (let i = 0; i < structure.sections.length; i++) {
|
| 175 |
+
setCurrentGeneratingSection(i);
|
| 176 |
+
|
| 177 |
+
try {
|
| 178 |
+
const generatedSection = await generateSingleBlogSection(
|
| 179 |
+
settings.apiKey,
|
| 180 |
+
settings.model,
|
| 181 |
+
paperContext,
|
| 182 |
+
structure.sections[i],
|
| 183 |
+
i,
|
| 184 |
+
structure.sections.length,
|
| 185 |
+
paperContextInfo,
|
| 186 |
+
true,
|
| 187 |
+
settings.useThinking
|
| 188 |
+
);
|
| 189 |
+
|
| 190 |
+
// Update the specific section in state
|
| 191 |
+
setBlogSections(prev => prev.map((section, idx) =>
|
| 192 |
+
idx === i ? { ...generatedSection, isLoading: false } : section
|
| 193 |
+
));
|
| 194 |
+
} catch (sectionError: any) {
|
| 195 |
+
// Mark section as errored but continue with others
|
| 196 |
+
setBlogSections(prev => prev.map((section, idx) =>
|
| 197 |
+
idx === i ? {
|
| 198 |
+
...section,
|
| 199 |
+
isLoading: false,
|
| 200 |
+
error: sectionError.message,
|
| 201 |
+
content: `Failed to generate this section: ${sectionError.message}`
|
| 202 |
+
} : section
|
| 203 |
+
));
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
setCurrentGeneratingSection(-1);
|
| 208 |
+
setBlogLoadingStage('idle');
|
| 209 |
+
|
| 210 |
+
} catch (error: any) {
|
| 211 |
+
console.error('Failed to generate blog view:', error);
|
| 212 |
+
setStatus({ state: 'error', message: 'Failed to analyze paper: ' + error.message });
|
| 213 |
+
setBlogLoadingStage('idle');
|
| 214 |
+
} finally {
|
| 215 |
+
setIsBlogLoading(false);
|
| 216 |
+
}
|
| 217 |
+
};
|
| 218 |
+
|
| 219 |
const handleExpandCard = async (card: BentoCardData) => {
|
| 220 |
if (card.expandedContent || card.isLoadingDetails) return;
|
| 221 |
|
|
|
|
| 502 |
{/* Results View */}
|
| 503 |
{view === 'results' && (
|
| 504 |
<div className="animate-in fade-in slide-in-from-bottom-8 duration-700">
|
| 505 |
+
{/* View Mode Toggle - Grid vs Blog */}
|
| 506 |
+
<div className="flex justify-center mb-8">
|
| 507 |
+
<div className="inline-flex items-center p-1.5 rounded-2xl bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
| 508 |
+
<button
|
| 509 |
+
onClick={() => setResultViewMode('grid')}
|
| 510 |
+
className={`
|
| 511 |
+
flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold transition-all duration-300
|
| 512 |
+
${resultViewMode === 'grid'
|
| 513 |
+
? 'bg-white dark:bg-gray-900 text-brand-600 dark:text-brand-400 shadow-lg'
|
| 514 |
+
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
| 515 |
+
}
|
| 516 |
+
`}
|
| 517 |
+
>
|
| 518 |
+
<Layers size={18} />
|
| 519 |
+
<span>Bento Grid</span>
|
| 520 |
+
</button>
|
| 521 |
+
<button
|
| 522 |
+
onClick={handleSwitchToBlogView}
|
| 523 |
+
disabled={isBlogLoading && resultViewMode !== 'blog'}
|
| 524 |
+
className={`
|
| 525 |
+
flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold transition-all duration-300
|
| 526 |
+
${resultViewMode === 'blog'
|
| 527 |
+
? 'bg-white dark:bg-gray-900 text-brand-600 dark:text-brand-400 shadow-lg'
|
| 528 |
+
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
| 529 |
+
}
|
| 530 |
+
disabled:opacity-50
|
| 531 |
+
`}
|
| 532 |
+
>
|
| 533 |
+
{isBlogLoading && resultViewMode !== 'blog' ? (
|
| 534 |
+
<>
|
| 535 |
+
<div className="w-4 h-4 border-2 border-gray-400 border-t-brand-500 rounded-full animate-spin" />
|
| 536 |
+
<span>Analyzing...</span>
|
| 537 |
+
</>
|
| 538 |
+
) : (
|
| 539 |
+
<>
|
| 540 |
+
<BookOpen size={18} />
|
| 541 |
+
<span>Blog View</span>
|
| 542 |
+
</>
|
| 543 |
)}
|
| 544 |
+
</button>
|
| 545 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
</div>
|
| 547 |
|
| 548 |
+
{/* Grid View */}
|
| 549 |
+
{resultViewMode === 'grid' && (
|
| 550 |
+
<>
|
| 551 |
+
{/* Controls */}
|
| 552 |
+
<div className="flex justify-between items-center mb-6">
|
| 553 |
+
<h2 className="text-2xl font-display font-bold flex items-center gap-3">
|
| 554 |
+
Summary Grid
|
| 555 |
+
{settings.useThinking && (
|
| 556 |
+
<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">
|
| 557 |
+
<BrainCircuit size={12} /> Deep Thought
|
| 558 |
+
</span>
|
| 559 |
+
)}
|
| 560 |
+
</h2>
|
| 561 |
+
<div className="flex gap-2">
|
| 562 |
+
<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">
|
| 563 |
+
<Share2 size={18} />
|
| 564 |
+
</button>
|
| 565 |
+
<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">
|
| 566 |
+
<Download size={18} />
|
| 567 |
+
</button>
|
| 568 |
+
<div className="w-px h-8 bg-gray-200 dark:bg-gray-700 mx-2"></div>
|
| 569 |
+
<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'}`}>
|
| 570 |
+
<Grid size={18} />
|
| 571 |
+
</button>
|
| 572 |
+
<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'}`}>
|
| 573 |
+
<List size={18} />
|
| 574 |
+
</button>
|
| 575 |
+
</div>
|
| 576 |
+
</div>
|
| 577 |
+
|
| 578 |
+
{/* Grid */}
|
| 579 |
+
<div
|
| 580 |
+
ref={gridRef}
|
| 581 |
+
className="grid grid-cols-1 md:grid-cols-4 auto-rows-[minmax(180px,auto)] gap-4 md:gap-6 p-1 grid-flow-dense"
|
| 582 |
+
>
|
| 583 |
+
{cards.map((card) => (
|
| 584 |
+
<BentoCard
|
| 585 |
+
key={card.id}
|
| 586 |
+
card={card}
|
| 587 |
+
onExpand={handleExpandCard}
|
| 588 |
+
onRate={handleRateCard}
|
| 589 |
+
onResize={handleResizeCard}
|
| 590 |
+
layoutMode={settings.layoutMode}
|
| 591 |
+
/>
|
| 592 |
+
))}
|
| 593 |
+
</div>
|
| 594 |
+
</>
|
| 595 |
+
)}
|
| 596 |
+
|
| 597 |
+
{/* Blog View */}
|
| 598 |
+
{resultViewMode === 'blog' && (
|
| 599 |
+
<BlogView
|
| 600 |
+
sections={blogSections}
|
| 601 |
+
paperTitle={paperStructure?.paperTitle || paperTitle}
|
| 602 |
+
theme={settings.theme}
|
| 603 |
+
onExport={handleExport}
|
| 604 |
+
onShare={handleShare}
|
| 605 |
+
isLoading={isBlogLoading}
|
| 606 |
+
loadingStage={blogLoadingStage}
|
| 607 |
+
currentSection={currentGeneratingSection}
|
| 608 |
+
paperStructure={paperStructure}
|
| 609 |
+
/>
|
| 610 |
+
)}
|
| 611 |
|
| 612 |
{/* Floating Chat Trigger */}
|
| 613 |
<button
|
components/BlogSection.tsx
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import ReactMarkdown from 'react-markdown';
|
| 3 |
+
import { BlogSection as BlogSectionType } from '../types';
|
| 4 |
+
import MermaidDiagram from './MermaidDiagram';
|
| 5 |
+
import InteractiveChart from './InteractiveChart';
|
| 6 |
+
import EquationBlock from './EquationBlock';
|
| 7 |
+
import Collapsible from './Collapsible';
|
| 8 |
+
import Tooltip from './Tooltip';
|
| 9 |
+
import { Info, Lightbulb, AlertTriangle, BookOpen } from 'lucide-react';
|
| 10 |
+
|
| 11 |
+
interface Props {
|
| 12 |
+
section: BlogSectionType;
|
| 13 |
+
theme: 'light' | 'dark';
|
| 14 |
+
index: number;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const BlogSectionComponent: React.FC<Props> = ({ section, theme, index }) => {
|
| 18 |
+
const getMarginNoteIcon = (icon?: 'info' | 'warning' | 'tip' | 'note') => {
|
| 19 |
+
switch (icon) {
|
| 20 |
+
case 'warning':
|
| 21 |
+
return <AlertTriangle size={14} className="text-amber-500" />;
|
| 22 |
+
case 'tip':
|
| 23 |
+
return <Lightbulb size={14} className="text-green-500" />;
|
| 24 |
+
case 'info':
|
| 25 |
+
return <Info size={14} className="text-blue-500" />;
|
| 26 |
+
default:
|
| 27 |
+
return <BookOpen size={14} className="text-gray-400" />;
|
| 28 |
+
}
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
// Apply tooltips to content
|
| 32 |
+
const renderContent = () => {
|
| 33 |
+
if (!section.technicalTerms || section.technicalTerms.length === 0) {
|
| 34 |
+
return <ReactMarkdown>{section.content}</ReactMarkdown>;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// For complex tooltip integration, we'll render ReactMarkdown
|
| 38 |
+
// and let users hover on specially marked terms
|
| 39 |
+
return (
|
| 40 |
+
<div className="relative">
|
| 41 |
+
<ReactMarkdown>{section.content}</ReactMarkdown>
|
| 42 |
+
|
| 43 |
+
{/* Technical Terms Legend */}
|
| 44 |
+
{section.technicalTerms.length > 0 && (
|
| 45 |
+
<div className="mt-6 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-200 dark:border-gray-700">
|
| 46 |
+
<h5 className="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">
|
| 47 |
+
Key Terms
|
| 48 |
+
</h5>
|
| 49 |
+
<div className="flex flex-wrap gap-2">
|
| 50 |
+
{section.technicalTerms.map((term, idx) => (
|
| 51 |
+
<Tooltip key={idx} term={term.term} definition={term.definition}>
|
| 52 |
+
<span className="inline-flex items-center px-2.5 py-1 rounded-lg bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300 text-sm font-medium cursor-help">
|
| 53 |
+
{term.term}
|
| 54 |
+
</span>
|
| 55 |
+
</Tooltip>
|
| 56 |
+
))}
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
)}
|
| 60 |
+
</div>
|
| 61 |
+
);
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
return (
|
| 65 |
+
<section
|
| 66 |
+
id={`section-${section.id}`}
|
| 67 |
+
className="relative scroll-mt-32 animate-in fade-in slide-in-from-bottom-4 duration-700"
|
| 68 |
+
style={{ animationDelay: `${index * 100}ms` }}
|
| 69 |
+
>
|
| 70 |
+
<div className="flex gap-8">
|
| 71 |
+
{/* Main Content */}
|
| 72 |
+
<article className="flex-1 min-w-0">
|
| 73 |
+
{/* Section Header */}
|
| 74 |
+
<header className="mb-8">
|
| 75 |
+
<div className="flex items-center gap-4 mb-4">
|
| 76 |
+
<span className="flex-shrink-0 w-10 h-10 rounded-xl bg-gradient-to-br from-brand-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg shadow-brand-500/20">
|
| 77 |
+
{index + 1}
|
| 78 |
+
</span>
|
| 79 |
+
<h2 className="text-2xl md:text-3xl font-display font-bold text-gray-900 dark:text-gray-50 leading-tight">
|
| 80 |
+
{section.title}
|
| 81 |
+
</h2>
|
| 82 |
+
</div>
|
| 83 |
+
<div className="w-20 h-1 bg-gradient-to-r from-brand-500 to-purple-500 rounded-full" />
|
| 84 |
+
</header>
|
| 85 |
+
|
| 86 |
+
{/* Content */}
|
| 87 |
+
<div className="prose prose-lg dark:prose-invert max-w-none
|
| 88 |
+
prose-headings:font-display prose-headings:font-bold
|
| 89 |
+
prose-h3:text-xl prose-h3:mt-8 prose-h3:mb-4
|
| 90 |
+
prose-p:text-gray-700 prose-p:dark:text-gray-300 prose-p:leading-relaxed
|
| 91 |
+
prose-strong:text-gray-900 prose-strong:dark:text-white
|
| 92 |
+
prose-a:text-brand-600 prose-a:dark:text-brand-400 prose-a:no-underline hover:prose-a:underline
|
| 93 |
+
prose-blockquote:border-l-brand-500 prose-blockquote:bg-gray-50 prose-blockquote:dark:bg-gray-800/50 prose-blockquote:py-2 prose-blockquote:px-4 prose-blockquote:rounded-r-lg prose-blockquote:not-italic
|
| 94 |
+
prose-code:bg-gray-100 prose-code:dark:bg-gray-800 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-sm prose-code:before:content-none prose-code:after:content-none
|
| 95 |
+
prose-pre:bg-gray-900 prose-pre:dark:bg-black prose-pre:border prose-pre:border-gray-800
|
| 96 |
+
prose-li:text-gray-700 prose-li:dark:text-gray-300
|
| 97 |
+
">
|
| 98 |
+
{renderContent()}
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
{/* Visualization */}
|
| 102 |
+
{section.visualizationType && section.visualizationType !== 'none' && (
|
| 103 |
+
<div className="my-10">
|
| 104 |
+
<div className="p-6 rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 shadow-lg shadow-gray-200/50 dark:shadow-none">
|
| 105 |
+
{section.visualizationType === 'mermaid' && section.visualizationData && (
|
| 106 |
+
<div className="min-h-[200px]">
|
| 107 |
+
<MermaidDiagram chart={section.visualizationData} theme={theme} />
|
| 108 |
+
</div>
|
| 109 |
+
)}
|
| 110 |
+
|
| 111 |
+
{section.visualizationType === 'chart' && section.chartData && (
|
| 112 |
+
<InteractiveChart data={section.chartData} theme={theme} />
|
| 113 |
+
)}
|
| 114 |
+
|
| 115 |
+
{section.visualizationType === 'equation' && section.visualizationData && (
|
| 116 |
+
<EquationBlock equation={section.visualizationData} label={`${index + 1}`} />
|
| 117 |
+
)}
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
)}
|
| 121 |
+
|
| 122 |
+
{/* Collapsible Deep Dives */}
|
| 123 |
+
{section.collapsibleSections && section.collapsibleSections.length > 0 && (
|
| 124 |
+
<div className="mt-8 space-y-4">
|
| 125 |
+
{section.collapsibleSections.map((collapsible, idx) => (
|
| 126 |
+
<Collapsible
|
| 127 |
+
key={collapsible.id}
|
| 128 |
+
title={collapsible.title}
|
| 129 |
+
variant={idx === 0 ? 'deep-dive' : 'default'}
|
| 130 |
+
>
|
| 131 |
+
<ReactMarkdown>{collapsible.content}</ReactMarkdown>
|
| 132 |
+
</Collapsible>
|
| 133 |
+
))}
|
| 134 |
+
</div>
|
| 135 |
+
)}
|
| 136 |
+
</article>
|
| 137 |
+
|
| 138 |
+
{/* Margin Notes */}
|
| 139 |
+
{section.marginNotes && section.marginNotes.length > 0 && (
|
| 140 |
+
<aside className="hidden xl:block w-64 flex-shrink-0">
|
| 141 |
+
<div className="sticky top-32 space-y-4">
|
| 142 |
+
{section.marginNotes.map((note, idx) => (
|
| 143 |
+
<div
|
| 144 |
+
key={note.id}
|
| 145 |
+
className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 animate-in fade-in slide-in-from-right-4 duration-500"
|
| 146 |
+
style={{ animationDelay: `${(index * 100) + (idx * 50)}ms` }}
|
| 147 |
+
>
|
| 148 |
+
<div className="flex items-start gap-3">
|
| 149 |
+
<div className="flex-shrink-0 mt-0.5">
|
| 150 |
+
{getMarginNoteIcon(note.icon)}
|
| 151 |
+
</div>
|
| 152 |
+
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed">
|
| 153 |
+
{note.text}
|
| 154 |
+
</p>
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
))}
|
| 158 |
+
</div>
|
| 159 |
+
</aside>
|
| 160 |
+
)}
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
{/* Section Divider */}
|
| 164 |
+
<div className="my-16 flex items-center gap-4">
|
| 165 |
+
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-gray-300 dark:via-gray-700 to-transparent" />
|
| 166 |
+
<div className="flex gap-1">
|
| 167 |
+
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 dark:bg-gray-700" />
|
| 168 |
+
<div className="w-1.5 h-1.5 rounded-full bg-gray-400 dark:bg-gray-600" />
|
| 169 |
+
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 dark:bg-gray-700" />
|
| 170 |
+
</div>
|
| 171 |
+
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-gray-300 dark:via-gray-700 to-transparent" />
|
| 172 |
+
</div>
|
| 173 |
+
</section>
|
| 174 |
+
);
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
export default BlogSectionComponent;
|
| 178 |
+
|
components/BlogView.tsx
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState, useRef } from 'react';
|
| 2 |
+
import { BlogSection as BlogSectionType, PaperStructure } from '../types';
|
| 3 |
+
import BlogSectionComponent from './BlogSection';
|
| 4 |
+
import Sidebar from './Sidebar';
|
| 5 |
+
import { Clock, BookOpen, FileText, Share2, Download, Sparkles, CheckCircle2, Loader2, AlertCircle, RefreshCw } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
// Loading placeholder for sections being generated
|
| 8 |
+
const SectionLoadingPlaceholder: React.FC<{ title: string; index: number; isCurrentlyGenerating: boolean }> = ({
|
| 9 |
+
title,
|
| 10 |
+
index,
|
| 11 |
+
isCurrentlyGenerating
|
| 12 |
+
}) => (
|
| 13 |
+
<section className="relative scroll-mt-32 animate-in fade-in duration-500">
|
| 14 |
+
<div className="flex gap-8">
|
| 15 |
+
<article className="flex-1 min-w-0">
|
| 16 |
+
<header className="mb-8">
|
| 17 |
+
<div className="flex items-center gap-4 mb-4">
|
| 18 |
+
<span className={`
|
| 19 |
+
flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center text-white font-bold text-lg shadow-lg
|
| 20 |
+
${isCurrentlyGenerating
|
| 21 |
+
? 'bg-gradient-to-br from-brand-500 to-purple-600 animate-pulse'
|
| 22 |
+
: 'bg-gray-300 dark:bg-gray-700'
|
| 23 |
+
}
|
| 24 |
+
`}>
|
| 25 |
+
{isCurrentlyGenerating ? (
|
| 26 |
+
<Loader2 size={20} className="animate-spin" />
|
| 27 |
+
) : (
|
| 28 |
+
index + 1
|
| 29 |
+
)}
|
| 30 |
+
</span>
|
| 31 |
+
<h2 className={`text-2xl md:text-3xl font-display font-bold leading-tight ${
|
| 32 |
+
isCurrentlyGenerating ? 'text-gray-900 dark:text-gray-50' : 'text-gray-400 dark:text-gray-600'
|
| 33 |
+
}`}>
|
| 34 |
+
{title}
|
| 35 |
+
</h2>
|
| 36 |
+
</div>
|
| 37 |
+
<div className={`w-20 h-1 rounded-full ${
|
| 38 |
+
isCurrentlyGenerating
|
| 39 |
+
? 'bg-gradient-to-r from-brand-500 to-purple-500 animate-pulse'
|
| 40 |
+
: 'bg-gray-200 dark:bg-gray-800'
|
| 41 |
+
}`} />
|
| 42 |
+
</header>
|
| 43 |
+
|
| 44 |
+
{/* Loading skeleton */}
|
| 45 |
+
<div className="space-y-4">
|
| 46 |
+
{isCurrentlyGenerating ? (
|
| 47 |
+
<>
|
| 48 |
+
<div className="flex items-center gap-2 text-sm text-brand-600 dark:text-brand-400 mb-4">
|
| 49 |
+
<Loader2 size={14} className="animate-spin" />
|
| 50 |
+
<span>Generating content...</span>
|
| 51 |
+
</div>
|
| 52 |
+
<div className="space-y-3">
|
| 53 |
+
<div className="h-4 bg-gray-200 dark:bg-gray-800 rounded-lg w-full animate-pulse" />
|
| 54 |
+
<div className="h-4 bg-gray-200 dark:bg-gray-800 rounded-lg w-11/12 animate-pulse delay-75" />
|
| 55 |
+
<div className="h-4 bg-gray-200 dark:bg-gray-800 rounded-lg w-4/5 animate-pulse delay-100" />
|
| 56 |
+
<div className="h-4 bg-gray-200 dark:bg-gray-800 rounded-lg w-full animate-pulse delay-150" />
|
| 57 |
+
<div className="h-4 bg-gray-200 dark:bg-gray-800 rounded-lg w-3/4 animate-pulse delay-200" />
|
| 58 |
+
</div>
|
| 59 |
+
<div className="mt-6 p-4 rounded-xl bg-gray-100 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700">
|
| 60 |
+
<div className="h-32 bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse" />
|
| 61 |
+
</div>
|
| 62 |
+
</>
|
| 63 |
+
) : (
|
| 64 |
+
<div className="p-8 rounded-xl border-2 border-dashed border-gray-200 dark:border-gray-800 text-center">
|
| 65 |
+
<p className="text-gray-400 dark:text-gray-600 text-sm">
|
| 66 |
+
Waiting to be generated...
|
| 67 |
+
</p>
|
| 68 |
+
</div>
|
| 69 |
+
)}
|
| 70 |
+
</div>
|
| 71 |
+
</article>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
{/* Section Divider */}
|
| 75 |
+
<div className="my-16 flex items-center gap-4">
|
| 76 |
+
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-gray-200 dark:via-gray-800 to-transparent" />
|
| 77 |
+
</div>
|
| 78 |
+
</section>
|
| 79 |
+
);
|
| 80 |
+
|
| 81 |
+
// Error state for failed sections
|
| 82 |
+
const SectionErrorState: React.FC<{ title: string; error: string; index: number }> = ({ title, error, index }) => (
|
| 83 |
+
<section className="relative scroll-mt-32">
|
| 84 |
+
<div className="flex gap-8">
|
| 85 |
+
<article className="flex-1 min-w-0">
|
| 86 |
+
<header className="mb-8">
|
| 87 |
+
<div className="flex items-center gap-4 mb-4">
|
| 88 |
+
<span className="flex-shrink-0 w-10 h-10 rounded-xl bg-red-500 flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
| 89 |
+
<AlertCircle size={20} />
|
| 90 |
+
</span>
|
| 91 |
+
<h2 className="text-2xl md:text-3xl font-display font-bold text-gray-900 dark:text-gray-50 leading-tight">
|
| 92 |
+
{title}
|
| 93 |
+
</h2>
|
| 94 |
+
</div>
|
| 95 |
+
</header>
|
| 96 |
+
|
| 97 |
+
<div className="p-6 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
| 98 |
+
<div className="flex items-start gap-3">
|
| 99 |
+
<AlertCircle size={20} className="text-red-500 flex-shrink-0 mt-0.5" />
|
| 100 |
+
<div>
|
| 101 |
+
<p className="font-semibold text-red-700 dark:text-red-300">
|
| 102 |
+
Failed to generate this section
|
| 103 |
+
</p>
|
| 104 |
+
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
|
| 105 |
+
{error}
|
| 106 |
+
</p>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
</article>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<div className="my-16 flex items-center gap-4">
|
| 114 |
+
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-gray-300 dark:via-gray-700 to-transparent" />
|
| 115 |
+
</div>
|
| 116 |
+
</section>
|
| 117 |
+
);
|
| 118 |
+
|
| 119 |
+
interface Props {
|
| 120 |
+
sections: BlogSectionType[];
|
| 121 |
+
paperTitle: string;
|
| 122 |
+
theme: 'light' | 'dark';
|
| 123 |
+
onExport: () => void;
|
| 124 |
+
onShare: () => void;
|
| 125 |
+
isLoading?: boolean;
|
| 126 |
+
loadingStage?: 'idle' | 'analyzing' | 'generating';
|
| 127 |
+
currentSection?: number;
|
| 128 |
+
paperStructure?: PaperStructure | null;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
const BlogView: React.FC<Props> = ({
|
| 132 |
+
sections,
|
| 133 |
+
paperTitle,
|
| 134 |
+
theme,
|
| 135 |
+
onExport,
|
| 136 |
+
onShare,
|
| 137 |
+
isLoading = false,
|
| 138 |
+
loadingStage = 'idle',
|
| 139 |
+
currentSection = -1,
|
| 140 |
+
paperStructure = null
|
| 141 |
+
}) => {
|
| 142 |
+
const [activeSection, setActiveSection] = useState<string>(sections[0]?.id || '');
|
| 143 |
+
const [readProgress, setReadProgress] = useState(0);
|
| 144 |
+
const contentRef = useRef<HTMLDivElement>(null);
|
| 145 |
+
|
| 146 |
+
// Calculate reading time (rough estimate: 200 words per minute)
|
| 147 |
+
const completedSections = sections.filter(s => !s.isLoading && s.content);
|
| 148 |
+
const totalWords = completedSections.reduce((acc, section) => {
|
| 149 |
+
return acc + (section.content?.split(/\s+/).length || 0);
|
| 150 |
+
}, 0);
|
| 151 |
+
const readingTime = Math.max(1, Math.ceil(totalWords / 200));
|
| 152 |
+
|
| 153 |
+
// Count completed sections
|
| 154 |
+
const completedCount = sections.filter(s => !s.isLoading && !s.error).length;
|
| 155 |
+
|
| 156 |
+
// Intersection Observer for active section tracking
|
| 157 |
+
useEffect(() => {
|
| 158 |
+
const options = {
|
| 159 |
+
root: null,
|
| 160 |
+
rootMargin: '-20% 0px -60% 0px',
|
| 161 |
+
threshold: 0
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
const observer = new IntersectionObserver((entries) => {
|
| 165 |
+
entries.forEach((entry) => {
|
| 166 |
+
if (entry.isIntersecting) {
|
| 167 |
+
const id = entry.target.id.replace('section-', '');
|
| 168 |
+
setActiveSection(id);
|
| 169 |
+
}
|
| 170 |
+
});
|
| 171 |
+
}, options);
|
| 172 |
+
|
| 173 |
+
sections.forEach((section) => {
|
| 174 |
+
const element = document.getElementById(`section-${section.id}`);
|
| 175 |
+
if (element) observer.observe(element);
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
return () => observer.disconnect();
|
| 179 |
+
}, [sections]);
|
| 180 |
+
|
| 181 |
+
// Scroll progress tracking
|
| 182 |
+
useEffect(() => {
|
| 183 |
+
const handleScroll = () => {
|
| 184 |
+
const winScroll = document.documentElement.scrollTop;
|
| 185 |
+
const height = document.documentElement.scrollHeight - document.documentElement.clientHeight;
|
| 186 |
+
const scrolled = (winScroll / height) * 100;
|
| 187 |
+
setReadProgress(scrolled);
|
| 188 |
+
};
|
| 189 |
+
|
| 190 |
+
window.addEventListener('scroll', handleScroll);
|
| 191 |
+
return () => window.removeEventListener('scroll', handleScroll);
|
| 192 |
+
}, []);
|
| 193 |
+
|
| 194 |
+
return (
|
| 195 |
+
<div className="min-h-screen">
|
| 196 |
+
{/* Reading Progress Bar */}
|
| 197 |
+
<div className="fixed top-0 left-0 right-0 h-1 bg-gray-200 dark:bg-gray-800 z-50">
|
| 198 |
+
<div
|
| 199 |
+
className="h-full bg-gradient-to-r from-brand-500 via-purple-500 to-pink-500 transition-all duration-150"
|
| 200 |
+
style={{ width: `${readProgress}%` }}
|
| 201 |
+
/>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
{/* Sidebar Navigation */}
|
| 205 |
+
{sections.length > 0 && (
|
| 206 |
+
<Sidebar
|
| 207 |
+
sections={sections}
|
| 208 |
+
activeSection={activeSection}
|
| 209 |
+
onSectionClick={setActiveSection}
|
| 210 |
+
/>
|
| 211 |
+
)}
|
| 212 |
+
|
| 213 |
+
{/* Main Content */}
|
| 214 |
+
<div className="lg:ml-72 xl:mr-8">
|
| 215 |
+
<div ref={contentRef} className="max-w-3xl mx-auto px-6 py-8">
|
| 216 |
+
|
| 217 |
+
{/* Loading State - Paper Analysis */}
|
| 218 |
+
{loadingStage === 'analyzing' && (
|
| 219 |
+
<div className="flex flex-col items-center justify-center min-h-[60vh] animate-in fade-in duration-500">
|
| 220 |
+
<div className="relative">
|
| 221 |
+
<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">
|
| 222 |
+
<Sparkles size={40} className="text-white animate-bounce" />
|
| 223 |
+
</div>
|
| 224 |
+
<div className="absolute inset-0 rounded-full bg-gradient-to-r from-brand-500 to-purple-600 animate-ping opacity-20" />
|
| 225 |
+
</div>
|
| 226 |
+
<h2 className="mt-8 text-2xl font-display font-bold text-gray-900 dark:text-white">
|
| 227 |
+
Analyzing Paper Structure
|
| 228 |
+
</h2>
|
| 229 |
+
<p className="mt-3 text-gray-500 dark:text-gray-400 text-center max-w-md">
|
| 230 |
+
Understanding the paper's key contributions, methodology, and findings to create the optimal narrative structure...
|
| 231 |
+
</p>
|
| 232 |
+
</div>
|
| 233 |
+
)}
|
| 234 |
+
|
| 235 |
+
{/* Generation Progress Banner */}
|
| 236 |
+
{loadingStage === 'generating' && (
|
| 237 |
+
<div className="mb-8 p-4 rounded-2xl bg-gradient-to-r from-brand-50 to-purple-50 dark:from-brand-900/20 dark:to-purple-900/20 border border-brand-200 dark:border-brand-800 animate-in slide-in-from-top duration-500">
|
| 238 |
+
<div className="flex items-center gap-4">
|
| 239 |
+
<div className="flex-shrink-0">
|
| 240 |
+
<div className="w-10 h-10 rounded-full bg-brand-500 flex items-center justify-center">
|
| 241 |
+
<Loader2 size={20} className="text-white animate-spin" />
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
<div className="flex-1 min-w-0">
|
| 245 |
+
<div className="flex items-center justify-between mb-2">
|
| 246 |
+
<span className="text-sm font-semibold text-brand-700 dark:text-brand-300">
|
| 247 |
+
Generating sections...
|
| 248 |
+
</span>
|
| 249 |
+
<span className="text-sm text-brand-600 dark:text-brand-400">
|
| 250 |
+
{completedCount} / {sections.length}
|
| 251 |
+
</span>
|
| 252 |
+
</div>
|
| 253 |
+
<div className="h-2 bg-brand-100 dark:bg-brand-900/30 rounded-full overflow-hidden">
|
| 254 |
+
<div
|
| 255 |
+
className="h-full bg-gradient-to-r from-brand-500 to-purple-500 rounded-full transition-all duration-500"
|
| 256 |
+
style={{ width: `${(completedCount / sections.length) * 100}%` }}
|
| 257 |
+
/>
|
| 258 |
+
</div>
|
| 259 |
+
{currentSection >= 0 && sections[currentSection] && (
|
| 260 |
+
<p className="mt-2 text-xs text-gray-600 dark:text-gray-400 truncate">
|
| 261 |
+
Currently writing: <span className="font-medium">{sections[currentSection].title}</span>
|
| 262 |
+
</p>
|
| 263 |
+
)}
|
| 264 |
+
</div>
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
)}
|
| 268 |
+
|
| 269 |
+
{/* Article Header */}
|
| 270 |
+
{(sections.length > 0 || paperStructure) && (
|
| 271 |
+
<header className="mb-16 animate-in fade-in slide-in-from-bottom-8 duration-700">
|
| 272 |
+
{/* Paper Badge */}
|
| 273 |
+
<div className="flex items-center gap-2 mb-6">
|
| 274 |
+
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300 text-xs font-semibold uppercase tracking-wider">
|
| 275 |
+
<FileText size={12} />
|
| 276 |
+
Research Summary
|
| 277 |
+
</span>
|
| 278 |
+
{loadingStage === 'generating' && (
|
| 279 |
+
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 text-xs font-semibold">
|
| 280 |
+
<Loader2 size={12} className="animate-spin" />
|
| 281 |
+
Generating...
|
| 282 |
+
</span>
|
| 283 |
+
)}
|
| 284 |
+
</div>
|
| 285 |
+
|
| 286 |
+
{/* Title */}
|
| 287 |
+
<h1 className="text-4xl md:text-5xl lg:text-6xl font-display font-bold text-gray-900 dark:text-white leading-[1.1] mb-8">
|
| 288 |
+
{(paperStructure?.paperTitle || paperTitle).replace('.pdf', '')}
|
| 289 |
+
</h1>
|
| 290 |
+
|
| 291 |
+
{/* Abstract Preview */}
|
| 292 |
+
{paperStructure?.paperAbstract && (
|
| 293 |
+
<p className="text-xl text-gray-600 dark:text-gray-300 leading-relaxed mb-8 italic border-l-4 border-brand-500 pl-4">
|
| 294 |
+
{paperStructure.paperAbstract}
|
| 295 |
+
</p>
|
| 296 |
+
)}
|
| 297 |
+
|
| 298 |
+
{/* Meta Info */}
|
| 299 |
+
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-500 dark:text-gray-400">
|
| 300 |
+
<div className="flex items-center gap-2">
|
| 301 |
+
<Clock size={16} />
|
| 302 |
+
<span>{readingTime} min read</span>
|
| 303 |
+
</div>
|
| 304 |
+
<div className="flex items-center gap-2">
|
| 305 |
+
<BookOpen size={16} />
|
| 306 |
+
<span>{sections.length} sections</span>
|
| 307 |
+
</div>
|
| 308 |
+
{completedCount === sections.length && sections.length > 0 && (
|
| 309 |
+
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
| 310 |
+
<CheckCircle2 size={16} />
|
| 311 |
+
<span>Complete</span>
|
| 312 |
+
</div>
|
| 313 |
+
)}
|
| 314 |
+
<div className="flex items-center gap-1 ml-auto">
|
| 315 |
+
<button
|
| 316 |
+
onClick={onShare}
|
| 317 |
+
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
| 318 |
+
title="Share"
|
| 319 |
+
>
|
| 320 |
+
<Share2 size={18} />
|
| 321 |
+
</button>
|
| 322 |
+
<button
|
| 323 |
+
onClick={onExport}
|
| 324 |
+
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
| 325 |
+
title="Export"
|
| 326 |
+
>
|
| 327 |
+
<Download size={18} />
|
| 328 |
+
</button>
|
| 329 |
+
</div>
|
| 330 |
+
</div>
|
| 331 |
+
|
| 332 |
+
{/* Decorative Line */}
|
| 333 |
+
<div className="mt-10 flex items-center gap-4">
|
| 334 |
+
<div className="flex-1 h-px bg-gradient-to-r from-brand-500 via-purple-500 to-transparent" />
|
| 335 |
+
<div className="w-2 h-2 rounded-full bg-brand-500 animate-pulse" />
|
| 336 |
+
</div>
|
| 337 |
+
</header>
|
| 338 |
+
)}
|
| 339 |
+
|
| 340 |
+
{/* Key Contribution Highlight */}
|
| 341 |
+
{paperStructure?.mainContribution && (
|
| 342 |
+
<div className="mb-16 p-8 rounded-2xl bg-gradient-to-br from-brand-50 to-purple-50 dark:from-brand-900/20 dark:to-purple-900/20 border border-brand-200 dark:border-brand-800 shadow-xl shadow-brand-200/20 dark:shadow-none animate-in fade-in slide-in-from-bottom-4 duration-700">
|
| 343 |
+
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-brand-600 dark:text-brand-400 mb-4">
|
| 344 |
+
<Sparkles size={14} />
|
| 345 |
+
Key Contribution
|
| 346 |
+
</div>
|
| 347 |
+
<p className="text-xl md:text-2xl leading-relaxed text-gray-800 dark:text-gray-200 font-medium">
|
| 348 |
+
{paperStructure.mainContribution}
|
| 349 |
+
</p>
|
| 350 |
+
</div>
|
| 351 |
+
)}
|
| 352 |
+
|
| 353 |
+
{/* Sections */}
|
| 354 |
+
<div className="space-y-4">
|
| 355 |
+
{sections.map((section, index) => (
|
| 356 |
+
<div key={section.id}>
|
| 357 |
+
{section.isLoading ? (
|
| 358 |
+
<SectionLoadingPlaceholder
|
| 359 |
+
title={section.title}
|
| 360 |
+
index={index}
|
| 361 |
+
isCurrentlyGenerating={index === currentSection}
|
| 362 |
+
/>
|
| 363 |
+
) : section.error ? (
|
| 364 |
+
<SectionErrorState title={section.title} error={section.error} index={index} />
|
| 365 |
+
) : (
|
| 366 |
+
<BlogSectionComponent
|
| 367 |
+
section={section}
|
| 368 |
+
theme={theme}
|
| 369 |
+
index={index}
|
| 370 |
+
/>
|
| 371 |
+
)}
|
| 372 |
+
</div>
|
| 373 |
+
))}
|
| 374 |
+
</div>
|
| 375 |
+
|
| 376 |
+
{/* Footer */}
|
| 377 |
+
<footer className="mt-20 pt-10 border-t border-gray-200 dark:border-gray-800">
|
| 378 |
+
<div className="text-center">
|
| 379 |
+
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
| 380 |
+
Generated with PaperStack • Powered by Gemini AI
|
| 381 |
+
</p>
|
| 382 |
+
<div className="flex justify-center gap-4">
|
| 383 |
+
<button
|
| 384 |
+
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
| 385 |
+
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"
|
| 386 |
+
>
|
| 387 |
+
Back to Top ↑
|
| 388 |
+
</button>
|
| 389 |
+
</div>
|
| 390 |
+
</div>
|
| 391 |
+
</footer>
|
| 392 |
+
</div>
|
| 393 |
+
</div>
|
| 394 |
+
</div>
|
| 395 |
+
);
|
| 396 |
+
};
|
| 397 |
+
|
| 398 |
+
export default BlogView;
|
| 399 |
+
|
components/Collapsible.tsx
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { ChevronDown, Lightbulb, BookOpen, Info } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
interface Props {
|
| 5 |
+
title: string;
|
| 6 |
+
children: React.ReactNode;
|
| 7 |
+
defaultOpen?: boolean;
|
| 8 |
+
variant?: 'default' | 'info' | 'deep-dive';
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const Collapsible: React.FC<Props> = ({
|
| 12 |
+
title,
|
| 13 |
+
children,
|
| 14 |
+
defaultOpen = false,
|
| 15 |
+
variant = 'default'
|
| 16 |
+
}) => {
|
| 17 |
+
const [isOpen, setIsOpen] = useState(defaultOpen);
|
| 18 |
+
|
| 19 |
+
const getVariantStyles = () => {
|
| 20 |
+
switch (variant) {
|
| 21 |
+
case 'info':
|
| 22 |
+
return {
|
| 23 |
+
wrapper: 'border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/10',
|
| 24 |
+
header: 'hover:bg-blue-100/50 dark:hover:bg-blue-900/20',
|
| 25 |
+
icon: <Info size={16} className="text-blue-500" />,
|
| 26 |
+
title: 'text-blue-700 dark:text-blue-300'
|
| 27 |
+
};
|
| 28 |
+
case 'deep-dive':
|
| 29 |
+
return {
|
| 30 |
+
wrapper: 'border-purple-200 dark:border-purple-800 bg-purple-50/50 dark:bg-purple-900/10',
|
| 31 |
+
header: 'hover:bg-purple-100/50 dark:hover:bg-purple-900/20',
|
| 32 |
+
icon: <Lightbulb size={16} className="text-purple-500" />,
|
| 33 |
+
title: 'text-purple-700 dark:text-purple-300'
|
| 34 |
+
};
|
| 35 |
+
default:
|
| 36 |
+
return {
|
| 37 |
+
wrapper: 'border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/30',
|
| 38 |
+
header: 'hover:bg-gray-100/50 dark:hover:bg-gray-800/50',
|
| 39 |
+
icon: <BookOpen size={16} className="text-gray-500" />,
|
| 40 |
+
title: 'text-gray-700 dark:text-gray-300'
|
| 41 |
+
};
|
| 42 |
+
}
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const styles = getVariantStyles();
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<div className={`rounded-xl border overflow-hidden transition-all duration-300 ${styles.wrapper}`}>
|
| 49 |
+
<button
|
| 50 |
+
onClick={() => setIsOpen(!isOpen)}
|
| 51 |
+
className={`
|
| 52 |
+
w-full flex items-center justify-between p-4 text-left
|
| 53 |
+
transition-colors duration-200 ${styles.header}
|
| 54 |
+
`}
|
| 55 |
+
>
|
| 56 |
+
<div className="flex items-center gap-3">
|
| 57 |
+
{styles.icon}
|
| 58 |
+
<span className={`font-semibold text-sm ${styles.title}`}>
|
| 59 |
+
{title}
|
| 60 |
+
</span>
|
| 61 |
+
</div>
|
| 62 |
+
<ChevronDown
|
| 63 |
+
size={18}
|
| 64 |
+
className={`
|
| 65 |
+
transition-transform duration-300 ease-out text-gray-400
|
| 66 |
+
${isOpen ? 'rotate-180' : ''}
|
| 67 |
+
`}
|
| 68 |
+
/>
|
| 69 |
+
</button>
|
| 70 |
+
|
| 71 |
+
<div
|
| 72 |
+
className={`
|
| 73 |
+
overflow-hidden transition-all duration-300 ease-out
|
| 74 |
+
${isOpen ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0'}
|
| 75 |
+
`}
|
| 76 |
+
>
|
| 77 |
+
<div className="p-4 pt-0 border-t border-gray-100 dark:border-gray-800">
|
| 78 |
+
<div className="pt-4 prose prose-sm dark:prose-invert max-w-none">
|
| 79 |
+
{children}
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
);
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
export default Collapsible;
|
| 88 |
+
|
components/EquationBlock.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Copy, Check } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
interface Props {
|
| 5 |
+
equation: string;
|
| 6 |
+
label?: string;
|
| 7 |
+
inline?: boolean;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
const EquationBlock: React.FC<Props> = ({ equation, label, inline = false }) => {
|
| 11 |
+
const [copied, setCopied] = React.useState(false);
|
| 12 |
+
|
| 13 |
+
const handleCopy = async () => {
|
| 14 |
+
await navigator.clipboard.writeText(equation);
|
| 15 |
+
setCopied(true);
|
| 16 |
+
setTimeout(() => setCopied(false), 2000);
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
// Simple LaTeX-like rendering (for demonstration)
|
| 20 |
+
// In production, integrate KaTeX for proper math rendering
|
| 21 |
+
const formatEquation = (eq: string): string => {
|
| 22 |
+
return eq
|
| 23 |
+
.replace(/\^(\{[^}]+\}|\d)/g, '<sup>$1</sup>')
|
| 24 |
+
.replace(/\_(\{[^}]+\}|\d)/g, '<sub>$1</sub>')
|
| 25 |
+
.replace(/\\frac\{([^}]+)\}\{([^}]+)\}/g, '<span class="frac"><span>$1</span><span>$2</span></span>')
|
| 26 |
+
.replace(/\\sqrt\{([^}]+)\}/g, '√($1)')
|
| 27 |
+
.replace(/\\sum/g, 'Σ')
|
| 28 |
+
.replace(/\\prod/g, 'Π')
|
| 29 |
+
.replace(/\\int/g, '∫')
|
| 30 |
+
.replace(/\\infty/g, '∞')
|
| 31 |
+
.replace(/\\alpha/g, 'α')
|
| 32 |
+
.replace(/\\beta/g, 'β')
|
| 33 |
+
.replace(/\\gamma/g, 'γ')
|
| 34 |
+
.replace(/\\delta/g, 'δ')
|
| 35 |
+
.replace(/\\theta/g, 'θ')
|
| 36 |
+
.replace(/\\lambda/g, 'λ')
|
| 37 |
+
.replace(/\\mu/g, 'μ')
|
| 38 |
+
.replace(/\\sigma/g, 'σ')
|
| 39 |
+
.replace(/\\pi/g, 'π')
|
| 40 |
+
.replace(/\\omega/g, 'ω')
|
| 41 |
+
.replace(/\\partial/g, '∂')
|
| 42 |
+
.replace(/\\nabla/g, '∇')
|
| 43 |
+
.replace(/\\cdot/g, '·')
|
| 44 |
+
.replace(/\\times/g, '×')
|
| 45 |
+
.replace(/\\leq/g, '≤')
|
| 46 |
+
.replace(/\\geq/g, '≥')
|
| 47 |
+
.replace(/\\neq/g, '≠')
|
| 48 |
+
.replace(/\\approx/g, '≈')
|
| 49 |
+
.replace(/\\rightarrow/g, '→')
|
| 50 |
+
.replace(/\\leftarrow/g, '←')
|
| 51 |
+
.replace(/\\Rightarrow/g, '⇒')
|
| 52 |
+
.replace(/\\in/g, '∈')
|
| 53 |
+
.replace(/\\forall/g, '∀')
|
| 54 |
+
.replace(/\\exists/g, '∃');
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
if (inline) {
|
| 58 |
+
return (
|
| 59 |
+
<span
|
| 60 |
+
className="inline-flex items-center px-2 py-0.5 mx-1 font-mono text-sm bg-gray-100 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
|
| 61 |
+
dangerouslySetInnerHTML={{ __html: formatEquation(equation) }}
|
| 62 |
+
/>
|
| 63 |
+
);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
return (
|
| 67 |
+
<div className="relative group my-6">
|
| 68 |
+
<div className="flex items-stretch rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700 bg-gradient-to-r from-gray-50 to-white dark:from-gray-900 dark:to-gray-800">
|
| 69 |
+
{/* Equation Number/Label */}
|
| 70 |
+
{label && (
|
| 71 |
+
<div className="flex items-center px-4 bg-gray-100 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700">
|
| 72 |
+
<span className="text-xs font-mono text-gray-500 dark:text-gray-400">
|
| 73 |
+
({label})
|
| 74 |
+
</span>
|
| 75 |
+
</div>
|
| 76 |
+
)}
|
| 77 |
+
|
| 78 |
+
{/* Equation Content */}
|
| 79 |
+
<div className="flex-1 flex items-center justify-center py-6 px-8">
|
| 80 |
+
<div
|
| 81 |
+
className="font-mono text-lg md:text-xl text-gray-800 dark:text-gray-200 tracking-wide"
|
| 82 |
+
dangerouslySetInnerHTML={{ __html: formatEquation(equation) }}
|
| 83 |
+
/>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
{/* Copy Button */}
|
| 87 |
+
<button
|
| 88 |
+
onClick={handleCopy}
|
| 89 |
+
className="absolute top-2 right-2 p-2 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700"
|
| 90 |
+
title="Copy equation"
|
| 91 |
+
>
|
| 92 |
+
{copied ? (
|
| 93 |
+
<Check size={14} className="text-green-500" />
|
| 94 |
+
) : (
|
| 95 |
+
<Copy size={14} className="text-gray-400" />
|
| 96 |
+
)}
|
| 97 |
+
</button>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
{/* Decorative elements */}
|
| 101 |
+
<div className="absolute -left-2 top-1/2 -translate-y-1/2 w-1 h-8 bg-gradient-to-b from-brand-400 to-purple-400 rounded-full opacity-60" />
|
| 102 |
+
</div>
|
| 103 |
+
);
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
export default EquationBlock;
|
| 107 |
+
|
components/InteractiveChart.tsx
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { ChartData } from '../types';
|
| 3 |
+
|
| 4 |
+
interface Props {
|
| 5 |
+
data: ChartData;
|
| 6 |
+
theme: 'light' | 'dark';
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
// Simple SVG-based chart implementation that works without external dependencies
|
| 10 |
+
const InteractiveChart: React.FC<Props> = ({ data, theme }) => {
|
| 11 |
+
const isDark = theme === 'dark';
|
| 12 |
+
|
| 13 |
+
// Default colors palette
|
| 14 |
+
const defaultColors = [
|
| 15 |
+
'#0ea5e9', // brand blue
|
| 16 |
+
'#8b5cf6', // purple
|
| 17 |
+
'#10b981', // emerald
|
| 18 |
+
'#f59e0b', // amber
|
| 19 |
+
'#ef4444', // red
|
| 20 |
+
'#ec4899', // pink
|
| 21 |
+
'#06b6d4', // cyan
|
| 22 |
+
'#84cc16', // lime
|
| 23 |
+
];
|
| 24 |
+
|
| 25 |
+
const colors = data.colors || defaultColors;
|
| 26 |
+
const maxValue = Math.max(...data.data.map(d => d.value));
|
| 27 |
+
const chartHeight = 200;
|
| 28 |
+
const chartWidth = 400;
|
| 29 |
+
const barWidth = Math.min(60, (chartWidth - 40) / data.data.length - 10);
|
| 30 |
+
|
| 31 |
+
const renderBarChart = () => (
|
| 32 |
+
<svg viewBox={`0 0 ${chartWidth} ${chartHeight + 60}`} className="w-full h-auto">
|
| 33 |
+
{/* Y-axis labels */}
|
| 34 |
+
{[0, 0.25, 0.5, 0.75, 1].map((ratio, i) => (
|
| 35 |
+
<g key={i}>
|
| 36 |
+
<text
|
| 37 |
+
x="30"
|
| 38 |
+
y={chartHeight - (ratio * chartHeight) + 5}
|
| 39 |
+
className={`text-[10px] ${isDark ? 'fill-gray-400' : 'fill-gray-500'}`}
|
| 40 |
+
textAnchor="end"
|
| 41 |
+
>
|
| 42 |
+
{Math.round(maxValue * ratio)}
|
| 43 |
+
</text>
|
| 44 |
+
<line
|
| 45 |
+
x1="40"
|
| 46 |
+
y1={chartHeight - (ratio * chartHeight)}
|
| 47 |
+
x2={chartWidth - 10}
|
| 48 |
+
y2={chartHeight - (ratio * chartHeight)}
|
| 49 |
+
className={isDark ? 'stroke-gray-700' : 'stroke-gray-200'}
|
| 50 |
+
strokeDasharray="4,4"
|
| 51 |
+
/>
|
| 52 |
+
</g>
|
| 53 |
+
))}
|
| 54 |
+
|
| 55 |
+
{/* Bars */}
|
| 56 |
+
{data.data.map((item, index) => {
|
| 57 |
+
const barHeight = (item.value / maxValue) * chartHeight;
|
| 58 |
+
const x = 50 + index * ((chartWidth - 60) / data.data.length);
|
| 59 |
+
const y = chartHeight - barHeight;
|
| 60 |
+
|
| 61 |
+
return (
|
| 62 |
+
<g key={index} className="group cursor-pointer">
|
| 63 |
+
{/* Bar */}
|
| 64 |
+
<rect
|
| 65 |
+
x={x}
|
| 66 |
+
y={y}
|
| 67 |
+
width={barWidth}
|
| 68 |
+
height={barHeight}
|
| 69 |
+
fill={colors[index % colors.length]}
|
| 70 |
+
rx="4"
|
| 71 |
+
className="transition-all duration-300 hover:opacity-80"
|
| 72 |
+
/>
|
| 73 |
+
|
| 74 |
+
{/* Value on hover */}
|
| 75 |
+
<text
|
| 76 |
+
x={x + barWidth / 2}
|
| 77 |
+
y={y - 8}
|
| 78 |
+
textAnchor="middle"
|
| 79 |
+
className={`text-[11px] font-bold opacity-0 group-hover:opacity-100 transition-opacity ${isDark ? 'fill-gray-200' : 'fill-gray-700'}`}
|
| 80 |
+
>
|
| 81 |
+
{item.value}
|
| 82 |
+
</text>
|
| 83 |
+
|
| 84 |
+
{/* Label */}
|
| 85 |
+
<text
|
| 86 |
+
x={x + barWidth / 2}
|
| 87 |
+
y={chartHeight + 20}
|
| 88 |
+
textAnchor="middle"
|
| 89 |
+
className={`text-[10px] ${isDark ? 'fill-gray-400' : 'fill-gray-600'}`}
|
| 90 |
+
>
|
| 91 |
+
{item.label.length > 10 ? item.label.substring(0, 10) + '...' : item.label}
|
| 92 |
+
</text>
|
| 93 |
+
</g>
|
| 94 |
+
);
|
| 95 |
+
})}
|
| 96 |
+
</svg>
|
| 97 |
+
);
|
| 98 |
+
|
| 99 |
+
const renderLineChart = () => {
|
| 100 |
+
const points = data.data.map((item, index) => {
|
| 101 |
+
const x = 50 + index * ((chartWidth - 80) / (data.data.length - 1 || 1));
|
| 102 |
+
const y = chartHeight - (item.value / maxValue) * chartHeight;
|
| 103 |
+
return { x, y, ...item };
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
const pathD = points
|
| 107 |
+
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`)
|
| 108 |
+
.join(' ');
|
| 109 |
+
|
| 110 |
+
// Area fill path
|
| 111 |
+
const areaD = `${pathD} L ${points[points.length - 1]?.x || 0} ${chartHeight} L ${points[0]?.x || 0} ${chartHeight} Z`;
|
| 112 |
+
|
| 113 |
+
return (
|
| 114 |
+
<svg viewBox={`0 0 ${chartWidth} ${chartHeight + 60}`} className="w-full h-auto">
|
| 115 |
+
{/* Grid lines */}
|
| 116 |
+
{[0, 0.25, 0.5, 0.75, 1].map((ratio, i) => (
|
| 117 |
+
<g key={i}>
|
| 118 |
+
<text
|
| 119 |
+
x="30"
|
| 120 |
+
y={chartHeight - (ratio * chartHeight) + 5}
|
| 121 |
+
className={`text-[10px] ${isDark ? 'fill-gray-400' : 'fill-gray-500'}`}
|
| 122 |
+
textAnchor="end"
|
| 123 |
+
>
|
| 124 |
+
{Math.round(maxValue * ratio)}
|
| 125 |
+
</text>
|
| 126 |
+
<line
|
| 127 |
+
x1="40"
|
| 128 |
+
y1={chartHeight - (ratio * chartHeight)}
|
| 129 |
+
x2={chartWidth - 10}
|
| 130 |
+
y2={chartHeight - (ratio * chartHeight)}
|
| 131 |
+
className={isDark ? 'stroke-gray-700' : 'stroke-gray-200'}
|
| 132 |
+
strokeDasharray="4,4"
|
| 133 |
+
/>
|
| 134 |
+
</g>
|
| 135 |
+
))}
|
| 136 |
+
|
| 137 |
+
{/* Area fill */}
|
| 138 |
+
<path
|
| 139 |
+
d={areaD}
|
| 140 |
+
fill={colors[0]}
|
| 141 |
+
fillOpacity="0.1"
|
| 142 |
+
/>
|
| 143 |
+
|
| 144 |
+
{/* Line */}
|
| 145 |
+
<path
|
| 146 |
+
d={pathD}
|
| 147 |
+
fill="none"
|
| 148 |
+
stroke={colors[0]}
|
| 149 |
+
strokeWidth="3"
|
| 150 |
+
strokeLinecap="round"
|
| 151 |
+
strokeLinejoin="round"
|
| 152 |
+
/>
|
| 153 |
+
|
| 154 |
+
{/* Points and labels */}
|
| 155 |
+
{points.map((point, index) => (
|
| 156 |
+
<g key={index} className="group cursor-pointer">
|
| 157 |
+
<circle
|
| 158 |
+
cx={point.x}
|
| 159 |
+
cy={point.y}
|
| 160 |
+
r="6"
|
| 161 |
+
fill={colors[0]}
|
| 162 |
+
className="transition-all duration-200 hover:r-8"
|
| 163 |
+
/>
|
| 164 |
+
<circle
|
| 165 |
+
cx={point.x}
|
| 166 |
+
cy={point.y}
|
| 167 |
+
r="3"
|
| 168 |
+
fill={isDark ? '#1e293b' : 'white'}
|
| 169 |
+
/>
|
| 170 |
+
|
| 171 |
+
{/* Tooltip on hover */}
|
| 172 |
+
<text
|
| 173 |
+
x={point.x}
|
| 174 |
+
y={point.y - 14}
|
| 175 |
+
textAnchor="middle"
|
| 176 |
+
className={`text-[11px] font-bold opacity-0 group-hover:opacity-100 transition-opacity ${isDark ? 'fill-gray-200' : 'fill-gray-700'}`}
|
| 177 |
+
>
|
| 178 |
+
{point.value}
|
| 179 |
+
</text>
|
| 180 |
+
|
| 181 |
+
{/* X-axis label */}
|
| 182 |
+
<text
|
| 183 |
+
x={point.x}
|
| 184 |
+
y={chartHeight + 20}
|
| 185 |
+
textAnchor="middle"
|
| 186 |
+
className={`text-[10px] ${isDark ? 'fill-gray-400' : 'fill-gray-600'}`}
|
| 187 |
+
>
|
| 188 |
+
{point.label.length > 8 ? point.label.substring(0, 8) + '..' : point.label}
|
| 189 |
+
</text>
|
| 190 |
+
</g>
|
| 191 |
+
))}
|
| 192 |
+
</svg>
|
| 193 |
+
);
|
| 194 |
+
};
|
| 195 |
+
|
| 196 |
+
const renderPieChart = () => {
|
| 197 |
+
const total = data.data.reduce((sum, item) => sum + item.value, 0);
|
| 198 |
+
const centerX = 150;
|
| 199 |
+
const centerY = 120;
|
| 200 |
+
const radius = 80;
|
| 201 |
+
let startAngle = -90;
|
| 202 |
+
|
| 203 |
+
return (
|
| 204 |
+
<svg viewBox="0 0 300 280" className="w-full h-auto max-w-[300px] mx-auto">
|
| 205 |
+
{data.data.map((item, index) => {
|
| 206 |
+
const angle = (item.value / total) * 360;
|
| 207 |
+
const endAngle = startAngle + angle;
|
| 208 |
+
|
| 209 |
+
const startRad = (startAngle * Math.PI) / 180;
|
| 210 |
+
const endRad = (endAngle * Math.PI) / 180;
|
| 211 |
+
|
| 212 |
+
const x1 = centerX + radius * Math.cos(startRad);
|
| 213 |
+
const y1 = centerY + radius * Math.sin(startRad);
|
| 214 |
+
const x2 = centerX + radius * Math.cos(endRad);
|
| 215 |
+
const y2 = centerY + radius * Math.sin(endRad);
|
| 216 |
+
|
| 217 |
+
const largeArc = angle > 180 ? 1 : 0;
|
| 218 |
+
|
| 219 |
+
const pathD = `M ${centerX} ${centerY} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`;
|
| 220 |
+
|
| 221 |
+
const midAngle = startAngle + angle / 2;
|
| 222 |
+
const midRad = (midAngle * Math.PI) / 180;
|
| 223 |
+
const labelRadius = radius + 25;
|
| 224 |
+
const labelX = centerX + labelRadius * Math.cos(midRad);
|
| 225 |
+
const labelY = centerY + labelRadius * Math.sin(midRad);
|
| 226 |
+
|
| 227 |
+
startAngle = endAngle;
|
| 228 |
+
|
| 229 |
+
return (
|
| 230 |
+
<g key={index} className="group cursor-pointer">
|
| 231 |
+
<path
|
| 232 |
+
d={pathD}
|
| 233 |
+
fill={colors[index % colors.length]}
|
| 234 |
+
className="transition-all duration-200 hover:opacity-80"
|
| 235 |
+
style={{ transformOrigin: `${centerX}px ${centerY}px` }}
|
| 236 |
+
/>
|
| 237 |
+
</g>
|
| 238 |
+
);
|
| 239 |
+
})}
|
| 240 |
+
|
| 241 |
+
{/* Center circle for donut effect */}
|
| 242 |
+
<circle
|
| 243 |
+
cx={centerX}
|
| 244 |
+
cy={centerY}
|
| 245 |
+
r={radius * 0.5}
|
| 246 |
+
className={isDark ? 'fill-gray-900' : 'fill-white'}
|
| 247 |
+
/>
|
| 248 |
+
|
| 249 |
+
{/* Legend */}
|
| 250 |
+
<g transform={`translate(10, ${centerY * 2 + 20})`}>
|
| 251 |
+
{data.data.map((item, index) => (
|
| 252 |
+
<g key={index} transform={`translate(${index * 90}, 0)`}>
|
| 253 |
+
<rect
|
| 254 |
+
x="0"
|
| 255 |
+
y="0"
|
| 256 |
+
width="12"
|
| 257 |
+
height="12"
|
| 258 |
+
rx="2"
|
| 259 |
+
fill={colors[index % colors.length]}
|
| 260 |
+
/>
|
| 261 |
+
<text
|
| 262 |
+
x="18"
|
| 263 |
+
y="10"
|
| 264 |
+
className={`text-[10px] ${isDark ? 'fill-gray-300' : 'fill-gray-600'}`}
|
| 265 |
+
>
|
| 266 |
+
{item.label.length > 8 ? item.label.substring(0, 8) + '..' : item.label}
|
| 267 |
+
</text>
|
| 268 |
+
</g>
|
| 269 |
+
))}
|
| 270 |
+
</g>
|
| 271 |
+
</svg>
|
| 272 |
+
);
|
| 273 |
+
};
|
| 274 |
+
|
| 275 |
+
const renderChart = () => {
|
| 276 |
+
switch (data.type) {
|
| 277 |
+
case 'bar':
|
| 278 |
+
return renderBarChart();
|
| 279 |
+
case 'line':
|
| 280 |
+
case 'area':
|
| 281 |
+
return renderLineChart();
|
| 282 |
+
case 'pie':
|
| 283 |
+
return renderPieChart();
|
| 284 |
+
default:
|
| 285 |
+
return renderBarChart();
|
| 286 |
+
}
|
| 287 |
+
};
|
| 288 |
+
|
| 289 |
+
return (
|
| 290 |
+
<div className="w-full">
|
| 291 |
+
{/* Chart Title */}
|
| 292 |
+
{data.title && (
|
| 293 |
+
<h4 className="text-center text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">
|
| 294 |
+
{data.title}
|
| 295 |
+
</h4>
|
| 296 |
+
)}
|
| 297 |
+
|
| 298 |
+
{/* Chart */}
|
| 299 |
+
<div className="relative">
|
| 300 |
+
{renderChart()}
|
| 301 |
+
</div>
|
| 302 |
+
|
| 303 |
+
{/* Axis Labels */}
|
| 304 |
+
{(data.xAxis || data.yAxis) && (
|
| 305 |
+
<div className="flex justify-between mt-2 text-xs text-gray-500 dark:text-gray-400">
|
| 306 |
+
{data.yAxis && <span className="italic">{data.yAxis}</span>}
|
| 307 |
+
{data.xAxis && <span className="italic">{data.xAxis}</span>}
|
| 308 |
+
</div>
|
| 309 |
+
)}
|
| 310 |
+
</div>
|
| 311 |
+
);
|
| 312 |
+
};
|
| 313 |
+
|
| 314 |
+
export default InteractiveChart;
|
| 315 |
+
|
components/Sidebar.tsx
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { BookOpen, ChevronRight, Loader2, CheckCircle } from 'lucide-react';
|
| 3 |
+
import { BlogSection } from '../types';
|
| 4 |
+
|
| 5 |
+
interface Props {
|
| 6 |
+
sections: BlogSection[];
|
| 7 |
+
activeSection: string;
|
| 8 |
+
onSectionClick: (id: string) => void;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const Sidebar: React.FC<Props> = ({ sections, activeSection, onSectionClick }) => {
|
| 12 |
+
const [isCollapsed, setIsCollapsed] = useState(false);
|
| 13 |
+
|
| 14 |
+
const handleClick = (id: string) => {
|
| 15 |
+
onSectionClick(id);
|
| 16 |
+
const element = document.getElementById(`section-${id}`);
|
| 17 |
+
if (element) {
|
| 18 |
+
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 19 |
+
}
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<nav
|
| 24 |
+
className={`
|
| 25 |
+
hidden lg:block fixed left-0 top-24 h-[calc(100vh-8rem)] z-40
|
| 26 |
+
transition-all duration-300 ease-out
|
| 27 |
+
${isCollapsed ? 'w-16' : 'w-72'}
|
| 28 |
+
`}
|
| 29 |
+
>
|
| 30 |
+
<div className="h-full flex flex-col ml-6">
|
| 31 |
+
{/* Header */}
|
| 32 |
+
<div className="flex items-center justify-between mb-6 pr-4">
|
| 33 |
+
{!isCollapsed && (
|
| 34 |
+
<div className="flex items-center gap-2 text-sm font-bold uppercase tracking-widest text-gray-500 dark:text-gray-400">
|
| 35 |
+
<BookOpen size={14} />
|
| 36 |
+
<span>Contents</span>
|
| 37 |
+
</div>
|
| 38 |
+
)}
|
| 39 |
+
<button
|
| 40 |
+
onClick={() => setIsCollapsed(!isCollapsed)}
|
| 41 |
+
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
| 42 |
+
>
|
| 43 |
+
<ChevronRight
|
| 44 |
+
size={16}
|
| 45 |
+
className={`transition-transform duration-300 ${isCollapsed ? '' : 'rotate-180'}`}
|
| 46 |
+
/>
|
| 47 |
+
</button>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
{/* Navigation Items */}
|
| 51 |
+
<div className="flex-1 overflow-y-auto custom-scrollbar pr-4 space-y-1">
|
| 52 |
+
{sections.map((section, index) => {
|
| 53 |
+
const isActive = activeSection === section.id;
|
| 54 |
+
const isLoading = section.isLoading;
|
| 55 |
+
const isComplete = !section.isLoading && section.content && !section.error;
|
| 56 |
+
const hasError = section.error;
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<button
|
| 60 |
+
key={section.id}
|
| 61 |
+
onClick={() => !isLoading && handleClick(section.id)}
|
| 62 |
+
disabled={isLoading}
|
| 63 |
+
className={`
|
| 64 |
+
w-full text-left transition-all duration-200 group relative
|
| 65 |
+
${isCollapsed ? 'px-2 py-3' : 'px-4 py-3'}
|
| 66 |
+
rounded-xl
|
| 67 |
+
${isLoading ? 'opacity-60 cursor-wait' : ''}
|
| 68 |
+
${hasError ? 'opacity-70' : ''}
|
| 69 |
+
${isActive
|
| 70 |
+
? 'bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
|
| 71 |
+
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50 text-gray-600 dark:text-gray-400'
|
| 72 |
+
}
|
| 73 |
+
`}
|
| 74 |
+
>
|
| 75 |
+
{/* Active Indicator */}
|
| 76 |
+
<div
|
| 77 |
+
className={`
|
| 78 |
+
absolute left-0 top-1/2 -translate-y-1/2 w-1 rounded-r-full
|
| 79 |
+
transition-all duration-300
|
| 80 |
+
${isActive ? 'h-8 bg-brand-500' : 'h-0 bg-transparent'}
|
| 81 |
+
`}
|
| 82 |
+
/>
|
| 83 |
+
|
| 84 |
+
{isCollapsed ? (
|
| 85 |
+
<div className={`
|
| 86 |
+
w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold
|
| 87 |
+
${isLoading ? 'bg-gray-200 dark:bg-gray-700' : ''}
|
| 88 |
+
${isActive && !isLoading
|
| 89 |
+
? 'bg-brand-500 text-white'
|
| 90 |
+
: 'bg-gray-100 dark:bg-gray-800 text-gray-500'
|
| 91 |
+
}
|
| 92 |
+
`}>
|
| 93 |
+
{isLoading ? (
|
| 94 |
+
<Loader2 size={14} className="animate-spin" />
|
| 95 |
+
) : (
|
| 96 |
+
index + 1
|
| 97 |
+
)}
|
| 98 |
+
</div>
|
| 99 |
+
) : (
|
| 100 |
+
<div className="flex items-start gap-3">
|
| 101 |
+
<span className={`
|
| 102 |
+
flex-shrink-0 w-6 h-6 rounded-md flex items-center justify-center text-xs font-bold mt-0.5
|
| 103 |
+
${isLoading ? 'bg-gray-200 dark:bg-gray-700 animate-pulse' : ''}
|
| 104 |
+
${isActive && !isLoading
|
| 105 |
+
? 'bg-brand-500 text-white'
|
| 106 |
+
: 'bg-gray-100 dark:bg-gray-800 text-gray-500 group-hover:bg-gray-200 dark:group-hover:bg-gray-700'
|
| 107 |
+
}
|
| 108 |
+
`}>
|
| 109 |
+
{isLoading ? (
|
| 110 |
+
<Loader2 size={10} className="animate-spin" />
|
| 111 |
+
) : isComplete ? (
|
| 112 |
+
<CheckCircle size={10} className="text-green-500" />
|
| 113 |
+
) : (
|
| 114 |
+
index + 1
|
| 115 |
+
)}
|
| 116 |
+
</span>
|
| 117 |
+
<span className={`
|
| 118 |
+
text-sm leading-snug transition-colors
|
| 119 |
+
${isLoading ? 'text-gray-400 dark:text-gray-600' : ''}
|
| 120 |
+
${isActive && !isLoading ? 'font-semibold' : 'font-medium'}
|
| 121 |
+
`}>
|
| 122 |
+
{section.title}
|
| 123 |
+
</span>
|
| 124 |
+
</div>
|
| 125 |
+
)}
|
| 126 |
+
</button>
|
| 127 |
+
);
|
| 128 |
+
})}
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
{/* Progress Indicator */}
|
| 132 |
+
{!isCollapsed && (
|
| 133 |
+
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-800 pr-4 space-y-3">
|
| 134 |
+
{/* Generation Progress */}
|
| 135 |
+
{sections.some(s => s.isLoading) && (
|
| 136 |
+
<div>
|
| 137 |
+
<div className="flex items-center justify-between text-xs text-brand-600 dark:text-brand-400 mb-2">
|
| 138 |
+
<span className="flex items-center gap-1">
|
| 139 |
+
<Loader2 size={10} className="animate-spin" />
|
| 140 |
+
Generating
|
| 141 |
+
</span>
|
| 142 |
+
<span>
|
| 143 |
+
{sections.filter(s => !s.isLoading).length}/{sections.length}
|
| 144 |
+
</span>
|
| 145 |
+
</div>
|
| 146 |
+
<div className="h-1.5 bg-brand-100 dark:bg-brand-900/30 rounded-full overflow-hidden">
|
| 147 |
+
<div
|
| 148 |
+
className="h-full bg-gradient-to-r from-brand-500 to-purple-500 rounded-full transition-all duration-500"
|
| 149 |
+
style={{
|
| 150 |
+
width: `${(sections.filter(s => !s.isLoading).length / sections.length) * 100}%`
|
| 151 |
+
}}
|
| 152 |
+
/>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
)}
|
| 156 |
+
|
| 157 |
+
{/* Reading Progress */}
|
| 158 |
+
<div>
|
| 159 |
+
<div className="flex items-center justify-between text-xs text-gray-500 mb-2">
|
| 160 |
+
<span>Reading</span>
|
| 161 |
+
<span>
|
| 162 |
+
{sections.findIndex(s => s.id === activeSection) + 1}/{sections.length}
|
| 163 |
+
</span>
|
| 164 |
+
</div>
|
| 165 |
+
<div className="h-1.5 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
| 166 |
+
<div
|
| 167 |
+
className="h-full bg-gradient-to-r from-gray-400 to-gray-500 dark:from-gray-600 dark:to-gray-500 rounded-full transition-all duration-500"
|
| 168 |
+
style={{
|
| 169 |
+
width: `${((sections.findIndex(s => s.id === activeSection) + 1) / sections.length) * 100}%`
|
| 170 |
+
}}
|
| 171 |
+
/>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
)}
|
| 176 |
+
</div>
|
| 177 |
+
</nav>
|
| 178 |
+
);
|
| 179 |
+
};
|
| 180 |
+
|
| 181 |
+
export default Sidebar;
|
| 182 |
+
|
components/Tooltip.tsx
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
interface Props {
|
| 4 |
+
term: string;
|
| 5 |
+
definition: string;
|
| 6 |
+
children: React.ReactNode;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
const Tooltip: React.FC<Props> = ({ term, definition, children }) => {
|
| 10 |
+
const [isVisible, setIsVisible] = useState(false);
|
| 11 |
+
const [position, setPosition] = useState<'top' | 'bottom'>('top');
|
| 12 |
+
const triggerRef = useRef<HTMLSpanElement>(null);
|
| 13 |
+
const tooltipRef = useRef<HTMLDivElement>(null);
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
if (isVisible && triggerRef.current && tooltipRef.current) {
|
| 17 |
+
const triggerRect = triggerRef.current.getBoundingClientRect();
|
| 18 |
+
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
| 19 |
+
|
| 20 |
+
// Check if tooltip would go off-screen at the top
|
| 21 |
+
if (triggerRect.top - tooltipRect.height - 12 < 0) {
|
| 22 |
+
setPosition('bottom');
|
| 23 |
+
} else {
|
| 24 |
+
setPosition('top');
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
}, [isVisible]);
|
| 28 |
+
|
| 29 |
+
return (
|
| 30 |
+
<span className="relative inline-block">
|
| 31 |
+
<span
|
| 32 |
+
ref={triggerRef}
|
| 33 |
+
onMouseEnter={() => setIsVisible(true)}
|
| 34 |
+
onMouseLeave={() => setIsVisible(false)}
|
| 35 |
+
className="border-b-2 border-dashed border-brand-400 dark:border-brand-500 cursor-help transition-colors hover:border-brand-600 dark:hover:border-brand-400 hover:text-brand-600 dark:hover:text-brand-400"
|
| 36 |
+
>
|
| 37 |
+
{children}
|
| 38 |
+
</span>
|
| 39 |
+
|
| 40 |
+
{isVisible && (
|
| 41 |
+
<div
|
| 42 |
+
ref={tooltipRef}
|
| 43 |
+
className={`
|
| 44 |
+
absolute z-50 w-72 p-4 rounded-xl shadow-2xl
|
| 45 |
+
bg-white dark:bg-gray-900
|
| 46 |
+
border border-gray-200 dark:border-gray-700
|
| 47 |
+
animate-in fade-in zoom-in-95 duration-200
|
| 48 |
+
${position === 'top'
|
| 49 |
+
? 'bottom-full mb-3 left-1/2 -translate-x-1/2'
|
| 50 |
+
: 'top-full mt-3 left-1/2 -translate-x-1/2'
|
| 51 |
+
}
|
| 52 |
+
`}
|
| 53 |
+
>
|
| 54 |
+
{/* Arrow */}
|
| 55 |
+
<div
|
| 56 |
+
className={`
|
| 57 |
+
absolute w-3 h-3 bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700
|
| 58 |
+
transform rotate-45
|
| 59 |
+
${position === 'top'
|
| 60 |
+
? 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1.5 border-r border-b'
|
| 61 |
+
: 'top-0 left-1/2 -translate-x-1/2 -translate-y-1.5 border-l border-t'
|
| 62 |
+
}
|
| 63 |
+
`}
|
| 64 |
+
/>
|
| 65 |
+
|
| 66 |
+
<div className="relative">
|
| 67 |
+
<div className="text-xs font-bold uppercase tracking-wider text-brand-600 dark:text-brand-400 mb-2">
|
| 68 |
+
Definition
|
| 69 |
+
</div>
|
| 70 |
+
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
| 71 |
+
{term}
|
| 72 |
+
</div>
|
| 73 |
+
<div className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
| 74 |
+
{definition}
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
)}
|
| 79 |
+
</span>
|
| 80 |
+
);
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
export default Tooltip;
|
| 84 |
+
|
index.html
CHANGED
|
@@ -32,14 +32,14 @@
|
|
| 32 |
background: #555;
|
| 33 |
}
|
| 34 |
.glass-panel {
|
| 35 |
-
background: rgba(255, 255, 255, 0.7);
|
| 36 |
backdrop-filter: blur(20px);
|
| 37 |
-webkit-backdrop-filter: blur(20px);
|
| 38 |
border: 1px solid rgba(255, 255, 255, 0.3);
|
| 39 |
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
| 40 |
}
|
| 41 |
.dark .glass-panel {
|
| 42 |
-
background: rgba(15, 23, 42, 0.6);
|
| 43 |
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 44 |
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5);
|
| 45 |
}
|
|
@@ -50,6 +50,100 @@
|
|
| 50 |
-webkit-background-clip: text;
|
| 51 |
-webkit-text-fill-color: transparent;
|
| 52 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
</style>
|
| 54 |
<script>
|
| 55 |
tailwind.config = {
|
|
|
|
| 32 |
background: #555;
|
| 33 |
}
|
| 34 |
.glass-panel {
|
| 35 |
+
background: rgba(255, 255, 255, 0.7);
|
| 36 |
backdrop-filter: blur(20px);
|
| 37 |
-webkit-backdrop-filter: blur(20px);
|
| 38 |
border: 1px solid rgba(255, 255, 255, 0.3);
|
| 39 |
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
| 40 |
}
|
| 41 |
.dark .glass-panel {
|
| 42 |
+
background: rgba(15, 23, 42, 0.6);
|
| 43 |
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 44 |
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5);
|
| 45 |
}
|
|
|
|
| 50 |
-webkit-background-clip: text;
|
| 51 |
-webkit-text-fill-color: transparent;
|
| 52 |
}
|
| 53 |
+
|
| 54 |
+
/* Blog View - Prose Enhancements */
|
| 55 |
+
.prose blockquote {
|
| 56 |
+
font-style: normal;
|
| 57 |
+
quotes: none;
|
| 58 |
+
}
|
| 59 |
+
.prose blockquote::before {
|
| 60 |
+
content: none;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/* Equation Fraction Styling */
|
| 64 |
+
.frac {
|
| 65 |
+
display: inline-flex;
|
| 66 |
+
flex-direction: column;
|
| 67 |
+
align-items: center;
|
| 68 |
+
vertical-align: middle;
|
| 69 |
+
margin: 0 0.2em;
|
| 70 |
+
}
|
| 71 |
+
.frac > span:first-child {
|
| 72 |
+
border-bottom: 1px solid currentColor;
|
| 73 |
+
padding: 0 0.3em 0.1em;
|
| 74 |
+
}
|
| 75 |
+
.frac > span:last-child {
|
| 76 |
+
padding: 0.1em 0.3em 0;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/* Smooth scroll behavior */
|
| 80 |
+
html {
|
| 81 |
+
scroll-behavior: smooth;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/* Animation utilities */
|
| 85 |
+
@keyframes fade-in-up {
|
| 86 |
+
from {
|
| 87 |
+
opacity: 0;
|
| 88 |
+
transform: translateY(20px);
|
| 89 |
+
}
|
| 90 |
+
to {
|
| 91 |
+
opacity: 1;
|
| 92 |
+
transform: translateY(0);
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.animate-fade-in-up {
|
| 97 |
+
animation: fade-in-up 0.6s ease-out forwards;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/* Stagger delays */
|
| 101 |
+
.delay-100 { animation-delay: 100ms; }
|
| 102 |
+
.delay-200 { animation-delay: 200ms; }
|
| 103 |
+
.delay-300 { animation-delay: 300ms; }
|
| 104 |
+
.delay-400 { animation-delay: 400ms; }
|
| 105 |
+
.delay-500 { animation-delay: 500ms; }
|
| 106 |
+
|
| 107 |
+
/* Line clamp utilities */
|
| 108 |
+
.line-clamp-4 {
|
| 109 |
+
display: -webkit-box;
|
| 110 |
+
-webkit-line-clamp: 4;
|
| 111 |
+
-webkit-box-orient: vertical;
|
| 112 |
+
overflow: hidden;
|
| 113 |
+
}
|
| 114 |
+
.line-clamp-\[12\] {
|
| 115 |
+
display: -webkit-box;
|
| 116 |
+
-webkit-line-clamp: 12;
|
| 117 |
+
-webkit-box-orient: vertical;
|
| 118 |
+
overflow: hidden;
|
| 119 |
+
}
|
| 120 |
+
.line-clamp-\[20\] {
|
| 121 |
+
display: -webkit-box;
|
| 122 |
+
-webkit-line-clamp: 20;
|
| 123 |
+
-webkit-box-orient: vertical;
|
| 124 |
+
overflow: hidden;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/* Mermaid container */
|
| 128 |
+
.mermaid-container svg {
|
| 129 |
+
max-width: 100%;
|
| 130 |
+
height: auto;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/* Custom scrollbar for specific containers */
|
| 134 |
+
.custom-scrollbar::-webkit-scrollbar {
|
| 135 |
+
width: 6px;
|
| 136 |
+
}
|
| 137 |
+
.custom-scrollbar::-webkit-scrollbar-track {
|
| 138 |
+
background: transparent;
|
| 139 |
+
}
|
| 140 |
+
.custom-scrollbar::-webkit-scrollbar-thumb {
|
| 141 |
+
background: rgba(0,0,0,0.2);
|
| 142 |
+
border-radius: 3px;
|
| 143 |
+
}
|
| 144 |
+
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
| 145 |
+
background: rgba(255,255,255,0.2);
|
| 146 |
+
}
|
| 147 |
</style>
|
| 148 |
<script>
|
| 149 |
tailwind.config = {
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
services/geminiService.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
|
| 2 |
import { GoogleGenAI, Type, Schema } from "@google/genai";
|
| 3 |
-
import { BentoCardData, ChatMessage, ModelType } from "../types";
|
| 4 |
|
| 5 |
const RESPONSE_SCHEMA = {
|
| 6 |
type: Type.ARRAY,
|
|
@@ -194,3 +194,415 @@ export const chatWithDocument = async (
|
|
| 194 |
const result = await chat.sendMessage({ message: newMessage });
|
| 195 |
return result.text || "";
|
| 196 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
|
| 2 |
import { GoogleGenAI, Type, Schema } from "@google/genai";
|
| 3 |
+
import { BentoCardData, BlogSection, ChatMessage, ModelType, ChartData, PaperStructure, SectionPlan } from "../types";
|
| 4 |
|
| 5 |
const RESPONSE_SCHEMA = {
|
| 6 |
type: Type.ARRAY,
|
|
|
|
| 194 |
const result = await chat.sendMessage({ message: newMessage });
|
| 195 |
return result.text || "";
|
| 196 |
};
|
| 197 |
+
|
| 198 |
+
// Paper Structure Analysis Schema
|
| 199 |
+
const PAPER_STRUCTURE_SCHEMA = {
|
| 200 |
+
type: Type.OBJECT,
|
| 201 |
+
properties: {
|
| 202 |
+
paperTitle: { type: Type.STRING, description: "The title of the paper" },
|
| 203 |
+
paperAbstract: { type: Type.STRING, description: "A 2-3 sentence summary of the paper" },
|
| 204 |
+
mainContribution: { type: Type.STRING, description: "The key innovation or finding in one sentence" },
|
| 205 |
+
keyTerms: {
|
| 206 |
+
type: Type.ARRAY,
|
| 207 |
+
items: { type: Type.STRING },
|
| 208 |
+
description: "5-10 important technical terms used throughout the paper"
|
| 209 |
+
},
|
| 210 |
+
sections: {
|
| 211 |
+
type: Type.ARRAY,
|
| 212 |
+
items: {
|
| 213 |
+
type: Type.OBJECT,
|
| 214 |
+
properties: {
|
| 215 |
+
title: { type: Type.STRING, description: "Engaging section title" },
|
| 216 |
+
sectionType: {
|
| 217 |
+
type: Type.STRING,
|
| 218 |
+
enum: ['intro', 'background', 'methodology', 'results', 'analysis', 'applications', 'conclusion', 'custom']
|
| 219 |
+
},
|
| 220 |
+
focusPoints: {
|
| 221 |
+
type: Type.ARRAY,
|
| 222 |
+
items: { type: Type.STRING },
|
| 223 |
+
description: "3-5 key points this section should cover"
|
| 224 |
+
},
|
| 225 |
+
suggestedVisualization: {
|
| 226 |
+
type: Type.STRING,
|
| 227 |
+
enum: ['mermaid', 'chart', 'equation', 'none']
|
| 228 |
+
},
|
| 229 |
+
visualizationHint: {
|
| 230 |
+
type: Type.STRING,
|
| 231 |
+
description: "What specifically should be visualized (e.g., 'architecture diagram from Figure 2', 'accuracy comparison from Table 1')"
|
| 232 |
+
},
|
| 233 |
+
estimatedLength: { type: Type.STRING, enum: ['short', 'medium', 'long'] }
|
| 234 |
+
},
|
| 235 |
+
required: ['title', 'sectionType', 'focusPoints', 'suggestedVisualization', 'estimatedLength']
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
},
|
| 239 |
+
required: ['paperTitle', 'paperAbstract', 'mainContribution', 'sections', 'keyTerms']
|
| 240 |
+
};
|
| 241 |
+
|
| 242 |
+
// Single Blog Section Schema
|
| 243 |
+
const SINGLE_SECTION_SCHEMA = {
|
| 244 |
+
type: Type.OBJECT,
|
| 245 |
+
properties: {
|
| 246 |
+
content: { type: Type.STRING, description: "Rich Markdown content (300-500 words). Use **bold**, *italics*, bullet points, numbered lists, and ### subheadings." },
|
| 247 |
+
visualizationType: {
|
| 248 |
+
type: Type.STRING,
|
| 249 |
+
enum: ['mermaid', 'chart', 'equation', 'none']
|
| 250 |
+
},
|
| 251 |
+
visualizationData: {
|
| 252 |
+
type: Type.STRING,
|
| 253 |
+
description: "For 'mermaid': valid Mermaid.js graph. For 'equation': LaTeX-style string. Empty for 'chart' or 'none'."
|
| 254 |
+
},
|
| 255 |
+
chartData: {
|
| 256 |
+
type: Type.OBJECT,
|
| 257 |
+
nullable: true,
|
| 258 |
+
properties: {
|
| 259 |
+
type: { type: Type.STRING, enum: ['bar', 'line', 'pie', 'area'] },
|
| 260 |
+
title: { type: Type.STRING },
|
| 261 |
+
data: {
|
| 262 |
+
type: Type.ARRAY,
|
| 263 |
+
items: {
|
| 264 |
+
type: Type.OBJECT,
|
| 265 |
+
properties: {
|
| 266 |
+
label: { type: Type.STRING },
|
| 267 |
+
value: { type: Type.NUMBER }
|
| 268 |
+
},
|
| 269 |
+
required: ['label', 'value']
|
| 270 |
+
}
|
| 271 |
+
},
|
| 272 |
+
xAxis: { type: Type.STRING },
|
| 273 |
+
yAxis: { type: Type.STRING }
|
| 274 |
+
}
|
| 275 |
+
},
|
| 276 |
+
marginNotes: {
|
| 277 |
+
type: Type.ARRAY,
|
| 278 |
+
items: {
|
| 279 |
+
type: Type.OBJECT,
|
| 280 |
+
properties: {
|
| 281 |
+
text: { type: Type.STRING },
|
| 282 |
+
icon: { type: Type.STRING, enum: ['info', 'warning', 'tip', 'note'] }
|
| 283 |
+
},
|
| 284 |
+
required: ['text']
|
| 285 |
+
}
|
| 286 |
+
},
|
| 287 |
+
technicalTerms: {
|
| 288 |
+
type: Type.ARRAY,
|
| 289 |
+
items: {
|
| 290 |
+
type: Type.OBJECT,
|
| 291 |
+
properties: {
|
| 292 |
+
term: { type: Type.STRING },
|
| 293 |
+
definition: { type: Type.STRING }
|
| 294 |
+
},
|
| 295 |
+
required: ['term', 'definition']
|
| 296 |
+
}
|
| 297 |
+
},
|
| 298 |
+
collapsibleSections: {
|
| 299 |
+
type: Type.ARRAY,
|
| 300 |
+
items: {
|
| 301 |
+
type: Type.OBJECT,
|
| 302 |
+
properties: {
|
| 303 |
+
title: { type: Type.STRING },
|
| 304 |
+
content: { type: Type.STRING }
|
| 305 |
+
},
|
| 306 |
+
required: ['title', 'content']
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
},
|
| 310 |
+
required: ['content', 'visualizationType']
|
| 311 |
+
};
|
| 312 |
+
|
| 313 |
+
/**
|
| 314 |
+
* Step 1: Analyze the paper and determine the best structure
|
| 315 |
+
*/
|
| 316 |
+
export const analyzePaperStructure = async (
|
| 317 |
+
apiKey: string,
|
| 318 |
+
model: ModelType,
|
| 319 |
+
content: string,
|
| 320 |
+
isPdf: boolean = false,
|
| 321 |
+
useThinking: boolean = false
|
| 322 |
+
): Promise<PaperStructure> => {
|
| 323 |
+
if (!apiKey) throw new Error("API Key is missing");
|
| 324 |
+
|
| 325 |
+
const ai = new GoogleGenAI({ apiKey });
|
| 326 |
+
|
| 327 |
+
let promptParts: any[] = [];
|
| 328 |
+
|
| 329 |
+
if (isPdf) {
|
| 330 |
+
promptParts.push({
|
| 331 |
+
inlineData: {
|
| 332 |
+
data: content,
|
| 333 |
+
mimeType: "application/pdf",
|
| 334 |
+
},
|
| 335 |
+
});
|
| 336 |
+
promptParts.push({ text: "Carefully analyze this research paper, including all figures, tables, diagrams, and equations." });
|
| 337 |
+
} else {
|
| 338 |
+
promptParts.push({ text: `Analyze this research paper: \n\n${content.substring(0, 50000)}` });
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
const thinkingInstruction = useThinking
|
| 342 |
+
? "Use deep reasoning to understand the paper's structure, contributions, and the best way to present it to a general technical audience."
|
| 343 |
+
: "";
|
| 344 |
+
|
| 345 |
+
promptParts.push({
|
| 346 |
+
text: `
|
| 347 |
+
${thinkingInstruction}
|
| 348 |
+
|
| 349 |
+
You are planning a distill.pub-style interactive article. Analyze this paper and create an OPTIMAL STRUCTURE for presenting it.
|
| 350 |
+
|
| 351 |
+
YOUR TASK:
|
| 352 |
+
1. Identify the paper's core contribution and why it matters
|
| 353 |
+
2. Determine 5-8 sections that best tell the story of this paper
|
| 354 |
+
3. For each section, identify what visualization would be most impactful
|
| 355 |
+
|
| 356 |
+
SECTION PLANNING GUIDELINES:
|
| 357 |
+
- Start with a hook that explains WHY this research matters
|
| 358 |
+
- Include background only if essential for understanding
|
| 359 |
+
- The methodology section should explain the "how" clearly
|
| 360 |
+
- Results sections should reference SPECIFIC figures/tables from the paper
|
| 361 |
+
- Include practical applications or implications
|
| 362 |
+
- End with limitations and future directions
|
| 363 |
+
|
| 364 |
+
VISUALIZATION PLANNING:
|
| 365 |
+
- 'mermaid': Use for architectures, pipelines, workflows, decision trees
|
| 366 |
+
- 'chart': Use when there are quantitative comparisons (accuracy, speed, etc.)
|
| 367 |
+
- 'equation': Use for key mathematical formulations
|
| 368 |
+
- 'none': Use for narrative sections
|
| 369 |
+
|
| 370 |
+
Look at the paper's figures and tables - reference them in visualizationHint so we can recreate them!
|
| 371 |
+
|
| 372 |
+
Return a structured JSON plan.
|
| 373 |
+
`
|
| 374 |
+
});
|
| 375 |
+
|
| 376 |
+
let effectiveModel = model;
|
| 377 |
+
let requestConfig: any = {
|
| 378 |
+
responseMimeType: "application/json",
|
| 379 |
+
responseSchema: PAPER_STRUCTURE_SCHEMA as any,
|
| 380 |
+
systemInstruction: "You are a senior science editor who plans engaging, accessible articles from complex research. You identify the narrative arc and visual opportunities in papers.",
|
| 381 |
+
};
|
| 382 |
+
|
| 383 |
+
if (useThinking) {
|
| 384 |
+
effectiveModel = 'gemini-3-pro-preview';
|
| 385 |
+
requestConfig.thinkingConfig = { thinkingBudget: 16384 };
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
try {
|
| 389 |
+
const response = await ai.models.generateContent({
|
| 390 |
+
model: effectiveModel,
|
| 391 |
+
contents: { parts: promptParts },
|
| 392 |
+
config: requestConfig
|
| 393 |
+
});
|
| 394 |
+
|
| 395 |
+
const text = response.text;
|
| 396 |
+
if (!text) throw new Error("No response from Gemini");
|
| 397 |
+
|
| 398 |
+
let jsonStr = text;
|
| 399 |
+
const jsonMatch = text.match(/\{.*\}/s);
|
| 400 |
+
if (jsonMatch) {
|
| 401 |
+
jsonStr = jsonMatch[0];
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
const parsed = JSON.parse(jsonStr);
|
| 405 |
+
|
| 406 |
+
return {
|
| 407 |
+
...parsed,
|
| 408 |
+
sections: parsed.sections.map((section: any, index: number) => ({
|
| 409 |
+
...section,
|
| 410 |
+
id: `plan-${index}-${Date.now()}`
|
| 411 |
+
}))
|
| 412 |
+
};
|
| 413 |
+
|
| 414 |
+
} catch (error: any) {
|
| 415 |
+
console.error("Paper Structure Analysis Error:", error);
|
| 416 |
+
throw new Error(error.message || "Failed to analyze paper structure");
|
| 417 |
+
}
|
| 418 |
+
};
|
| 419 |
+
|
| 420 |
+
/**
|
| 421 |
+
* Step 2: Generate a single section based on the plan
|
| 422 |
+
*/
|
| 423 |
+
export const generateSingleBlogSection = async (
|
| 424 |
+
apiKey: string,
|
| 425 |
+
model: ModelType,
|
| 426 |
+
content: string,
|
| 427 |
+
sectionPlan: SectionPlan,
|
| 428 |
+
sectionIndex: number,
|
| 429 |
+
totalSections: number,
|
| 430 |
+
paperContext: { title: string; abstract: string; mainContribution: string; keyTerms: string[] },
|
| 431 |
+
isPdf: boolean = false,
|
| 432 |
+
useThinking: boolean = false
|
| 433 |
+
): Promise<BlogSection> => {
|
| 434 |
+
if (!apiKey) throw new Error("API Key is missing");
|
| 435 |
+
|
| 436 |
+
const ai = new GoogleGenAI({ apiKey });
|
| 437 |
+
|
| 438 |
+
let promptParts: any[] = [];
|
| 439 |
+
|
| 440 |
+
if (isPdf) {
|
| 441 |
+
promptParts.push({
|
| 442 |
+
inlineData: {
|
| 443 |
+
data: content,
|
| 444 |
+
mimeType: "application/pdf",
|
| 445 |
+
},
|
| 446 |
+
});
|
| 447 |
+
} else {
|
| 448 |
+
promptParts.push({ text: `Paper content: \n\n${content.substring(0, 40000)}` });
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
const lengthGuide = {
|
| 452 |
+
'short': '200-300 words',
|
| 453 |
+
'medium': '300-450 words',
|
| 454 |
+
'long': '450-600 words'
|
| 455 |
+
};
|
| 456 |
+
|
| 457 |
+
const sectionTypeGuide: Record<string, string> = {
|
| 458 |
+
'intro': 'Write an engaging opening that hooks the reader. Explain WHY this research matters to the world. Be provocative and inspiring.',
|
| 459 |
+
'background': 'Provide essential context. Explain prerequisite concepts clearly. Define technical terms for a smart but non-expert reader.',
|
| 460 |
+
'methodology': 'Explain HOW it works step by step. Use clear analogies. Break down complex processes into digestible parts.',
|
| 461 |
+
'results': 'Present the key findings with specifics. Use actual numbers from the paper. Compare to baselines meaningfully.',
|
| 462 |
+
'analysis': 'Go deeper. Discuss implications, trade-offs, and surprising findings. Address limitations honestly.',
|
| 463 |
+
'applications': 'Describe real-world use cases. Be concrete about how this could be applied. Include potential impact.',
|
| 464 |
+
'conclusion': 'Synthesize the key takeaways. Discuss open questions and future directions. End with a forward-looking statement.',
|
| 465 |
+
'custom': 'Write compelling content following the focus points provided.'
|
| 466 |
+
};
|
| 467 |
+
|
| 468 |
+
promptParts.push({
|
| 469 |
+
text: `
|
| 470 |
+
PAPER CONTEXT:
|
| 471 |
+
- Title: ${paperContext.title}
|
| 472 |
+
- Main Contribution: ${paperContext.mainContribution}
|
| 473 |
+
- Key Terms: ${paperContext.keyTerms.join(', ')}
|
| 474 |
+
|
| 475 |
+
SECTION TO GENERATE (${sectionIndex + 1} of ${totalSections}):
|
| 476 |
+
- Title: "${sectionPlan.title}"
|
| 477 |
+
- Type: ${sectionPlan.sectionType}
|
| 478 |
+
- Focus Points: ${sectionPlan.focusPoints.map((p, i) => `\n ${i + 1}. ${p}`).join('')}
|
| 479 |
+
- Target Length: ${lengthGuide[sectionPlan.estimatedLength]}
|
| 480 |
+
- Visualization: ${sectionPlan.suggestedVisualization}${sectionPlan.visualizationHint ? ` - ${sectionPlan.visualizationHint}` : ''}
|
| 481 |
+
|
| 482 |
+
WRITING INSTRUCTIONS:
|
| 483 |
+
${sectionTypeGuide[sectionPlan.sectionType]}
|
| 484 |
+
|
| 485 |
+
CONTENT REQUIREMENTS:
|
| 486 |
+
- Write in distill.pub style: accessible yet technically accurate
|
| 487 |
+
- Use Markdown formatting: **bold** for emphasis, bullet points for lists, ### for subheadings
|
| 488 |
+
- Include 1-2 margin notes with helpful asides (use 'tip' for pro tips, 'info' for context, 'warning' for caveats)
|
| 489 |
+
- Define 2-3 technical terms that readers might not know
|
| 490 |
+
- Add 1 collapsible "deep dive" section for readers who want more detail
|
| 491 |
+
|
| 492 |
+
VISUALIZATION REQUIREMENTS:
|
| 493 |
+
${sectionPlan.suggestedVisualization === 'mermaid' ? `
|
| 494 |
+
Create a Mermaid.js diagram. Use this format:
|
| 495 |
+
- Start with 'graph TD' (top-down) or 'graph LR' (left-right)
|
| 496 |
+
- Use descriptive node labels: A[Input Data] --> B[Process Step]
|
| 497 |
+
- Keep it clear and not too complex (5-10 nodes max)
|
| 498 |
+
` : ''}
|
| 499 |
+
${sectionPlan.suggestedVisualization === 'chart' ? `
|
| 500 |
+
Extract ACTUAL data from the paper to create a chart:
|
| 501 |
+
- Use real numbers from tables/figures
|
| 502 |
+
- Choose the right chart type (bar for comparisons, line for trends)
|
| 503 |
+
- Include axis labels
|
| 504 |
+
` : ''}
|
| 505 |
+
${sectionPlan.suggestedVisualization === 'equation' ? `
|
| 506 |
+
Write the key equation in LaTeX-style notation:
|
| 507 |
+
- Use \\frac{}{} for fractions
|
| 508 |
+
- Use \\sum, \\prod for summations/products
|
| 509 |
+
- Use Greek letters: \\alpha, \\beta, \\theta, etc.
|
| 510 |
+
` : ''}
|
| 511 |
+
|
| 512 |
+
Generate the section content now.
|
| 513 |
+
`
|
| 514 |
+
});
|
| 515 |
+
|
| 516 |
+
let effectiveModel = model;
|
| 517 |
+
let requestConfig: any = {
|
| 518 |
+
responseMimeType: "application/json",
|
| 519 |
+
responseSchema: SINGLE_SECTION_SCHEMA as any,
|
| 520 |
+
systemInstruction: "You are an expert science writer for distill.pub. You write clear, engaging, and technically accurate content that makes complex research accessible.",
|
| 521 |
+
};
|
| 522 |
+
|
| 523 |
+
if (useThinking) {
|
| 524 |
+
effectiveModel = 'gemini-3-pro-preview';
|
| 525 |
+
requestConfig.thinkingConfig = { thinkingBudget: 8192 };
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
try {
|
| 529 |
+
const response = await ai.models.generateContent({
|
| 530 |
+
model: effectiveModel,
|
| 531 |
+
contents: { parts: promptParts },
|
| 532 |
+
config: requestConfig
|
| 533 |
+
});
|
| 534 |
+
|
| 535 |
+
const text = response.text;
|
| 536 |
+
if (!text) throw new Error("No response from Gemini");
|
| 537 |
+
|
| 538 |
+
let jsonStr = text;
|
| 539 |
+
const jsonMatch = text.match(/\{.*\}/s);
|
| 540 |
+
if (jsonMatch) {
|
| 541 |
+
jsonStr = jsonMatch[0];
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
const parsed = JSON.parse(jsonStr);
|
| 545 |
+
|
| 546 |
+
return {
|
| 547 |
+
id: sectionPlan.id,
|
| 548 |
+
title: sectionPlan.title,
|
| 549 |
+
content: parsed.content,
|
| 550 |
+
visualizationType: parsed.visualizationType,
|
| 551 |
+
visualizationData: parsed.visualizationData,
|
| 552 |
+
chartData: parsed.chartData,
|
| 553 |
+
marginNotes: (parsed.marginNotes || []).map((note: any, noteIdx: number) => ({
|
| 554 |
+
...note,
|
| 555 |
+
id: `note-${sectionIndex}-${noteIdx}-${Date.now()}`
|
| 556 |
+
})),
|
| 557 |
+
technicalTerms: parsed.technicalTerms || [],
|
| 558 |
+
collapsibleSections: (parsed.collapsibleSections || []).map((section: any, secIdx: number) => ({
|
| 559 |
+
...section,
|
| 560 |
+
id: `collapse-${sectionIndex}-${secIdx}-${Date.now()}`
|
| 561 |
+
}))
|
| 562 |
+
};
|
| 563 |
+
|
| 564 |
+
} catch (error: any) {
|
| 565 |
+
console.error(`Section ${sectionIndex + 1} Generation Error:`, error);
|
| 566 |
+
throw new Error(error.message || `Failed to generate section: ${sectionPlan.title}`);
|
| 567 |
+
}
|
| 568 |
+
};
|
| 569 |
+
|
| 570 |
+
/**
|
| 571 |
+
* Legacy function - kept for compatibility but now uses the progressive approach internally
|
| 572 |
+
*/
|
| 573 |
+
export const generateBlogContent = async (
|
| 574 |
+
apiKey: string,
|
| 575 |
+
model: ModelType,
|
| 576 |
+
content: string,
|
| 577 |
+
isPdf: boolean = false,
|
| 578 |
+
useThinking: boolean = false
|
| 579 |
+
): Promise<BlogSection[]> => {
|
| 580 |
+
// First, analyze the structure
|
| 581 |
+
const structure = await analyzePaperStructure(apiKey, model, content, isPdf, useThinking);
|
| 582 |
+
|
| 583 |
+
// Then generate each section
|
| 584 |
+
const sections: BlogSection[] = [];
|
| 585 |
+
const paperContext = {
|
| 586 |
+
title: structure.paperTitle,
|
| 587 |
+
abstract: structure.paperAbstract,
|
| 588 |
+
mainContribution: structure.mainContribution,
|
| 589 |
+
keyTerms: structure.keyTerms
|
| 590 |
+
};
|
| 591 |
+
|
| 592 |
+
for (let i = 0; i < structure.sections.length; i++) {
|
| 593 |
+
const section = await generateSingleBlogSection(
|
| 594 |
+
apiKey,
|
| 595 |
+
model,
|
| 596 |
+
content,
|
| 597 |
+
structure.sections[i],
|
| 598 |
+
i,
|
| 599 |
+
structure.sections.length,
|
| 600 |
+
paperContext,
|
| 601 |
+
isPdf,
|
| 602 |
+
useThinking
|
| 603 |
+
);
|
| 604 |
+
sections.push(section);
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
return sections;
|
| 608 |
+
};
|
types.ts
CHANGED
|
@@ -35,3 +35,66 @@ export interface ProcessingStatus {
|
|
| 35 |
state: 'idle' | 'reading' | 'analyzing' | 'generating' | 'complete' | 'error';
|
| 36 |
message?: string;
|
| 37 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
state: 'idle' | 'reading' | 'analyzing' | 'generating' | 'complete' | 'error';
|
| 36 |
message?: string;
|
| 37 |
}
|
| 38 |
+
|
| 39 |
+
// Blog View Types
|
| 40 |
+
export interface BlogSection {
|
| 41 |
+
id: string;
|
| 42 |
+
title: string;
|
| 43 |
+
content: string; // Markdown
|
| 44 |
+
visualizationType?: 'mermaid' | 'chart' | 'equation' | 'none';
|
| 45 |
+
visualizationData?: string;
|
| 46 |
+
chartData?: ChartData;
|
| 47 |
+
marginNotes?: MarginNote[];
|
| 48 |
+
technicalTerms?: TechnicalTerm[];
|
| 49 |
+
collapsibleSections?: CollapsibleContent[];
|
| 50 |
+
isLoading?: boolean;
|
| 51 |
+
error?: string;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// Structure plan from paper analysis
|
| 55 |
+
export interface SectionPlan {
|
| 56 |
+
id: string;
|
| 57 |
+
title: string;
|
| 58 |
+
sectionType: 'intro' | 'background' | 'methodology' | 'results' | 'analysis' | 'applications' | 'conclusion' | 'custom';
|
| 59 |
+
focusPoints: string[]; // Key points to cover
|
| 60 |
+
suggestedVisualization: 'mermaid' | 'chart' | 'equation' | 'none';
|
| 61 |
+
visualizationHint?: string; // What to visualize
|
| 62 |
+
estimatedLength: 'short' | 'medium' | 'long';
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export interface PaperStructure {
|
| 66 |
+
paperTitle: string;
|
| 67 |
+
paperAbstract: string;
|
| 68 |
+
mainContribution: string;
|
| 69 |
+
sections: SectionPlan[];
|
| 70 |
+
keyTerms: string[]; // Important terms across the paper
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
export interface MarginNote {
|
| 74 |
+
id: string;
|
| 75 |
+
text: string;
|
| 76 |
+
icon?: 'info' | 'warning' | 'tip' | 'note';
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
export interface TechnicalTerm {
|
| 80 |
+
term: string;
|
| 81 |
+
definition: string;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export interface CollapsibleContent {
|
| 85 |
+
id: string;
|
| 86 |
+
title: string;
|
| 87 |
+
content: string;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// Chart Types
|
| 91 |
+
export interface ChartData {
|
| 92 |
+
type: 'bar' | 'line' | 'pie' | 'scatter' | 'area';
|
| 93 |
+
title: string;
|
| 94 |
+
data: Array<{ label: string; value: number; [key: string]: any }>;
|
| 95 |
+
xAxis?: string;
|
| 96 |
+
yAxis?: string;
|
| 97 |
+
colors?: string[];
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
export type ViewMode = 'grid' | 'blog';
|