Spaces:
Sleeping
Sleeping
Upload 32 files
Browse files- App.tsx +14 -2
- components/Background.tsx +237 -62
- components/BionicText.tsx +170 -0
- components/BlogSection.tsx +98 -8
- components/BlogView.tsx +37 -3
- components/Collapsible.tsx +1 -0
- components/EquationBlock.tsx +1 -0
- components/InteractiveChart.tsx +1 -0
- components/InteractiveChartPlayable.tsx +516 -0
- components/ProgressiveContent.tsx +289 -0
- components/ReadingToolbar.tsx +364 -0
- components/Sidebar.tsx +118 -29
- components/SourceAnchor.tsx +201 -0
- components/TextToSpeech.tsx +321 -0
- services/geminiService.ts +52 -2
- style.css +112 -0
- types.ts +91 -2
App.tsx
CHANGED
|
@@ -4,9 +4,18 @@ import Background from './components/Background';
|
|
| 4 |
import BentoCard from './components/BentoCard';
|
| 5 |
import ChatBot from './components/ChatBot';
|
| 6 |
import BlogView from './components/BlogView';
|
| 7 |
-
import { BentoCardData, BlogSection, ChatMessage, AppSettings, ProcessingStatus, ViewMode, PaperStructure, GeminiModel } from './types';
|
| 8 |
import { generateBentoCards, expandBentoCard, chatWithDocument, analyzePaperStructure, generateAndValidateSection, MODEL_INFO } from './services/aiService';
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
const App: React.FC = () => {
|
| 11 |
// Settings
|
| 12 |
const [settings, setSettings] = useState<AppSettings>({
|
|
@@ -14,7 +23,8 @@ const App: React.FC = () => {
|
|
| 14 |
model: 'gemini-2.5-flash',
|
| 15 |
theme: 'light',
|
| 16 |
layoutMode: 'auto',
|
| 17 |
-
useThinking: false
|
|
|
|
| 18 |
});
|
| 19 |
|
| 20 |
// Inputs
|
|
@@ -735,6 +745,8 @@ const App: React.FC = () => {
|
|
| 735 |
onRetrySection={handleRetrySection}
|
| 736 |
retryingSectionIndex={retryingSectionIndex}
|
| 737 |
contentRef={blogRef}
|
|
|
|
|
|
|
| 738 |
/>
|
| 739 |
)}
|
| 740 |
|
|
|
|
| 4 |
import BentoCard from './components/BentoCard';
|
| 5 |
import ChatBot from './components/ChatBot';
|
| 6 |
import BlogView from './components/BlogView';
|
| 7 |
+
import { BentoCardData, BlogSection, ChatMessage, AppSettings, ProcessingStatus, ViewMode, PaperStructure, GeminiModel, ReadingPreferences } from './types';
|
| 8 |
import { generateBentoCards, expandBentoCard, chatWithDocument, analyzePaperStructure, generateAndValidateSection, MODEL_INFO } from './services/aiService';
|
| 9 |
|
| 10 |
+
// Default reading preferences for ADHD-friendly features
|
| 11 |
+
const defaultReadingPreferences: ReadingPreferences = {
|
| 12 |
+
bionicReading: false,
|
| 13 |
+
eli5Mode: false,
|
| 14 |
+
highlightKeyTerms: true,
|
| 15 |
+
ttsEnabled: false,
|
| 16 |
+
ttsSpeed: 1.0
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
const App: React.FC = () => {
|
| 20 |
// Settings
|
| 21 |
const [settings, setSettings] = useState<AppSettings>({
|
|
|
|
| 23 |
model: 'gemini-2.5-flash',
|
| 24 |
theme: 'light',
|
| 25 |
layoutMode: 'auto',
|
| 26 |
+
useThinking: false,
|
| 27 |
+
readingPreferences: defaultReadingPreferences
|
| 28 |
});
|
| 29 |
|
| 30 |
// Inputs
|
|
|
|
| 745 |
onRetrySection={handleRetrySection}
|
| 746 |
retryingSectionIndex={retryingSectionIndex}
|
| 747 |
contentRef={blogRef}
|
| 748 |
+
readingPreferences={settings.readingPreferences}
|
| 749 |
+
onReadingPreferencesChange={(prefs) => setSettings(prev => ({ ...prev, readingPreferences: prefs }))}
|
| 750 |
/>
|
| 751 |
)}
|
| 752 |
|
components/Background.tsx
CHANGED
|
@@ -1,6 +1,26 @@
|
|
| 1 |
-
|
| 2 |
import React, { useEffect, useRef } from 'react';
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
const Background: React.FC<{ theme: 'light' | 'dark' }> = ({ theme }) => {
|
| 5 |
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 6 |
|
|
@@ -11,7 +31,69 @@ const Background: React.FC<{ theme: 'light' | 'dark' }> = ({ theme }) => {
|
|
| 11 |
if (!ctx) return;
|
| 12 |
|
| 13 |
let animationFrameId: number;
|
| 14 |
-
let
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
const resize = () => {
|
| 17 |
canvas.width = window.innerWidth;
|
|
@@ -20,90 +102,183 @@ const Background: React.FC<{ theme: 'light' | 'dark' }> = ({ theme }) => {
|
|
| 20 |
window.addEventListener('resize', resize);
|
| 21 |
resize();
|
| 22 |
|
| 23 |
-
const
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
x: (Math.random() - 0.5) * size,
|
| 31 |
-
y: (Math.random() - 0.5) * size,
|
| 32 |
-
z: (Math.random() - 0.5) * size,
|
| 33 |
-
});
|
| 34 |
-
}
|
| 35 |
|
| 36 |
-
|
| 37 |
-
//
|
| 38 |
-
|
|
|
|
| 39 |
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
const
|
| 43 |
-
const
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
// Rotate Y
|
| 55 |
-
let x = p.x * Math.cos(time) - p.z * Math.sin(time);
|
| 56 |
-
let z = p.x * Math.sin(time) + p.z * Math.cos(time);
|
| 57 |
-
// Rotate X
|
| 58 |
-
let y = p.y * Math.cos(time * 0.5) - z * Math.sin(time * 0.5);
|
| 59 |
-
z = p.y * Math.sin(time * 0.5) + z * Math.cos(time * 0.5);
|
| 60 |
-
|
| 61 |
-
// Perspective projection
|
| 62 |
-
const scale = 800 / (800 + z);
|
| 63 |
-
return {
|
| 64 |
-
x: cx + x * scale,
|
| 65 |
-
y: cy + y * scale,
|
| 66 |
-
scale
|
| 67 |
-
};
|
| 68 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
-
//
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
const dist = Math.sqrt(dx * dx + dy * dy);
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
}
|
| 82 |
}
|
| 83 |
-
ctx.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
| 90 |
});
|
| 91 |
|
| 92 |
animationFrameId = requestAnimationFrame(draw);
|
| 93 |
};
|
| 94 |
|
| 95 |
-
draw
|
| 96 |
|
| 97 |
return () => {
|
| 98 |
window.removeEventListener('resize', resize);
|
|
|
|
| 99 |
cancelAnimationFrame(animationFrameId);
|
| 100 |
};
|
| 101 |
}, [theme]);
|
| 102 |
|
| 103 |
return (
|
| 104 |
-
<canvas
|
| 105 |
-
ref={canvasRef}
|
| 106 |
-
className="fixed top-0 left-0 w-full h-full -z-10 pointer-events-none
|
| 107 |
/>
|
| 108 |
);
|
| 109 |
};
|
|
|
|
|
|
|
| 1 |
import React, { useEffect, useRef } from 'react';
|
| 2 |
|
| 3 |
+
interface Point {
|
| 4 |
+
x: number;
|
| 5 |
+
y: number;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
interface ShapeDefinition {
|
| 9 |
+
points: Point[];
|
| 10 |
+
type: 'doc' | 'brain' | 'code' | 'graph';
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
interface DocumentEntity {
|
| 14 |
+
id: number;
|
| 15 |
+
gridX: number;
|
| 16 |
+
gridY: number;
|
| 17 |
+
life: number;
|
| 18 |
+
state: 'forming' | 'flipping' | 'fading';
|
| 19 |
+
flipProgress: number;
|
| 20 |
+
opacity: number;
|
| 21 |
+
shape: ShapeDefinition;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
const Background: React.FC<{ theme: 'light' | 'dark' }> = ({ theme }) => {
|
| 25 |
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 26 |
|
|
|
|
| 31 |
if (!ctx) return;
|
| 32 |
|
| 33 |
let animationFrameId: number;
|
| 34 |
+
let mouseX = -1000;
|
| 35 |
+
let mouseY = -1000;
|
| 36 |
+
|
| 37 |
+
// Grid configuration
|
| 38 |
+
const DOT_SPACING = 25; // Denser grid
|
| 39 |
+
const DOT_RADIUS = 1.2;
|
| 40 |
+
|
| 41 |
+
// Shape Definitions
|
| 42 |
+
const DOC_SHAPE: ShapeDefinition = {
|
| 43 |
+
type: 'doc',
|
| 44 |
+
points: [
|
| 45 |
+
{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }, /* corner fold */ { x: 4, y: 1 },
|
| 46 |
+
{ x: 0, y: 1 }, { x: 0, y: 2 }, { x: 0, y: 3 }, { x: 0, y: 4 }, { x: 0, y: 5 },
|
| 47 |
+
{ x: 4, y: 2 }, { x: 4, y: 3 }, { x: 4, y: 4 }, { x: 4, y: 5 },
|
| 48 |
+
{ x: 1, y: 5 }, { x: 2, y: 5 }, { x: 3, y: 5 },
|
| 49 |
+
{ x: 3, y: 0 }, { x: 4, y: 1 },
|
| 50 |
+
{ x: 1, y: 2 }, { x: 2, y: 2 }, { x: 3, y: 2 },
|
| 51 |
+
{ x: 1, y: 4 }, { x: 2, y: 4 }, { x: 3, y: 4 },
|
| 52 |
+
]
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
const CODE_SHAPE: ShapeDefinition = {
|
| 56 |
+
type: 'code',
|
| 57 |
+
points: [
|
| 58 |
+
// <
|
| 59 |
+
{ x: 2, y: 0 }, { x: 1, y: 1 }, { x: 0, y: 2 }, { x: 1, y: 3 }, { x: 2, y: 4 },
|
| 60 |
+
// /
|
| 61 |
+
{ x: 3, y: 0 }, { x: 3, y: 4 }, // Slash approximation
|
| 62 |
+
// >
|
| 63 |
+
{ x: 4, y: 0 }, { x: 5, y: 1 }, { x: 6, y: 2 }, { x: 5, y: 3 }, { x: 4, y: 4 },
|
| 64 |
+
]
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const BRAIN_SHAPE: ShapeDefinition = {
|
| 68 |
+
type: 'brain',
|
| 69 |
+
points: [
|
| 70 |
+
{ x: 2, y: 0 }, { x: 3, y: 0 },
|
| 71 |
+
{ x: 1, y: 1 }, { x: 4, y: 1 },
|
| 72 |
+
{ x: 0, y: 2 }, { x: 5, y: 2 },
|
| 73 |
+
{ x: 0, y: 3 }, { x: 5, y: 3 },
|
| 74 |
+
{ x: 1, y: 4 }, { x: 4, y: 4 },
|
| 75 |
+
{ x: 2, y: 5 }, { x: 3, y: 5 },
|
| 76 |
+
// Internal connections
|
| 77 |
+
{ x: 2, y: 2 }, { x: 3, y: 2 },
|
| 78 |
+
{ x: 2, y: 3 }, { x: 3, y: 3 },
|
| 79 |
+
]
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
const GRAPH_SHAPE: ShapeDefinition = {
|
| 83 |
+
type: 'graph',
|
| 84 |
+
points: [
|
| 85 |
+
{ x: 0, y: 4 }, { x: 1, y: 3 }, { x: 2, y: 4 }, { x: 3, y: 2 }, { x: 4, y: 3 }, { x: 5, y: 0 },
|
| 86 |
+
// Nodes
|
| 87 |
+
{ x: 0, y: 4 }, { x: 2, y: 4 }, { x: 3, y: 2 }, { x: 5, y: 0 }
|
| 88 |
+
]
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
const SHAPES = [DOC_SHAPE, CODE_SHAPE, BRAIN_SHAPE, GRAPH_SHAPE];
|
| 92 |
+
|
| 93 |
+
let documents: DocumentEntity[] = [];
|
| 94 |
+
let nextDocId = 0;
|
| 95 |
+
let lastSpawnTime = 0;
|
| 96 |
+
let pulseTime = 0;
|
| 97 |
|
| 98 |
const resize = () => {
|
| 99 |
canvas.width = window.innerWidth;
|
|
|
|
| 102 |
window.addEventListener('resize', resize);
|
| 103 |
resize();
|
| 104 |
|
| 105 |
+
const handleMouseMove = (e: MouseEvent) => {
|
| 106 |
+
mouseX = e.clientX;
|
| 107 |
+
mouseY = e.clientY;
|
| 108 |
+
};
|
| 109 |
+
window.addEventListener('mousemove', handleMouseMove);
|
| 110 |
|
| 111 |
+
const spawnDocument = () => {
|
| 112 |
+
const cols = Math.ceil(canvas.width / DOT_SPACING);
|
| 113 |
+
const rows = Math.ceil(canvas.height / DOT_SPACING);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
+
// Spawn shapes only in the edge regions (left 15% or right 15%)
|
| 116 |
+
// to avoid cluttering the content area
|
| 117 |
+
const leftZoneCols = Math.floor(cols * 0.12);
|
| 118 |
+
const rightZoneStart = Math.floor(cols * 0.88);
|
| 119 |
|
| 120 |
+
// Randomly pick left or right edge zone
|
| 121 |
+
const useLeftZone = Math.random() < 0.5;
|
| 122 |
+
const gridX = useLeftZone
|
| 123 |
+
? Math.floor(Math.random() * Math.max(1, leftZoneCols - 8))
|
| 124 |
+
: rightZoneStart + Math.floor(Math.random() * Math.max(1, cols - rightZoneStart - 8));
|
| 125 |
|
| 126 |
+
const gridY = Math.floor(Math.random() * (rows - 8));
|
| 127 |
+
const shape = SHAPES[Math.floor(Math.random() * SHAPES.length)];
|
| 128 |
+
|
| 129 |
+
documents.push({
|
| 130 |
+
id: nextDocId++,
|
| 131 |
+
gridX,
|
| 132 |
+
gridY,
|
| 133 |
+
life: 0,
|
| 134 |
+
state: 'forming',
|
| 135 |
+
flipProgress: 0,
|
| 136 |
+
opacity: 0,
|
| 137 |
+
shape
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
});
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
const draw = (timestamp: number) => {
|
| 142 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 143 |
+
|
| 144 |
+
const cols = Math.ceil(canvas.width / DOT_SPACING);
|
| 145 |
+
const rows = Math.ceil(canvas.height / DOT_SPACING);
|
| 146 |
+
|
| 147 |
+
const baseDotColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.15)';
|
| 148 |
+
const activeDotColor = theme === 'dark' ? 'rgba(99, 102, 241, 0.9)' : 'rgba(79, 70, 229, 0.9)';
|
| 149 |
|
| 150 |
+
// Pulse wave
|
| 151 |
+
pulseTime += 0.05;
|
| 152 |
+
|
| 153 |
+
// Define content-safe zone (center area where text is displayed)
|
| 154 |
+
// Dots will fade out as they approach this zone
|
| 155 |
+
const contentLeft = canvas.width * 0.15; // Left 15% is safe for dots
|
| 156 |
+
const contentRight = canvas.width * 0.85; // Right 15% is safe for dots
|
| 157 |
+
const contentTop = canvas.height * 0.08; // Top 8% is safe for dots
|
| 158 |
+
const fadeWidth = 100; // Pixels over which dots fade out
|
| 159 |
+
|
| 160 |
+
// Draw background grid with interaction
|
| 161 |
+
ctx.fillStyle = baseDotColor;
|
| 162 |
+
for (let i = 0; i < cols; i++) {
|
| 163 |
+
for (let j = 0; j < rows; j++) {
|
| 164 |
+
const x = i * DOT_SPACING + (DOT_SPACING / 2);
|
| 165 |
+
const y = j * DOT_SPACING + (DOT_SPACING / 2);
|
| 166 |
+
|
| 167 |
+
// Calculate opacity based on distance from content zone
|
| 168 |
+
let zoneOpacity = 1;
|
| 169 |
+
|
| 170 |
+
// Fade out dots as they enter the content area
|
| 171 |
+
if (x > contentLeft && x < contentRight && y > contentTop) {
|
| 172 |
+
// Inside content zone - calculate fade based on distance from edges
|
| 173 |
+
const distFromLeft = x - contentLeft;
|
| 174 |
+
const distFromRight = contentRight - x;
|
| 175 |
+
const distFromTop = y - contentTop;
|
| 176 |
+
|
| 177 |
+
const minDistX = Math.min(distFromLeft, distFromRight);
|
| 178 |
+
const minDist = Math.min(minDistX, distFromTop);
|
| 179 |
+
|
| 180 |
+
// Fade out completely within fadeWidth pixels of entering content zone
|
| 181 |
+
zoneOpacity = Math.max(0, 1 - (minDist / fadeWidth));
|
| 182 |
+
|
| 183 |
+
// Skip drawing dots that are fully transparent
|
| 184 |
+
if (zoneOpacity < 0.01) continue;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
// Mouse interaction
|
| 188 |
+
const dx = x - mouseX;
|
| 189 |
+
const dy = y - mouseY;
|
| 190 |
const dist = Math.sqrt(dx * dx + dy * dy);
|
| 191 |
+
|
| 192 |
+
let scale = 1;
|
| 193 |
+
let alpha = 1;
|
| 194 |
+
|
| 195 |
+
// Mouse ripple
|
| 196 |
+
if (dist < 150) {
|
| 197 |
+
scale = 1 + (150 - dist) / 150; // Scale up to 2x
|
| 198 |
+
alpha = 1 + (150 - dist) / 100;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
// Global pulse wave
|
| 202 |
+
const wave = Math.sin(x * 0.01 + y * 0.01 + pulseTime) * 0.5 + 0.5;
|
| 203 |
+
if (wave > 0.8) {
|
| 204 |
+
scale += 0.2;
|
| 205 |
+
alpha += 0.2;
|
| 206 |
}
|
| 207 |
+
|
| 208 |
+
ctx.globalAlpha = Math.min(alpha, 0.5) * zoneOpacity; // Apply zone fade
|
| 209 |
+
ctx.beginPath();
|
| 210 |
+
ctx.arc(x, y, DOT_RADIUS * scale, 0, Math.PI * 2);
|
| 211 |
+
ctx.fill();
|
| 212 |
}
|
| 213 |
}
|
| 214 |
+
ctx.globalAlpha = 1;
|
| 215 |
+
|
| 216 |
+
// Update and draw entities
|
| 217 |
+
if (timestamp - lastSpawnTime > 1500) { // Slightly faster spawn
|
| 218 |
+
spawnDocument();
|
| 219 |
+
lastSpawnTime = timestamp;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
documents = documents.filter(doc => doc.state !== 'fading' || doc.opacity > 0.01);
|
| 223 |
+
|
| 224 |
+
documents.forEach(doc => {
|
| 225 |
+
if (doc.state === 'forming') {
|
| 226 |
+
doc.opacity += 0.03;
|
| 227 |
+
if (doc.opacity >= 1) {
|
| 228 |
+
doc.opacity = 1;
|
| 229 |
+
doc.state = 'flipping';
|
| 230 |
+
}
|
| 231 |
+
} else if (doc.state === 'flipping') {
|
| 232 |
+
doc.flipProgress += 0.03;
|
| 233 |
+
if (doc.flipProgress >= Math.PI) {
|
| 234 |
+
doc.state = 'fading';
|
| 235 |
+
}
|
| 236 |
+
} else if (doc.state === 'fading') {
|
| 237 |
+
doc.opacity -= 0.03;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
const centerX = doc.gridX + 3; // Approx center
|
| 241 |
+
|
| 242 |
+
ctx.fillStyle = activeDotColor;
|
| 243 |
+
|
| 244 |
+
doc.shape.points.forEach(pt => {
|
| 245 |
+
const gx = doc.gridX + pt.x;
|
| 246 |
+
const gy = doc.gridY + pt.y;
|
| 247 |
+
|
| 248 |
+
let screenX = gx * DOT_SPACING + (DOT_SPACING / 2);
|
| 249 |
+
const screenY = gy * DOT_SPACING + (DOT_SPACING / 2);
|
| 250 |
+
|
| 251 |
+
if (doc.state === 'flipping' || doc.state === 'fading') {
|
| 252 |
+
const docCenterScreenX = centerX * DOT_SPACING + (DOT_SPACING / 2);
|
| 253 |
+
const relativeX = screenX - docCenterScreenX;
|
| 254 |
+
const scaleX = Math.cos(doc.flipProgress);
|
| 255 |
+
screenX = docCenterScreenX + (relativeX * scaleX);
|
| 256 |
+
}
|
| 257 |
|
| 258 |
+
ctx.globalAlpha = doc.opacity;
|
| 259 |
+
ctx.beginPath();
|
| 260 |
+
ctx.arc(screenX, screenY, DOT_RADIUS * 2.8, 0, Math.PI * 2);
|
| 261 |
+
ctx.fill();
|
| 262 |
+
ctx.globalAlpha = 1.0;
|
| 263 |
+
});
|
| 264 |
});
|
| 265 |
|
| 266 |
animationFrameId = requestAnimationFrame(draw);
|
| 267 |
};
|
| 268 |
|
| 269 |
+
animationFrameId = requestAnimationFrame(draw);
|
| 270 |
|
| 271 |
return () => {
|
| 272 |
window.removeEventListener('resize', resize);
|
| 273 |
+
window.removeEventListener('mousemove', handleMouseMove);
|
| 274 |
cancelAnimationFrame(animationFrameId);
|
| 275 |
};
|
| 276 |
}, [theme]);
|
| 277 |
|
| 278 |
return (
|
| 279 |
+
<canvas
|
| 280 |
+
ref={canvasRef}
|
| 281 |
+
className="fixed top-0 left-0 w-full h-full -z-10 pointer-events-none"
|
| 282 |
/>
|
| 283 |
);
|
| 284 |
};
|
components/BionicText.tsx
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useMemo, ReactElement } from 'react';
|
| 2 |
+
import { TechnicalTerm } from '../types';
|
| 3 |
+
|
| 4 |
+
interface Props {
|
| 5 |
+
text: string;
|
| 6 |
+
bionicEnabled?: boolean;
|
| 7 |
+
highlightTerms?: TechnicalTerm[];
|
| 8 |
+
currentTTSWord?: number; // Index of word being spoken for TTS highlighting
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
// Color palette for consistent term highlighting
|
| 12 |
+
const TERM_COLORS = [
|
| 13 |
+
'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-200',
|
| 14 |
+
'bg-purple-100 dark:bg-purple-900/40 text-purple-800 dark:text-purple-200',
|
| 15 |
+
'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-800 dark:text-emerald-200',
|
| 16 |
+
'bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200',
|
| 17 |
+
'bg-rose-100 dark:bg-rose-900/40 text-rose-800 dark:text-rose-200',
|
| 18 |
+
'bg-cyan-100 dark:bg-cyan-900/40 text-cyan-800 dark:text-cyan-200',
|
| 19 |
+
'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-800 dark:text-indigo-200',
|
| 20 |
+
'bg-teal-100 dark:bg-teal-900/40 text-teal-800 dark:text-teal-200',
|
| 21 |
+
];
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* Apply Bionic Reading formatting to a word
|
| 25 |
+
* Bold the first portion of each word based on length
|
| 26 |
+
*/
|
| 27 |
+
const applyBionicReading = (word: string): ReactElement => {
|
| 28 |
+
if (word.length <= 1) return <>{word}</>;
|
| 29 |
+
|
| 30 |
+
// Calculate how many letters to bold
|
| 31 |
+
// Short words (1-3): 1 letter
|
| 32 |
+
// Medium words (4-6): 2 letters
|
| 33 |
+
// Longer words (7+): ~40% of the word
|
| 34 |
+
let boldCount: number;
|
| 35 |
+
if (word.length <= 3) {
|
| 36 |
+
boldCount = 1;
|
| 37 |
+
} else if (word.length <= 6) {
|
| 38 |
+
boldCount = 2;
|
| 39 |
+
} else {
|
| 40 |
+
boldCount = Math.ceil(word.length * 0.4);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const boldPart = word.slice(0, boldCount);
|
| 44 |
+
const normalPart = word.slice(boldCount);
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<>
|
| 48 |
+
<span className="font-bold">{boldPart}</span>
|
| 49 |
+
<span className="font-normal opacity-80">{normalPart}</span>
|
| 50 |
+
</>
|
| 51 |
+
);
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* Check if a word matches a technical term (case-insensitive)
|
| 56 |
+
*/
|
| 57 |
+
const findMatchingTerm = (
|
| 58 |
+
word: string,
|
| 59 |
+
terms: TechnicalTerm[]
|
| 60 |
+
): { term: TechnicalTerm; colorClass: string } | null => {
|
| 61 |
+
const cleanWord = word.toLowerCase().replace(/[^a-z0-9]/g, '');
|
| 62 |
+
|
| 63 |
+
for (let i = 0; i < terms.length; i++) {
|
| 64 |
+
const termLower = terms[i].term.toLowerCase();
|
| 65 |
+
if (cleanWord === termLower || cleanWord.includes(termLower)) {
|
| 66 |
+
return {
|
| 67 |
+
term: terms[i],
|
| 68 |
+
colorClass: terms[i].highlightColor || TERM_COLORS[i % TERM_COLORS.length]
|
| 69 |
+
};
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
return null;
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
const BionicText: React.FC<Props> = ({
|
| 76 |
+
text,
|
| 77 |
+
bionicEnabled = false,
|
| 78 |
+
highlightTerms = [],
|
| 79 |
+
currentTTSWord
|
| 80 |
+
}) => {
|
| 81 |
+
const processedContent = useMemo(() => {
|
| 82 |
+
// Split text into words while preserving whitespace and punctuation
|
| 83 |
+
const words = text.split(/(\s+)/);
|
| 84 |
+
let wordIndex = 0;
|
| 85 |
+
|
| 86 |
+
return words.map((segment, idx) => {
|
| 87 |
+
// If it's whitespace, just return it
|
| 88 |
+
if (/^\s+$/.test(segment)) {
|
| 89 |
+
return <span key={idx}>{segment}</span>;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const currentWordIdx = wordIndex;
|
| 93 |
+
wordIndex++;
|
| 94 |
+
|
| 95 |
+
// Check if this word is currently being spoken (TTS)
|
| 96 |
+
const isTTSActive = currentTTSWord !== undefined && currentWordIdx === currentTTSWord;
|
| 97 |
+
|
| 98 |
+
// Check if this word matches a technical term
|
| 99 |
+
const matchingTerm = highlightTerms.length > 0
|
| 100 |
+
? findMatchingTerm(segment, highlightTerms)
|
| 101 |
+
: null;
|
| 102 |
+
|
| 103 |
+
// Base styling
|
| 104 |
+
let className = '';
|
| 105 |
+
let content: ReactElement | string = segment;
|
| 106 |
+
|
| 107 |
+
// Apply TTS highlighting (karaoke style)
|
| 108 |
+
if (isTTSActive) {
|
| 109 |
+
className = 'bg-brand-200 dark:bg-brand-700 px-1 rounded transition-colors duration-150';
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// Apply term highlighting
|
| 113 |
+
if (matchingTerm) {
|
| 114 |
+
className = `${matchingTerm.colorClass} px-1 py-0.5 rounded cursor-help transition-colors`;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// Apply bionic reading
|
| 118 |
+
if (bionicEnabled) {
|
| 119 |
+
content = applyBionicReading(segment);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// If it's a technical term, wrap with tooltip
|
| 123 |
+
if (matchingTerm) {
|
| 124 |
+
return (
|
| 125 |
+
<span
|
| 126 |
+
key={idx}
|
| 127 |
+
className={`inline-block ${className} group relative`}
|
| 128 |
+
title={matchingTerm.term.definition}
|
| 129 |
+
>
|
| 130 |
+
{content}
|
| 131 |
+
{/* Inline tooltip on hover */}
|
| 132 |
+
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap max-w-xs z-50 shadow-lg">
|
| 133 |
+
<span className="font-bold">{matchingTerm.term.term}:</span>{' '}
|
| 134 |
+
{matchingTerm.term.definition}
|
| 135 |
+
</span>
|
| 136 |
+
</span>
|
| 137 |
+
);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
return (
|
| 141 |
+
<span key={idx} className={className}>
|
| 142 |
+
{content}
|
| 143 |
+
</span>
|
| 144 |
+
);
|
| 145 |
+
});
|
| 146 |
+
}, [text, bionicEnabled, highlightTerms, currentTTSWord]);
|
| 147 |
+
|
| 148 |
+
return <span className="bionic-text">{processedContent}</span>;
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
+
/**
|
| 152 |
+
* Hook for processing markdown content with bionic reading
|
| 153 |
+
*/
|
| 154 |
+
export const useBionicMarkdown = (content: string, enabled: boolean): string => {
|
| 155 |
+
return useMemo(() => {
|
| 156 |
+
if (!enabled) return content;
|
| 157 |
+
|
| 158 |
+
// Process markdown while preserving syntax
|
| 159 |
+
// We'll bold the first letters of words that aren't part of markdown syntax
|
| 160 |
+
return content.replace(
|
| 161 |
+
/(?<![#*_`\[\]])(\b\w{2,}\b)(?![#*_`\[\]])/g,
|
| 162 |
+
(match) => {
|
| 163 |
+
const boldCount = match.length <= 3 ? 1 : match.length <= 6 ? 2 : Math.ceil(match.length * 0.4);
|
| 164 |
+
return `**${match.slice(0, boldCount)}**${match.slice(boldCount)}`;
|
| 165 |
+
}
|
| 166 |
+
);
|
| 167 |
+
}, [content, enabled]);
|
| 168 |
+
};
|
| 169 |
+
|
| 170 |
+
export default BionicText;
|
components/BlogSection.tsx
CHANGED
|
@@ -1,17 +1,21 @@
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
import ReactMarkdown from 'react-markdown';
|
| 3 |
-
import { BlogSection as BlogSectionType } from '../types';
|
| 4 |
import MermaidDiagram from './MermaidDiagram';
|
| 5 |
-
import
|
| 6 |
import EquationBlock from './EquationBlock';
|
| 7 |
import Collapsible from './Collapsible';
|
| 8 |
import Tooltip from './Tooltip';
|
| 9 |
-
import
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
interface Props {
|
| 12 |
section: BlogSectionType;
|
| 13 |
theme: 'light' | 'dark';
|
| 14 |
index: number;
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
// Validation Badge Component
|
|
@@ -116,7 +120,10 @@ const ValidationBadge: React.FC<{ section: BlogSectionType }> = ({ section }) =>
|
|
| 116 |
);
|
| 117 |
};
|
| 118 |
|
| 119 |
-
const BlogSectionComponent: React.FC<Props> = ({ section, theme, index }) => {
|
|
|
|
|
|
|
|
|
|
| 120 |
const getMarginNoteIcon = (icon?: 'info' | 'warning' | 'tip' | 'note') => {
|
| 121 |
switch (icon) {
|
| 122 |
case 'warning':
|
|
@@ -132,18 +139,73 @@ const BlogSectionComponent: React.FC<Props> = ({ section, theme, index }) => {
|
|
| 132 |
|
| 133 |
// Apply tooltips to content
|
| 134 |
const renderContent = () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
if (!section.technicalTerms || section.technicalTerms.length === 0) {
|
| 136 |
-
return <ReactMarkdown>{
|
| 137 |
}
|
| 138 |
|
| 139 |
// For complex tooltip integration, we'll render ReactMarkdown
|
| 140 |
// and let users hover on specially marked terms
|
| 141 |
return (
|
| 142 |
<div className="relative">
|
| 143 |
-
<ReactMarkdown>{
|
| 144 |
|
| 145 |
{/* Technical Terms Legend */}
|
| 146 |
-
{section.technicalTerms.length > 0 && (
|
| 147 |
<div className="mt-8 p-6 bg-gradient-to-br from-gray-50 to-white dark:from-gray-800/50 dark:to-gray-900/30 rounded-2xl border border-gray-200 dark:border-gray-700">
|
| 148 |
<div className="flex items-center gap-2 mb-4">
|
| 149 |
<div className="w-1 h-4 bg-brand-500 rounded-full"></div>
|
|
@@ -186,8 +248,36 @@ const BlogSectionComponent: React.FC<Props> = ({ section, theme, index }) => {
|
|
| 186 |
{section.title}
|
| 187 |
</h2>
|
| 188 |
<div className="h-1 w-24 bg-gradient-to-r from-brand-500 to-purple-500 rounded-full opacity-80" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
</div>
|
| 190 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
</header>
|
| 192 |
|
| 193 |
{/* Content */}
|
|
@@ -233,7 +323,7 @@ const BlogSectionComponent: React.FC<Props> = ({ section, theme, index }) => {
|
|
| 233 |
)}
|
| 234 |
|
| 235 |
{section.visualizationType === 'chart' && section.chartData && (
|
| 236 |
-
<
|
| 237 |
)}
|
| 238 |
|
| 239 |
{section.visualizationType === 'equation' && section.visualizationData && (
|
|
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
import ReactMarkdown from 'react-markdown';
|
| 3 |
+
import { BlogSection as BlogSectionType, ReadingPreferences } from '../types';
|
| 4 |
import MermaidDiagram from './MermaidDiagram';
|
| 5 |
+
import InteractiveChartPlayable from './InteractiveChartPlayable';
|
| 6 |
import EquationBlock from './EquationBlock';
|
| 7 |
import Collapsible from './Collapsible';
|
| 8 |
import Tooltip from './Tooltip';
|
| 9 |
+
import ProgressiveContent from './ProgressiveContent';
|
| 10 |
+
import TextToSpeech from './TextToSpeech';
|
| 11 |
+
import BionicText from './BionicText';
|
| 12 |
+
import { Info, Lightbulb, AlertTriangle, BookOpen, CheckCircle, XCircle, Shield, ChevronDown, Wrench, Volume2, Zap, Layers } from 'lucide-react';
|
| 13 |
|
| 14 |
interface Props {
|
| 15 |
section: BlogSectionType;
|
| 16 |
theme: 'light' | 'dark';
|
| 17 |
index: number;
|
| 18 |
+
readingPreferences?: ReadingPreferences;
|
| 19 |
}
|
| 20 |
|
| 21 |
// Validation Badge Component
|
|
|
|
| 120 |
);
|
| 121 |
};
|
| 122 |
|
| 123 |
+
const BlogSectionComponent: React.FC<Props> = ({ section, theme, index, readingPreferences }) => {
|
| 124 |
+
const [ttsWordIndex, setTtsWordIndex] = useState<number | undefined>(undefined);
|
| 125 |
+
const [isTtsPlaying, setIsTtsPlaying] = useState(false);
|
| 126 |
+
|
| 127 |
const getMarginNoteIcon = (icon?: 'info' | 'warning' | 'tip' | 'note') => {
|
| 128 |
switch (icon) {
|
| 129 |
case 'warning':
|
|
|
|
| 139 |
|
| 140 |
// Apply tooltips to content
|
| 141 |
const renderContent = () => {
|
| 142 |
+
// Get the content to display based on ELI5 mode
|
| 143 |
+
const displayContent = readingPreferences?.eli5Mode && section.layers?.eli5Content
|
| 144 |
+
? section.layers.eli5Content
|
| 145 |
+
: section.content;
|
| 146 |
+
|
| 147 |
+
// Apply bionic reading if enabled
|
| 148 |
+
const processText = (text: string) => {
|
| 149 |
+
if (!readingPreferences?.bionicReading) return text;
|
| 150 |
+
|
| 151 |
+
// Apply bionic reading: bold first portion of each word
|
| 152 |
+
return text.replace(/\b(\w{2,})\b/g, (match) => {
|
| 153 |
+
const boldCount = match.length <= 3 ? 1 : match.length <= 6 ? 2 : Math.ceil(match.length * 0.4);
|
| 154 |
+
return `**${match.slice(0, boldCount)}**${match.slice(boldCount)}`;
|
| 155 |
+
});
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
const processedContent = processText(displayContent);
|
| 159 |
+
|
| 160 |
+
// If we have progressive disclosure layers, show them first
|
| 161 |
+
if (section.layers) {
|
| 162 |
+
return (
|
| 163 |
+
<div className="space-y-8">
|
| 164 |
+
<ProgressiveContent
|
| 165 |
+
layers={section.layers}
|
| 166 |
+
eli5Mode={readingPreferences?.eli5Mode}
|
| 167 |
+
showConfidenceBadges={true}
|
| 168 |
+
/>
|
| 169 |
+
|
| 170 |
+
{/* Technical Terms Legend */}
|
| 171 |
+
{section.technicalTerms && section.technicalTerms.length > 0 && readingPreferences?.highlightKeyTerms && (
|
| 172 |
+
<div className="mt-8 p-6 bg-gradient-to-br from-gray-50 to-white dark:from-gray-800/50 dark:to-gray-900/30 rounded-2xl border border-gray-200 dark:border-gray-700">
|
| 173 |
+
<div className="flex items-center gap-2 mb-4">
|
| 174 |
+
<div className="w-1 h-4 bg-brand-500 rounded-full"></div>
|
| 175 |
+
<h5 className="text-sm font-bold uppercase tracking-wider text-gray-600 dark:text-gray-400">
|
| 176 |
+
Key Terms
|
| 177 |
+
</h5>
|
| 178 |
+
</div>
|
| 179 |
+
<div className="flex flex-wrap gap-2">
|
| 180 |
+
{section.technicalTerms.map((term, idx) => (
|
| 181 |
+
<Tooltip key={idx} term={term.term} definition={term.definition}>
|
| 182 |
+
<span className={`inline-flex items-center px-2.5 py-1 rounded-lg text-sm font-medium cursor-help transition-colors ${
|
| 183 |
+
term.highlightColor || 'bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
|
| 184 |
+
}`}>
|
| 185 |
+
{term.term}
|
| 186 |
+
</span>
|
| 187 |
+
</Tooltip>
|
| 188 |
+
))}
|
| 189 |
+
</div>
|
| 190 |
+
</div>
|
| 191 |
+
)}
|
| 192 |
+
</div>
|
| 193 |
+
);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
// Fallback to regular markdown rendering
|
| 197 |
if (!section.technicalTerms || section.technicalTerms.length === 0) {
|
| 198 |
+
return <ReactMarkdown>{processedContent}</ReactMarkdown>;
|
| 199 |
}
|
| 200 |
|
| 201 |
// For complex tooltip integration, we'll render ReactMarkdown
|
| 202 |
// and let users hover on specially marked terms
|
| 203 |
return (
|
| 204 |
<div className="relative">
|
| 205 |
+
<ReactMarkdown>{processedContent}</ReactMarkdown>
|
| 206 |
|
| 207 |
{/* Technical Terms Legend */}
|
| 208 |
+
{section.technicalTerms.length > 0 && readingPreferences?.highlightKeyTerms && (
|
| 209 |
<div className="mt-8 p-6 bg-gradient-to-br from-gray-50 to-white dark:from-gray-800/50 dark:to-gray-900/30 rounded-2xl border border-gray-200 dark:border-gray-700">
|
| 210 |
<div className="flex items-center gap-2 mb-4">
|
| 211 |
<div className="w-1 h-4 bg-brand-500 rounded-full"></div>
|
|
|
|
| 248 |
{section.title}
|
| 249 |
</h2>
|
| 250 |
<div className="h-1 w-24 bg-gradient-to-r from-brand-500 to-purple-500 rounded-full opacity-80" />
|
| 251 |
+
|
| 252 |
+
{/* Quick info badges */}
|
| 253 |
+
<div className="flex flex-wrap items-center gap-2 mt-4">
|
| 254 |
+
{section.layers && (
|
| 255 |
+
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 text-xs font-medium">
|
| 256 |
+
<Layers size={12} />
|
| 257 |
+
3-Layer View
|
| 258 |
+
</span>
|
| 259 |
+
)}
|
| 260 |
+
{section.visualizationType && section.visualizationType !== 'none' && (
|
| 261 |
+
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-xs font-medium">
|
| 262 |
+
<Zap size={12} />
|
| 263 |
+
Interactive
|
| 264 |
+
</span>
|
| 265 |
+
)}
|
| 266 |
+
</div>
|
| 267 |
</div>
|
| 268 |
</div>
|
| 269 |
+
|
| 270 |
+
{/* Text-to-Speech Controls */}
|
| 271 |
+
{readingPreferences?.ttsEnabled && (
|
| 272 |
+
<div className="mt-6 pl-0 md:pl-[4.5rem]">
|
| 273 |
+
<TextToSpeech
|
| 274 |
+
text={section.content}
|
| 275 |
+
sectionId={section.id}
|
| 276 |
+
onWordChange={setTtsWordIndex}
|
| 277 |
+
onPlayingChange={setIsTtsPlaying}
|
| 278 |
+
/>
|
| 279 |
+
</div>
|
| 280 |
+
)}
|
| 281 |
</header>
|
| 282 |
|
| 283 |
{/* Content */}
|
|
|
|
| 323 |
)}
|
| 324 |
|
| 325 |
{section.visualizationType === 'chart' && section.chartData && (
|
| 326 |
+
<InteractiveChartPlayable data={section.chartData} theme={theme} />
|
| 327 |
)}
|
| 328 |
|
| 329 |
{section.visualizationType === 'equation' && section.visualizationData && (
|
components/BlogView.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 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
|
|
|
|
| 6 |
|
| 7 |
// Loading placeholder for sections being generated
|
| 8 |
const SectionLoadingPlaceholder: React.FC<{
|
|
@@ -164,8 +165,19 @@ interface Props {
|
|
| 164 |
onRetrySection?: (sectionIndex: number) => Promise<void>;
|
| 165 |
retryingSectionIndex?: number;
|
| 166 |
contentRef?: React.RefObject<HTMLDivElement>;
|
|
|
|
|
|
|
| 167 |
}
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
const BlogView: React.FC<Props> = ({
|
| 170 |
sections,
|
| 171 |
paperTitle,
|
|
@@ -179,13 +191,20 @@ const BlogView: React.FC<Props> = ({
|
|
| 179 |
sectionStatus = '',
|
| 180 |
onRetrySection,
|
| 181 |
retryingSectionIndex = -1,
|
| 182 |
-
contentRef
|
|
|
|
|
|
|
| 183 |
}) => {
|
| 184 |
const [activeSection, setActiveSection] = useState<string>(sections[0]?.id || '');
|
| 185 |
const [readProgress, setReadProgress] = useState(0);
|
|
|
|
| 186 |
const internalContentRef = useRef<HTMLDivElement>(null);
|
| 187 |
const effectiveContentRef = contentRef || internalContentRef;
|
| 188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
// Calculate reading time (rough estimate: 200 words per minute)
|
| 190 |
const completedSections = sections.filter(s => !s.isLoading && s.content);
|
| 191 |
const totalWords = completedSections.reduce((acc, section) => {
|
|
@@ -399,6 +418,20 @@ const BlogView: React.FC<Props> = ({
|
|
| 399 |
</button>
|
| 400 |
</div>
|
| 401 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
</header>
|
| 403 |
)}
|
| 404 |
|
|
@@ -446,6 +479,7 @@ const BlogView: React.FC<Props> = ({
|
|
| 446 |
section={section}
|
| 447 |
theme={theme}
|
| 448 |
index={index}
|
|
|
|
| 449 |
/>
|
| 450 |
)}
|
| 451 |
</div>
|
|
|
|
| 1 |
import React, { useEffect, useState, useRef } from 'react';
|
| 2 |
+
import { BlogSection as BlogSectionType, PaperStructure, ReadingPreferences } from '../types';
|
| 3 |
import BlogSectionComponent from './BlogSection';
|
| 4 |
import Sidebar from './Sidebar';
|
| 5 |
+
import ReadingToolbar from './ReadingToolbar';
|
| 6 |
+
import { Clock, BookOpen, FileText, Share2, Download, Sparkles, CheckCircle2, Loader2, AlertCircle, RefreshCw, RotateCcw, Zap, GraduationCap, Lightbulb } from 'lucide-react';
|
| 7 |
|
| 8 |
// Loading placeholder for sections being generated
|
| 9 |
const SectionLoadingPlaceholder: React.FC<{
|
|
|
|
| 165 |
onRetrySection?: (sectionIndex: number) => Promise<void>;
|
| 166 |
retryingSectionIndex?: number;
|
| 167 |
contentRef?: React.RefObject<HTMLDivElement>;
|
| 168 |
+
readingPreferences?: ReadingPreferences;
|
| 169 |
+
onReadingPreferencesChange?: (prefs: ReadingPreferences) => void;
|
| 170 |
}
|
| 171 |
|
| 172 |
+
// Default reading preferences
|
| 173 |
+
const defaultReadingPreferences: ReadingPreferences = {
|
| 174 |
+
bionicReading: false,
|
| 175 |
+
eli5Mode: false,
|
| 176 |
+
highlightKeyTerms: true,
|
| 177 |
+
ttsEnabled: false,
|
| 178 |
+
ttsSpeed: 1.0
|
| 179 |
+
};
|
| 180 |
+
|
| 181 |
const BlogView: React.FC<Props> = ({
|
| 182 |
sections,
|
| 183 |
paperTitle,
|
|
|
|
| 191 |
sectionStatus = '',
|
| 192 |
onRetrySection,
|
| 193 |
retryingSectionIndex = -1,
|
| 194 |
+
contentRef,
|
| 195 |
+
readingPreferences: externalPreferences,
|
| 196 |
+
onReadingPreferencesChange
|
| 197 |
}) => {
|
| 198 |
const [activeSection, setActiveSection] = useState<string>(sections[0]?.id || '');
|
| 199 |
const [readProgress, setReadProgress] = useState(0);
|
| 200 |
+
const [internalPreferences, setInternalPreferences] = useState<ReadingPreferences>(defaultReadingPreferences);
|
| 201 |
const internalContentRef = useRef<HTMLDivElement>(null);
|
| 202 |
const effectiveContentRef = contentRef || internalContentRef;
|
| 203 |
|
| 204 |
+
// Use external or internal preferences
|
| 205 |
+
const readingPreferences = externalPreferences || internalPreferences;
|
| 206 |
+
const handlePreferencesChange = onReadingPreferencesChange || setInternalPreferences;
|
| 207 |
+
|
| 208 |
// Calculate reading time (rough estimate: 200 words per minute)
|
| 209 |
const completedSections = sections.filter(s => !s.isLoading && s.content);
|
| 210 |
const totalWords = completedSections.reduce((acc, section) => {
|
|
|
|
| 418 |
</button>
|
| 419 |
</div>
|
| 420 |
</div>
|
| 421 |
+
|
| 422 |
+
{/* ADHD-Friendly Reading Toolbar */}
|
| 423 |
+
<div className="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800">
|
| 424 |
+
<div className="flex items-center gap-2 mb-3">
|
| 425 |
+
<Sparkles size={14} className="text-brand-500" />
|
| 426 |
+
<span className="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
| 427 |
+
Reading Mode
|
| 428 |
+
</span>
|
| 429 |
+
</div>
|
| 430 |
+
<ReadingToolbar
|
| 431 |
+
preferences={readingPreferences}
|
| 432 |
+
onPreferencesChange={handlePreferencesChange}
|
| 433 |
+
/>
|
| 434 |
+
</div>
|
| 435 |
</header>
|
| 436 |
)}
|
| 437 |
|
|
|
|
| 479 |
section={section}
|
| 480 |
theme={theme}
|
| 481 |
index={index}
|
| 482 |
+
readingPreferences={readingPreferences}
|
| 483 |
/>
|
| 484 |
)}
|
| 485 |
</div>
|
components/Collapsible.tsx
CHANGED
|
@@ -86,3 +86,4 @@ const Collapsible: React.FC<Props> = ({
|
|
| 86 |
|
| 87 |
export default Collapsible;
|
| 88 |
|
|
|
|
|
|
| 86 |
|
| 87 |
export default Collapsible;
|
| 88 |
|
| 89 |
+
|
components/EquationBlock.tsx
CHANGED
|
@@ -105,3 +105,4 @@ const EquationBlock: React.FC<Props> = ({ equation, label, inline = false }) =>
|
|
| 105 |
|
| 106 |
export default EquationBlock;
|
| 107 |
|
|
|
|
|
|
| 105 |
|
| 106 |
export default EquationBlock;
|
| 107 |
|
| 108 |
+
|
components/InteractiveChart.tsx
CHANGED
|
@@ -333,3 +333,4 @@ const InteractiveChart: React.FC<Props> = ({ data, theme }) => {
|
|
| 333 |
|
| 334 |
export default InteractiveChart;
|
| 335 |
|
|
|
|
|
|
| 333 |
|
| 334 |
export default InteractiveChart;
|
| 335 |
|
| 336 |
+
|
components/InteractiveChartPlayable.tsx
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useMemo, useCallback } from 'react';
|
| 2 |
+
import { ChartData, SliderConfig } from '../types';
|
| 3 |
+
import { Play, Pause, RotateCcw, Eye, EyeOff, Sliders, Info } from 'lucide-react';
|
| 4 |
+
|
| 5 |
+
interface Props {
|
| 6 |
+
data: ChartData;
|
| 7 |
+
theme: 'light' | 'dark';
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
// Default colors palette
|
| 11 |
+
const DEFAULT_COLORS = [
|
| 12 |
+
'#0ea5e9', // brand blue
|
| 13 |
+
'#8b5cf6', // purple
|
| 14 |
+
'#10b981', // emerald
|
| 15 |
+
'#f59e0b', // amber
|
| 16 |
+
'#ef4444', // red
|
| 17 |
+
'#ec4899', // pink
|
| 18 |
+
'#06b6d4', // cyan
|
| 19 |
+
'#84cc16', // lime
|
| 20 |
+
];
|
| 21 |
+
|
| 22 |
+
const InteractiveChartPlayable: React.FC<Props> = ({ data, theme }) => {
|
| 23 |
+
const isDark = theme === 'dark';
|
| 24 |
+
const colors = data.colors || DEFAULT_COLORS;
|
| 25 |
+
|
| 26 |
+
// State for interactive features
|
| 27 |
+
const [sliderValues, setSliderValues] = useState<Record<string, number>>(() => {
|
| 28 |
+
const initial: Record<string, number> = {};
|
| 29 |
+
data.interactive?.sliderConfig?.forEach(config => {
|
| 30 |
+
initial[config.id] = config.defaultValue;
|
| 31 |
+
});
|
| 32 |
+
return initial;
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
const [visibleDatasets, setVisibleDatasets] = useState<Set<string>>(() => {
|
| 36 |
+
return new Set(data.data.map(d => d.label));
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
const [hoveredItem, setHoveredItem] = useState<number | null>(null);
|
| 40 |
+
const [isAnimating, setIsAnimating] = useState(false);
|
| 41 |
+
const [showControls, setShowControls] = useState(true);
|
| 42 |
+
|
| 43 |
+
// Apply slider transformations to data
|
| 44 |
+
const transformedData = useMemo(() => {
|
| 45 |
+
if (!data.interactive?.enableSliders || !data.interactive.sliderConfig) {
|
| 46 |
+
return data.data;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return data.data.map(item => {
|
| 50 |
+
let newValue = item.value;
|
| 51 |
+
|
| 52 |
+
data.interactive?.sliderConfig?.forEach(config => {
|
| 53 |
+
const sliderValue = sliderValues[config.id] || config.defaultValue;
|
| 54 |
+
|
| 55 |
+
// Apply transformation based on the affected variable
|
| 56 |
+
if (config.affectsVariable === 'multiplier') {
|
| 57 |
+
newValue = item.value * sliderValue;
|
| 58 |
+
} else if (config.affectsVariable === 'offset') {
|
| 59 |
+
newValue = item.value + sliderValue;
|
| 60 |
+
} else if (config.affectsVariable === 'power') {
|
| 61 |
+
newValue = Math.pow(item.value, sliderValue);
|
| 62 |
+
}
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
return { ...item, value: newValue };
|
| 66 |
+
});
|
| 67 |
+
}, [data.data, sliderValues, data.interactive]);
|
| 68 |
+
|
| 69 |
+
// Filter by visible datasets
|
| 70 |
+
const visibleData = useMemo(() => {
|
| 71 |
+
return transformedData.filter(item => visibleDatasets.has(item.label));
|
| 72 |
+
}, [transformedData, visibleDatasets]);
|
| 73 |
+
|
| 74 |
+
const maxValue = useMemo(() => {
|
| 75 |
+
return Math.max(...visibleData.map(d => d.value), 1);
|
| 76 |
+
}, [visibleData]);
|
| 77 |
+
|
| 78 |
+
const handleSliderChange = useCallback((sliderId: string, value: number) => {
|
| 79 |
+
setSliderValues(prev => ({ ...prev, [sliderId]: value }));
|
| 80 |
+
}, []);
|
| 81 |
+
|
| 82 |
+
const handleToggleDataset = useCallback((label: string) => {
|
| 83 |
+
setVisibleDatasets(prev => {
|
| 84 |
+
const next = new Set(prev);
|
| 85 |
+
if (next.has(label)) {
|
| 86 |
+
if (next.size > 1) next.delete(label);
|
| 87 |
+
} else {
|
| 88 |
+
next.add(label);
|
| 89 |
+
}
|
| 90 |
+
return next;
|
| 91 |
+
});
|
| 92 |
+
}, []);
|
| 93 |
+
|
| 94 |
+
const handleReset = useCallback(() => {
|
| 95 |
+
setSliderValues(() => {
|
| 96 |
+
const initial: Record<string, number> = {};
|
| 97 |
+
data.interactive?.sliderConfig?.forEach(config => {
|
| 98 |
+
initial[config.id] = config.defaultValue;
|
| 99 |
+
});
|
| 100 |
+
return initial;
|
| 101 |
+
});
|
| 102 |
+
setVisibleDatasets(new Set(data.data.map(d => d.label)));
|
| 103 |
+
}, [data]);
|
| 104 |
+
|
| 105 |
+
const handleAnimate = useCallback(() => {
|
| 106 |
+
if (!data.interactive?.sliderConfig?.[0]) return;
|
| 107 |
+
|
| 108 |
+
setIsAnimating(true);
|
| 109 |
+
const config = data.interactive.sliderConfig[0];
|
| 110 |
+
const steps = 20;
|
| 111 |
+
const stepSize = (config.max - config.min) / steps;
|
| 112 |
+
let currentStep = 0;
|
| 113 |
+
|
| 114 |
+
const interval = setInterval(() => {
|
| 115 |
+
setSliderValues(prev => ({
|
| 116 |
+
...prev,
|
| 117 |
+
[config.id]: config.min + (stepSize * currentStep)
|
| 118 |
+
}));
|
| 119 |
+
currentStep++;
|
| 120 |
+
|
| 121 |
+
if (currentStep > steps) {
|
| 122 |
+
clearInterval(interval);
|
| 123 |
+
setIsAnimating(false);
|
| 124 |
+
}
|
| 125 |
+
}, 100);
|
| 126 |
+
}, [data.interactive]);
|
| 127 |
+
|
| 128 |
+
// Chart dimensions
|
| 129 |
+
const chartWidth = 400;
|
| 130 |
+
const chartHeight = 200;
|
| 131 |
+
const barWidth = Math.min(50, (chartWidth - 80) / Math.max(visibleData.length, 1) - 10);
|
| 132 |
+
|
| 133 |
+
const renderBarChart = () => (
|
| 134 |
+
<svg viewBox={`0 0 ${chartWidth} ${chartHeight + 60}`} className="w-full h-auto">
|
| 135 |
+
{/* Grid lines */}
|
| 136 |
+
{[0, 0.25, 0.5, 0.75, 1].map((ratio, i) => (
|
| 137 |
+
<g key={i}>
|
| 138 |
+
<text
|
| 139 |
+
x="30"
|
| 140 |
+
y={chartHeight - (ratio * chartHeight) + 5}
|
| 141 |
+
className={`text-[10px] ${isDark ? 'fill-gray-400' : 'fill-gray-500'}`}
|
| 142 |
+
textAnchor="end"
|
| 143 |
+
>
|
| 144 |
+
{Math.round(maxValue * ratio)}
|
| 145 |
+
</text>
|
| 146 |
+
<line
|
| 147 |
+
x1="40"
|
| 148 |
+
y1={chartHeight - (ratio * chartHeight)}
|
| 149 |
+
x2={chartWidth - 10}
|
| 150 |
+
y2={chartHeight - (ratio * chartHeight)}
|
| 151 |
+
className={isDark ? 'stroke-gray-700' : 'stroke-gray-200'}
|
| 152 |
+
strokeDasharray="4,4"
|
| 153 |
+
/>
|
| 154 |
+
</g>
|
| 155 |
+
))}
|
| 156 |
+
|
| 157 |
+
{/* Bars with animation */}
|
| 158 |
+
{visibleData.map((item, index) => {
|
| 159 |
+
const barHeight = (item.value / maxValue) * chartHeight;
|
| 160 |
+
const x = 50 + index * ((chartWidth - 60) / Math.max(visibleData.length, 1));
|
| 161 |
+
const y = chartHeight - barHeight;
|
| 162 |
+
const isHovered = hoveredItem === index;
|
| 163 |
+
const originalIndex = data.data.findIndex(d => d.label === item.label);
|
| 164 |
+
|
| 165 |
+
return (
|
| 166 |
+
<g
|
| 167 |
+
key={item.label}
|
| 168 |
+
className="cursor-pointer"
|
| 169 |
+
onMouseEnter={() => setHoveredItem(index)}
|
| 170 |
+
onMouseLeave={() => setHoveredItem(null)}
|
| 171 |
+
>
|
| 172 |
+
{/* Bar */}
|
| 173 |
+
<rect
|
| 174 |
+
x={x}
|
| 175 |
+
y={y}
|
| 176 |
+
width={barWidth}
|
| 177 |
+
height={barHeight}
|
| 178 |
+
fill={colors[originalIndex % colors.length]}
|
| 179 |
+
rx="4"
|
| 180 |
+
className={`
|
| 181 |
+
transition-all duration-300 ease-out
|
| 182 |
+
${isHovered ? 'opacity-100' : 'opacity-85'}
|
| 183 |
+
`}
|
| 184 |
+
style={{
|
| 185 |
+
transform: isHovered ? 'scale(1.02)' : 'scale(1)',
|
| 186 |
+
transformOrigin: `${x + barWidth/2}px ${chartHeight}px`
|
| 187 |
+
}}
|
| 188 |
+
/>
|
| 189 |
+
|
| 190 |
+
{/* Highlight glow on hover */}
|
| 191 |
+
{isHovered && (
|
| 192 |
+
<rect
|
| 193 |
+
x={x - 2}
|
| 194 |
+
y={y - 2}
|
| 195 |
+
width={barWidth + 4}
|
| 196 |
+
height={barHeight + 4}
|
| 197 |
+
fill="none"
|
| 198 |
+
stroke={colors[originalIndex % colors.length]}
|
| 199 |
+
strokeWidth="2"
|
| 200 |
+
rx="6"
|
| 201 |
+
opacity="0.3"
|
| 202 |
+
/>
|
| 203 |
+
)}
|
| 204 |
+
|
| 205 |
+
{/* Value tooltip on hover */}
|
| 206 |
+
{isHovered && (
|
| 207 |
+
<g>
|
| 208 |
+
<rect
|
| 209 |
+
x={x + barWidth/2 - 25}
|
| 210 |
+
y={y - 35}
|
| 211 |
+
width="50"
|
| 212 |
+
height="24"
|
| 213 |
+
rx="4"
|
| 214 |
+
className={isDark ? 'fill-gray-800' : 'fill-gray-900'}
|
| 215 |
+
/>
|
| 216 |
+
<text
|
| 217 |
+
x={x + barWidth / 2}
|
| 218 |
+
y={y - 18}
|
| 219 |
+
textAnchor="middle"
|
| 220 |
+
className="text-[12px] font-bold fill-white"
|
| 221 |
+
>
|
| 222 |
+
{item.value.toFixed(1)}
|
| 223 |
+
</text>
|
| 224 |
+
</g>
|
| 225 |
+
)}
|
| 226 |
+
|
| 227 |
+
{/* Label */}
|
| 228 |
+
<text
|
| 229 |
+
x={x + barWidth / 2}
|
| 230 |
+
y={chartHeight + 18}
|
| 231 |
+
textAnchor="middle"
|
| 232 |
+
className={`text-[10px] ${isDark ? 'fill-gray-400' : 'fill-gray-600'} ${isHovered ? 'font-bold' : ''}`}
|
| 233 |
+
>
|
| 234 |
+
{item.label.length > 10 ? item.label.substring(0, 10) + '...' : item.label}
|
| 235 |
+
</text>
|
| 236 |
+
</g>
|
| 237 |
+
);
|
| 238 |
+
})}
|
| 239 |
+
</svg>
|
| 240 |
+
);
|
| 241 |
+
|
| 242 |
+
const renderLineChart = () => {
|
| 243 |
+
const points = visibleData.map((item, index) => {
|
| 244 |
+
const x = 50 + index * ((chartWidth - 80) / (Math.max(visibleData.length, 1) - 1 || 1));
|
| 245 |
+
const y = chartHeight - (item.value / maxValue) * chartHeight;
|
| 246 |
+
return { x, y, ...item };
|
| 247 |
+
});
|
| 248 |
+
|
| 249 |
+
const pathD = points
|
| 250 |
+
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`)
|
| 251 |
+
.join(' ');
|
| 252 |
+
|
| 253 |
+
const areaD = `${pathD} L ${points[points.length - 1]?.x || 0} ${chartHeight} L ${points[0]?.x || 0} ${chartHeight} Z`;
|
| 254 |
+
|
| 255 |
+
return (
|
| 256 |
+
<svg viewBox={`0 0 ${chartWidth} ${chartHeight + 60}`} className="w-full h-auto">
|
| 257 |
+
{/* Grid */}
|
| 258 |
+
{[0, 0.25, 0.5, 0.75, 1].map((ratio, i) => (
|
| 259 |
+
<g key={i}>
|
| 260 |
+
<text
|
| 261 |
+
x="30"
|
| 262 |
+
y={chartHeight - (ratio * chartHeight) + 5}
|
| 263 |
+
className={`text-[10px] ${isDark ? 'fill-gray-400' : 'fill-gray-500'}`}
|
| 264 |
+
textAnchor="end"
|
| 265 |
+
>
|
| 266 |
+
{Math.round(maxValue * ratio)}
|
| 267 |
+
</text>
|
| 268 |
+
<line
|
| 269 |
+
x1="40"
|
| 270 |
+
y1={chartHeight - (ratio * chartHeight)}
|
| 271 |
+
x2={chartWidth - 10}
|
| 272 |
+
y2={chartHeight - (ratio * chartHeight)}
|
| 273 |
+
className={isDark ? 'stroke-gray-700' : 'stroke-gray-200'}
|
| 274 |
+
strokeDasharray="4,4"
|
| 275 |
+
/>
|
| 276 |
+
</g>
|
| 277 |
+
))}
|
| 278 |
+
|
| 279 |
+
{/* Area fill */}
|
| 280 |
+
<path
|
| 281 |
+
d={areaD}
|
| 282 |
+
fill={colors[0]}
|
| 283 |
+
fillOpacity="0.15"
|
| 284 |
+
className="transition-all duration-300"
|
| 285 |
+
/>
|
| 286 |
+
|
| 287 |
+
{/* Line */}
|
| 288 |
+
<path
|
| 289 |
+
d={pathD}
|
| 290 |
+
fill="none"
|
| 291 |
+
stroke={colors[0]}
|
| 292 |
+
strokeWidth="3"
|
| 293 |
+
strokeLinecap="round"
|
| 294 |
+
strokeLinejoin="round"
|
| 295 |
+
className="transition-all duration-300"
|
| 296 |
+
/>
|
| 297 |
+
|
| 298 |
+
{/* Interactive points */}
|
| 299 |
+
{points.map((point, index) => (
|
| 300 |
+
<g
|
| 301 |
+
key={index}
|
| 302 |
+
className="cursor-pointer"
|
| 303 |
+
onMouseEnter={() => setHoveredItem(index)}
|
| 304 |
+
onMouseLeave={() => setHoveredItem(null)}
|
| 305 |
+
>
|
| 306 |
+
{/* Larger hit area */}
|
| 307 |
+
<circle
|
| 308 |
+
cx={point.x}
|
| 309 |
+
cy={point.y}
|
| 310 |
+
r="15"
|
| 311 |
+
fill="transparent"
|
| 312 |
+
/>
|
| 313 |
+
|
| 314 |
+
{/* Visible point */}
|
| 315 |
+
<circle
|
| 316 |
+
cx={point.x}
|
| 317 |
+
cy={point.y}
|
| 318 |
+
r={hoveredItem === index ? 8 : 5}
|
| 319 |
+
fill={colors[0]}
|
| 320 |
+
className="transition-all duration-200"
|
| 321 |
+
/>
|
| 322 |
+
<circle
|
| 323 |
+
cx={point.x}
|
| 324 |
+
cy={point.y}
|
| 325 |
+
r={hoveredItem === index ? 4 : 2}
|
| 326 |
+
fill={isDark ? '#1e293b' : 'white'}
|
| 327 |
+
/>
|
| 328 |
+
|
| 329 |
+
{/* Tooltip */}
|
| 330 |
+
{hoveredItem === index && (
|
| 331 |
+
<g>
|
| 332 |
+
<rect
|
| 333 |
+
x={point.x - 30}
|
| 334 |
+
y={point.y - 40}
|
| 335 |
+
width="60"
|
| 336 |
+
height="28"
|
| 337 |
+
rx="4"
|
| 338 |
+
className={isDark ? 'fill-gray-800' : 'fill-gray-900'}
|
| 339 |
+
/>
|
| 340 |
+
<text
|
| 341 |
+
x={point.x}
|
| 342 |
+
y={point.y - 22}
|
| 343 |
+
textAnchor="middle"
|
| 344 |
+
className="text-[11px] font-bold fill-white"
|
| 345 |
+
>
|
| 346 |
+
{point.value.toFixed(1)}
|
| 347 |
+
</text>
|
| 348 |
+
</g>
|
| 349 |
+
)}
|
| 350 |
+
|
| 351 |
+
{/* X-axis label */}
|
| 352 |
+
<text
|
| 353 |
+
x={point.x}
|
| 354 |
+
y={chartHeight + 18}
|
| 355 |
+
textAnchor="middle"
|
| 356 |
+
className={`text-[10px] ${isDark ? 'fill-gray-400' : 'fill-gray-600'}`}
|
| 357 |
+
>
|
| 358 |
+
{point.label.length > 8 ? point.label.substring(0, 8) + '..' : point.label}
|
| 359 |
+
</text>
|
| 360 |
+
</g>
|
| 361 |
+
))}
|
| 362 |
+
</svg>
|
| 363 |
+
);
|
| 364 |
+
};
|
| 365 |
+
|
| 366 |
+
return (
|
| 367 |
+
<div className="space-y-4">
|
| 368 |
+
{/* Interactive Controls Header */}
|
| 369 |
+
<div className="flex items-center justify-between">
|
| 370 |
+
<div className="flex items-center gap-2">
|
| 371 |
+
<Sliders size={16} className="text-brand-500" />
|
| 372 |
+
<span className="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
| 373 |
+
Interactive Chart
|
| 374 |
+
</span>
|
| 375 |
+
</div>
|
| 376 |
+
|
| 377 |
+
<div className="flex items-center gap-2">
|
| 378 |
+
{data.interactive?.enableSliders && (
|
| 379 |
+
<button
|
| 380 |
+
onClick={handleAnimate}
|
| 381 |
+
disabled={isAnimating}
|
| 382 |
+
className={`
|
| 383 |
+
flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium transition-colors
|
| 384 |
+
${isAnimating
|
| 385 |
+
? 'bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400'
|
| 386 |
+
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
| 387 |
+
}
|
| 388 |
+
`}
|
| 389 |
+
>
|
| 390 |
+
{isAnimating ? <Pause size={12} /> : <Play size={12} />}
|
| 391 |
+
{isAnimating ? 'Playing' : 'Animate'}
|
| 392 |
+
</button>
|
| 393 |
+
)}
|
| 394 |
+
|
| 395 |
+
<button
|
| 396 |
+
onClick={handleReset}
|
| 397 |
+
className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
| 398 |
+
>
|
| 399 |
+
<RotateCcw size={12} />
|
| 400 |
+
Reset
|
| 401 |
+
</button>
|
| 402 |
+
|
| 403 |
+
<button
|
| 404 |
+
onClick={() => setShowControls(!showControls)}
|
| 405 |
+
className={`
|
| 406 |
+
p-1.5 rounded-lg transition-colors
|
| 407 |
+
${showControls
|
| 408 |
+
? 'bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400'
|
| 409 |
+
: 'bg-gray-100 dark:bg-gray-800 text-gray-500'
|
| 410 |
+
}
|
| 411 |
+
`}
|
| 412 |
+
>
|
| 413 |
+
{showControls ? <Eye size={14} /> : <EyeOff size={14} />}
|
| 414 |
+
</button>
|
| 415 |
+
</div>
|
| 416 |
+
</div>
|
| 417 |
+
|
| 418 |
+
{/* Title */}
|
| 419 |
+
{data.title && (
|
| 420 |
+
<h4 className="text-center text-sm font-semibold text-gray-700 dark:text-gray-300">
|
| 421 |
+
{data.title}
|
| 422 |
+
</h4>
|
| 423 |
+
)}
|
| 424 |
+
|
| 425 |
+
{/* Chart */}
|
| 426 |
+
<div className="relative bg-white dark:bg-gray-900 rounded-xl p-4">
|
| 427 |
+
{data.type === 'line' || data.type === 'area' ? renderLineChart() : renderBarChart()}
|
| 428 |
+
</div>
|
| 429 |
+
|
| 430 |
+
{/* Interactive Controls */}
|
| 431 |
+
{showControls && (
|
| 432 |
+
<div className="space-y-4 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-200 dark:border-gray-700">
|
| 433 |
+
|
| 434 |
+
{/* Sliders */}
|
| 435 |
+
{data.interactive?.enableSliders && data.interactive.sliderConfig?.map((config) => (
|
| 436 |
+
<div key={config.id} className="space-y-2">
|
| 437 |
+
<div className="flex items-center justify-between">
|
| 438 |
+
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">
|
| 439 |
+
{config.label}
|
| 440 |
+
</label>
|
| 441 |
+
<span className="text-sm font-mono font-bold text-brand-600 dark:text-brand-400">
|
| 442 |
+
{sliderValues[config.id]?.toFixed(2) || config.defaultValue.toFixed(2)}
|
| 443 |
+
</span>
|
| 444 |
+
</div>
|
| 445 |
+
<input
|
| 446 |
+
type="range"
|
| 447 |
+
min={config.min}
|
| 448 |
+
max={config.max}
|
| 449 |
+
step={config.step}
|
| 450 |
+
value={sliderValues[config.id] || config.defaultValue}
|
| 451 |
+
onChange={(e) => handleSliderChange(config.id, parseFloat(e.target.value))}
|
| 452 |
+
className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-brand-500"
|
| 453 |
+
/>
|
| 454 |
+
<div className="flex justify-between text-[10px] text-gray-400">
|
| 455 |
+
<span>{config.min}</span>
|
| 456 |
+
<span>{config.max}</span>
|
| 457 |
+
</div>
|
| 458 |
+
</div>
|
| 459 |
+
))}
|
| 460 |
+
|
| 461 |
+
{/* Dataset Toggles */}
|
| 462 |
+
{data.interactive?.enableToggles && (
|
| 463 |
+
<div className="space-y-2">
|
| 464 |
+
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">
|
| 465 |
+
Toggle Datasets
|
| 466 |
+
</label>
|
| 467 |
+
<div className="flex flex-wrap gap-2">
|
| 468 |
+
{data.data.map((item, idx) => (
|
| 469 |
+
<button
|
| 470 |
+
key={item.label}
|
| 471 |
+
onClick={() => handleToggleDataset(item.label)}
|
| 472 |
+
className={`
|
| 473 |
+
flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all
|
| 474 |
+
${visibleDatasets.has(item.label)
|
| 475 |
+
? 'bg-white dark:bg-gray-700 shadow-sm border border-gray-200 dark:border-gray-600'
|
| 476 |
+
: 'bg-gray-100 dark:bg-gray-800 opacity-50'
|
| 477 |
+
}
|
| 478 |
+
`}
|
| 479 |
+
>
|
| 480 |
+
<span
|
| 481 |
+
className="w-3 h-3 rounded-full"
|
| 482 |
+
style={{ backgroundColor: colors[idx % colors.length] }}
|
| 483 |
+
/>
|
| 484 |
+
<span className={visibleDatasets.has(item.label) ? '' : 'line-through'}>
|
| 485 |
+
{item.label}
|
| 486 |
+
</span>
|
| 487 |
+
</button>
|
| 488 |
+
))}
|
| 489 |
+
</div>
|
| 490 |
+
</div>
|
| 491 |
+
)}
|
| 492 |
+
|
| 493 |
+
{/* Info tooltip */}
|
| 494 |
+
<div className="flex items-start gap-2 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
|
| 495 |
+
<Info size={14} className="flex-shrink-0 mt-0.5" />
|
| 496 |
+
<p className="text-xs">
|
| 497 |
+
<span className="font-medium">Tip:</span> Hover over data points for details.
|
| 498 |
+
{data.interactive?.enableSliders && ' Use the slider to see how changes affect the visualization.'}
|
| 499 |
+
{data.interactive?.enableToggles && ' Toggle datasets on/off to compare.'}
|
| 500 |
+
</p>
|
| 501 |
+
</div>
|
| 502 |
+
</div>
|
| 503 |
+
)}
|
| 504 |
+
|
| 505 |
+
{/* Axis Labels */}
|
| 506 |
+
{(data.xAxis || data.yAxis) && (
|
| 507 |
+
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400 italic">
|
| 508 |
+
{data.yAxis && <span>↑ {data.yAxis}</span>}
|
| 509 |
+
{data.xAxis && <span>{data.xAxis} →</span>}
|
| 510 |
+
</div>
|
| 511 |
+
)}
|
| 512 |
+
</div>
|
| 513 |
+
);
|
| 514 |
+
};
|
| 515 |
+
|
| 516 |
+
export default InteractiveChartPlayable;
|
components/ProgressiveContent.tsx
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import ReactMarkdown from 'react-markdown';
|
| 3 |
+
import { ChevronDown, ChevronUp, Layers, Zap, List, FileText, Quote, Sparkles } from 'lucide-react';
|
| 4 |
+
import { ContentLayers, ConfidenceLevel } from '../types';
|
| 5 |
+
|
| 6 |
+
interface Props {
|
| 7 |
+
layers: ContentLayers;
|
| 8 |
+
eli5Mode?: boolean;
|
| 9 |
+
showConfidenceBadges?: boolean;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
type LayerLevel = 1 | 2 | 3;
|
| 13 |
+
|
| 14 |
+
const layerInfo = {
|
| 15 |
+
1: {
|
| 16 |
+
name: 'TL;DR',
|
| 17 |
+
icon: Zap,
|
| 18 |
+
description: 'Core thesis in one sentence',
|
| 19 |
+
color: 'brand'
|
| 20 |
+
},
|
| 21 |
+
2: {
|
| 22 |
+
name: 'Key Takeaways',
|
| 23 |
+
icon: List,
|
| 24 |
+
description: '3-5 essential points',
|
| 25 |
+
color: 'purple'
|
| 26 |
+
},
|
| 27 |
+
3: {
|
| 28 |
+
name: 'Full Details',
|
| 29 |
+
icon: FileText,
|
| 30 |
+
description: 'Complete explanation',
|
| 31 |
+
color: 'emerald'
|
| 32 |
+
}
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
const ProgressiveContent: React.FC<Props> = ({
|
| 36 |
+
layers,
|
| 37 |
+
eli5Mode = false,
|
| 38 |
+
showConfidenceBadges = true
|
| 39 |
+
}) => {
|
| 40 |
+
const [expandedLayer, setExpandedLayer] = useState<LayerLevel>(1);
|
| 41 |
+
const [isAnimating, setIsAnimating] = useState(false);
|
| 42 |
+
|
| 43 |
+
const handleLayerChange = (level: LayerLevel) => {
|
| 44 |
+
if (level === expandedLayer) return;
|
| 45 |
+
setIsAnimating(true);
|
| 46 |
+
setTimeout(() => {
|
| 47 |
+
setExpandedLayer(level);
|
| 48 |
+
setIsAnimating(false);
|
| 49 |
+
}, 150);
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const content = eli5Mode && layers.eli5Content ? layers.eli5Content : layers.detailed;
|
| 53 |
+
|
| 54 |
+
return (
|
| 55 |
+
<div className="space-y-4">
|
| 56 |
+
{/* Layer Selector Pills */}
|
| 57 |
+
<div className="flex flex-wrap items-center gap-2 mb-6">
|
| 58 |
+
<span className="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mr-2">
|
| 59 |
+
Depth:
|
| 60 |
+
</span>
|
| 61 |
+
{([1, 2, 3] as LayerLevel[]).map((level) => {
|
| 62 |
+
const info = layerInfo[level];
|
| 63 |
+
const Icon = info.icon;
|
| 64 |
+
const isActive = expandedLayer === level;
|
| 65 |
+
|
| 66 |
+
return (
|
| 67 |
+
<button
|
| 68 |
+
key={level}
|
| 69 |
+
onClick={() => handleLayerChange(level)}
|
| 70 |
+
className={`
|
| 71 |
+
flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-all duration-300
|
| 72 |
+
${isActive
|
| 73 |
+
? `bg-${info.color}-100 dark:bg-${info.color}-900/30 text-${info.color}-700 dark:text-${info.color}-300 ring-2 ring-${info.color}-500/30 shadow-sm`
|
| 74 |
+
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
| 75 |
+
}
|
| 76 |
+
`}
|
| 77 |
+
title={info.description}
|
| 78 |
+
>
|
| 79 |
+
<Icon size={14} />
|
| 80 |
+
<span>{info.name}</span>
|
| 81 |
+
{isActive && level < 3 && (
|
| 82 |
+
<span className="text-[10px] opacity-60 ml-1">
|
| 83 |
+
Click {level + 1} for more
|
| 84 |
+
</span>
|
| 85 |
+
)}
|
| 86 |
+
</button>
|
| 87 |
+
);
|
| 88 |
+
})}
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
{/* Content Display */}
|
| 92 |
+
<div className={`transition-all duration-300 ${isAnimating ? 'opacity-0 scale-98' : 'opacity-100 scale-100'}`}>
|
| 93 |
+
|
| 94 |
+
{/* Layer 1: Thesis */}
|
| 95 |
+
{expandedLayer >= 1 && (
|
| 96 |
+
<div className="mb-6">
|
| 97 |
+
<div className={`
|
| 98 |
+
relative p-6 rounded-2xl border-2 transition-all duration-500
|
| 99 |
+
${expandedLayer === 1
|
| 100 |
+
? 'bg-brand-50 dark:bg-brand-900/20 border-brand-200 dark:border-brand-800 shadow-lg shadow-brand-500/10'
|
| 101 |
+
: 'bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'
|
| 102 |
+
}
|
| 103 |
+
`}>
|
| 104 |
+
<div className="flex items-start gap-4">
|
| 105 |
+
<div className={`
|
| 106 |
+
flex-shrink-0 p-2 rounded-xl
|
| 107 |
+
${expandedLayer === 1
|
| 108 |
+
? 'bg-brand-500 text-white'
|
| 109 |
+
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
|
| 110 |
+
}
|
| 111 |
+
`}>
|
| 112 |
+
<Zap size={20} />
|
| 113 |
+
</div>
|
| 114 |
+
<div className="flex-1">
|
| 115 |
+
<div className="flex items-center gap-2 mb-2">
|
| 116 |
+
<span className="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
| 117 |
+
Core Thesis
|
| 118 |
+
</span>
|
| 119 |
+
{showConfidenceBadges && (
|
| 120 |
+
<ConfidenceBadge level="synthesis" />
|
| 121 |
+
)}
|
| 122 |
+
</div>
|
| 123 |
+
<p className={`
|
| 124 |
+
text-lg md:text-xl font-medium leading-relaxed
|
| 125 |
+
${expandedLayer === 1
|
| 126 |
+
? 'text-gray-900 dark:text-white'
|
| 127 |
+
: 'text-gray-600 dark:text-gray-400'
|
| 128 |
+
}
|
| 129 |
+
`}>
|
| 130 |
+
{layers.thesis}
|
| 131 |
+
</p>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
{expandedLayer === 1 && (
|
| 136 |
+
<button
|
| 137 |
+
onClick={() => handleLayerChange(2)}
|
| 138 |
+
className="absolute -bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1 px-3 py-1 rounded-full bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 text-xs font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors shadow-sm"
|
| 139 |
+
>
|
| 140 |
+
<span>Want more detail?</span>
|
| 141 |
+
<ChevronDown size={14} />
|
| 142 |
+
</button>
|
| 143 |
+
)}
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
)}
|
| 147 |
+
|
| 148 |
+
{/* Layer 2: Key Takeaways */}
|
| 149 |
+
{expandedLayer >= 2 && (
|
| 150 |
+
<div className={`mb-6 transition-all duration-500 ${expandedLayer >= 2 ? 'animate-in fade-in slide-in-from-top-4' : ''}`}>
|
| 151 |
+
<div className={`
|
| 152 |
+
relative p-6 rounded-2xl border-2 transition-all duration-500
|
| 153 |
+
${expandedLayer === 2
|
| 154 |
+
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800 shadow-lg shadow-purple-500/10'
|
| 155 |
+
: 'bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'
|
| 156 |
+
}
|
| 157 |
+
`}>
|
| 158 |
+
<div className="flex items-start gap-4">
|
| 159 |
+
<div className={`
|
| 160 |
+
flex-shrink-0 p-2 rounded-xl
|
| 161 |
+
${expandedLayer === 2
|
| 162 |
+
? 'bg-purple-500 text-white'
|
| 163 |
+
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
|
| 164 |
+
}
|
| 165 |
+
`}>
|
| 166 |
+
<List size={20} />
|
| 167 |
+
</div>
|
| 168 |
+
<div className="flex-1">
|
| 169 |
+
<div className="flex items-center gap-2 mb-3">
|
| 170 |
+
<span className="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
| 171 |
+
Key Takeaways
|
| 172 |
+
</span>
|
| 173 |
+
</div>
|
| 174 |
+
<ul className="space-y-3">
|
| 175 |
+
{layers.takeaways.map((takeaway, idx) => (
|
| 176 |
+
<li
|
| 177 |
+
key={idx}
|
| 178 |
+
className={`
|
| 179 |
+
flex items-start gap-3 animate-in fade-in slide-in-from-left-2
|
| 180 |
+
${expandedLayer === 2
|
| 181 |
+
? 'text-gray-800 dark:text-gray-200'
|
| 182 |
+
: 'text-gray-600 dark:text-gray-400'
|
| 183 |
+
}
|
| 184 |
+
`}
|
| 185 |
+
style={{ animationDelay: `${idx * 100}ms` }}
|
| 186 |
+
>
|
| 187 |
+
<span className={`
|
| 188 |
+
flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold mt-0.5
|
| 189 |
+
${expandedLayer === 2
|
| 190 |
+
? 'bg-purple-200 dark:bg-purple-800 text-purple-700 dark:text-purple-300'
|
| 191 |
+
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
|
| 192 |
+
}
|
| 193 |
+
`}>
|
| 194 |
+
{idx + 1}
|
| 195 |
+
</span>
|
| 196 |
+
<span className="font-medium">{takeaway}</span>
|
| 197 |
+
</li>
|
| 198 |
+
))}
|
| 199 |
+
</ul>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
{expandedLayer === 2 && (
|
| 204 |
+
<button
|
| 205 |
+
onClick={() => handleLayerChange(3)}
|
| 206 |
+
className="absolute -bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1 px-3 py-1 rounded-full bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 text-xs font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors shadow-sm"
|
| 207 |
+
>
|
| 208 |
+
<span>Read full details</span>
|
| 209 |
+
<ChevronDown size={14} />
|
| 210 |
+
</button>
|
| 211 |
+
)}
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
)}
|
| 215 |
+
|
| 216 |
+
{/* Layer 3: Full Details */}
|
| 217 |
+
{expandedLayer === 3 && (
|
| 218 |
+
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
| 219 |
+
<div className="relative p-6 rounded-2xl bg-emerald-50 dark:bg-emerald-900/20 border-2 border-emerald-200 dark:border-emerald-800">
|
| 220 |
+
<div className="flex items-center gap-2 mb-4">
|
| 221 |
+
<div className="p-2 rounded-xl bg-emerald-500 text-white">
|
| 222 |
+
<FileText size={20} />
|
| 223 |
+
</div>
|
| 224 |
+
<span className="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
| 225 |
+
Full Explanation
|
| 226 |
+
</span>
|
| 227 |
+
{eli5Mode && layers.eli5Content && (
|
| 228 |
+
<span className="ml-2 px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 text-xs font-medium">
|
| 229 |
+
Simple Mode
|
| 230 |
+
</span>
|
| 231 |
+
)}
|
| 232 |
+
</div>
|
| 233 |
+
|
| 234 |
+
<div className="prose prose-lg dark:prose-invert max-w-none">
|
| 235 |
+
<ReactMarkdown>{content}</ReactMarkdown>
|
| 236 |
+
</div>
|
| 237 |
+
|
| 238 |
+
<button
|
| 239 |
+
onClick={() => handleLayerChange(1)}
|
| 240 |
+
className="mt-6 flex items-center gap-1 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
| 241 |
+
>
|
| 242 |
+
<ChevronUp size={14} />
|
| 243 |
+
<span>Collapse to summary</span>
|
| 244 |
+
</button>
|
| 245 |
+
</div>
|
| 246 |
+
</div>
|
| 247 |
+
)}
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
);
|
| 251 |
+
};
|
| 252 |
+
|
| 253 |
+
// Confidence Badge Component
|
| 254 |
+
const ConfidenceBadge: React.FC<{ level: ConfidenceLevel }> = ({ level }) => {
|
| 255 |
+
const config = {
|
| 256 |
+
'direct-quote': {
|
| 257 |
+
label: 'Direct Quote',
|
| 258 |
+
color: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800',
|
| 259 |
+
icon: Quote
|
| 260 |
+
},
|
| 261 |
+
'paraphrase': {
|
| 262 |
+
label: 'Paraphrased',
|
| 263 |
+
color: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800',
|
| 264 |
+
icon: Sparkles
|
| 265 |
+
},
|
| 266 |
+
'synthesis': {
|
| 267 |
+
label: 'Synthesized',
|
| 268 |
+
color: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-800',
|
| 269 |
+
icon: Layers
|
| 270 |
+
},
|
| 271 |
+
'interpretation': {
|
| 272 |
+
label: 'AI Interpretation',
|
| 273 |
+
color: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800',
|
| 274 |
+
icon: Sparkles
|
| 275 |
+
}
|
| 276 |
+
};
|
| 277 |
+
|
| 278 |
+
const { label, color, icon: Icon } = config[level];
|
| 279 |
+
|
| 280 |
+
return (
|
| 281 |
+
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border ${color}`}>
|
| 282 |
+
<Icon size={10} />
|
| 283 |
+
{label}
|
| 284 |
+
</span>
|
| 285 |
+
);
|
| 286 |
+
};
|
| 287 |
+
|
| 288 |
+
export { ConfidenceBadge };
|
| 289 |
+
export default ProgressiveContent;
|
components/ReadingToolbar.tsx
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
BookOpen,
|
| 4 |
+
Sparkles,
|
| 5 |
+
Type,
|
| 6 |
+
Volume2,
|
| 7 |
+
VolumeX,
|
| 8 |
+
Settings,
|
| 9 |
+
X,
|
| 10 |
+
Zap,
|
| 11 |
+
GraduationCap,
|
| 12 |
+
Eye,
|
| 13 |
+
Lightbulb
|
| 14 |
+
} from 'lucide-react';
|
| 15 |
+
import { ReadingPreferences } from '../types';
|
| 16 |
+
|
| 17 |
+
interface Props {
|
| 18 |
+
preferences: ReadingPreferences;
|
| 19 |
+
onPreferencesChange: (preferences: ReadingPreferences) => void;
|
| 20 |
+
compact?: boolean;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const ReadingToolbar: React.FC<Props> = ({
|
| 24 |
+
preferences,
|
| 25 |
+
onPreferencesChange,
|
| 26 |
+
compact = false
|
| 27 |
+
}) => {
|
| 28 |
+
const [showPanel, setShowPanel] = useState(false);
|
| 29 |
+
|
| 30 |
+
const togglePreference = (key: keyof ReadingPreferences) => {
|
| 31 |
+
if (typeof preferences[key] === 'boolean') {
|
| 32 |
+
onPreferencesChange({
|
| 33 |
+
...preferences,
|
| 34 |
+
[key]: !preferences[key]
|
| 35 |
+
});
|
| 36 |
+
}
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const handleSpeedChange = (speed: number) => {
|
| 40 |
+
onPreferencesChange({
|
| 41 |
+
...preferences,
|
| 42 |
+
ttsSpeed: speed
|
| 43 |
+
});
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
if (compact) {
|
| 47 |
+
return (
|
| 48 |
+
<div className="flex items-center gap-1">
|
| 49 |
+
{/* ELI5 Toggle */}
|
| 50 |
+
<button
|
| 51 |
+
onClick={() => togglePreference('eli5Mode')}
|
| 52 |
+
className={`
|
| 53 |
+
p-2 rounded-lg transition-all duration-200
|
| 54 |
+
${preferences.eli5Mode
|
| 55 |
+
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 ring-2 ring-amber-500/30'
|
| 56 |
+
: 'bg-gray-100 dark:bg-gray-800 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
|
| 57 |
+
}
|
| 58 |
+
`}
|
| 59 |
+
title={preferences.eli5Mode ? 'Switch to Academic mode' : 'Switch to Simple mode'}
|
| 60 |
+
>
|
| 61 |
+
{preferences.eli5Mode ? <Lightbulb size={18} /> : <GraduationCap size={18} />}
|
| 62 |
+
</button>
|
| 63 |
+
|
| 64 |
+
{/* Bionic Reading Toggle */}
|
| 65 |
+
<button
|
| 66 |
+
onClick={() => togglePreference('bionicReading')}
|
| 67 |
+
className={`
|
| 68 |
+
p-2 rounded-lg transition-all duration-200
|
| 69 |
+
${preferences.bionicReading
|
| 70 |
+
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 ring-2 ring-emerald-500/30'
|
| 71 |
+
: 'bg-gray-100 dark:bg-gray-800 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
|
| 72 |
+
}
|
| 73 |
+
`}
|
| 74 |
+
title={preferences.bionicReading ? 'Disable Bionic Reading' : 'Enable Bionic Reading'}
|
| 75 |
+
>
|
| 76 |
+
<Type size={18} />
|
| 77 |
+
</button>
|
| 78 |
+
|
| 79 |
+
{/* TTS Toggle */}
|
| 80 |
+
<button
|
| 81 |
+
onClick={() => togglePreference('ttsEnabled')}
|
| 82 |
+
className={`
|
| 83 |
+
p-2 rounded-lg transition-all duration-200
|
| 84 |
+
${preferences.ttsEnabled
|
| 85 |
+
? 'bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 ring-2 ring-brand-500/30'
|
| 86 |
+
: 'bg-gray-100 dark:bg-gray-800 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
|
| 87 |
+
}
|
| 88 |
+
`}
|
| 89 |
+
title={preferences.ttsEnabled ? 'Hide audio controls' : 'Show audio controls'}
|
| 90 |
+
>
|
| 91 |
+
{preferences.ttsEnabled ? <Volume2 size={18} /> : <VolumeX size={18} />}
|
| 92 |
+
</button>
|
| 93 |
+
|
| 94 |
+
{/* Settings */}
|
| 95 |
+
<button
|
| 96 |
+
onClick={() => setShowPanel(!showPanel)}
|
| 97 |
+
className={`
|
| 98 |
+
p-2 rounded-lg transition-all duration-200
|
| 99 |
+
${showPanel
|
| 100 |
+
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400'
|
| 101 |
+
: 'bg-gray-100 dark:bg-gray-800 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
|
| 102 |
+
}
|
| 103 |
+
`}
|
| 104 |
+
>
|
| 105 |
+
<Settings size={18} />
|
| 106 |
+
</button>
|
| 107 |
+
</div>
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
return (
|
| 112 |
+
<div className="relative">
|
| 113 |
+
{/* Main Toolbar */}
|
| 114 |
+
<div className="flex items-center gap-3 p-3 rounded-2xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg">
|
| 115 |
+
<div className="flex items-center gap-2 pr-3 border-r border-gray-200 dark:border-gray-700">
|
| 116 |
+
<Eye size={16} className="text-gray-400" />
|
| 117 |
+
<span className="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
| 118 |
+
Reading Mode
|
| 119 |
+
</span>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
{/* ELI5 Toggle Button */}
|
| 123 |
+
<button
|
| 124 |
+
onClick={() => togglePreference('eli5Mode')}
|
| 125 |
+
className={`
|
| 126 |
+
flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-300
|
| 127 |
+
${preferences.eli5Mode
|
| 128 |
+
? 'bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-lg shadow-amber-500/30'
|
| 129 |
+
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
| 130 |
+
}
|
| 131 |
+
`}
|
| 132 |
+
>
|
| 133 |
+
{preferences.eli5Mode ? (
|
| 134 |
+
<>
|
| 135 |
+
<Lightbulb size={16} />
|
| 136 |
+
<span>Simple</span>
|
| 137 |
+
</>
|
| 138 |
+
) : (
|
| 139 |
+
<>
|
| 140 |
+
<GraduationCap size={16} />
|
| 141 |
+
<span>Academic</span>
|
| 142 |
+
</>
|
| 143 |
+
)}
|
| 144 |
+
</button>
|
| 145 |
+
|
| 146 |
+
{/* Bionic Reading Toggle */}
|
| 147 |
+
<button
|
| 148 |
+
onClick={() => togglePreference('bionicReading')}
|
| 149 |
+
className={`
|
| 150 |
+
flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-300
|
| 151 |
+
${preferences.bionicReading
|
| 152 |
+
? 'bg-gradient-to-r from-emerald-500 to-teal-500 text-white shadow-lg shadow-emerald-500/30'
|
| 153 |
+
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
| 154 |
+
}
|
| 155 |
+
`}
|
| 156 |
+
>
|
| 157 |
+
<Type size={16} />
|
| 158 |
+
<span className={preferences.bionicReading ? '' : 'hidden sm:inline'}>
|
| 159 |
+
{preferences.bionicReading ? 'Bio**nic** On' : 'Bionic'}
|
| 160 |
+
</span>
|
| 161 |
+
</button>
|
| 162 |
+
|
| 163 |
+
{/* Key Terms Highlight Toggle */}
|
| 164 |
+
<button
|
| 165 |
+
onClick={() => togglePreference('highlightKeyTerms')}
|
| 166 |
+
className={`
|
| 167 |
+
flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-300
|
| 168 |
+
${preferences.highlightKeyTerms
|
| 169 |
+
? 'bg-gradient-to-r from-purple-500 to-indigo-500 text-white shadow-lg shadow-purple-500/30'
|
| 170 |
+
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
| 171 |
+
}
|
| 172 |
+
`}
|
| 173 |
+
>
|
| 174 |
+
<Sparkles size={16} />
|
| 175 |
+
<span className="hidden sm:inline">Highlight</span>
|
| 176 |
+
</button>
|
| 177 |
+
|
| 178 |
+
{/* TTS Toggle */}
|
| 179 |
+
<button
|
| 180 |
+
onClick={() => togglePreference('ttsEnabled')}
|
| 181 |
+
className={`
|
| 182 |
+
flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-300
|
| 183 |
+
${preferences.ttsEnabled
|
| 184 |
+
? 'bg-gradient-to-r from-brand-500 to-blue-500 text-white shadow-lg shadow-brand-500/30'
|
| 185 |
+
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
| 186 |
+
}
|
| 187 |
+
`}
|
| 188 |
+
>
|
| 189 |
+
{preferences.ttsEnabled ? <Volume2 size={16} /> : <VolumeX size={16} />}
|
| 190 |
+
<span className="hidden sm:inline">Audio</span>
|
| 191 |
+
</button>
|
| 192 |
+
|
| 193 |
+
{/* Settings */}
|
| 194 |
+
<button
|
| 195 |
+
onClick={() => setShowPanel(!showPanel)}
|
| 196 |
+
className={`
|
| 197 |
+
p-2 rounded-xl transition-all duration-200 ml-auto
|
| 198 |
+
${showPanel
|
| 199 |
+
? 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200'
|
| 200 |
+
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
| 201 |
+
}
|
| 202 |
+
`}
|
| 203 |
+
>
|
| 204 |
+
<Settings size={18} />
|
| 205 |
+
</button>
|
| 206 |
+
</div>
|
| 207 |
+
|
| 208 |
+
{/* Settings Panel */}
|
| 209 |
+
{showPanel && (
|
| 210 |
+
<div className="absolute top-full left-0 right-0 mt-2 p-4 rounded-2xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-xl z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
| 211 |
+
<div className="flex items-center justify-between mb-4">
|
| 212 |
+
<h4 className="text-sm font-bold text-gray-900 dark:text-white">Reading Preferences</h4>
|
| 213 |
+
<button
|
| 214 |
+
onClick={() => setShowPanel(false)}
|
| 215 |
+
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
| 216 |
+
>
|
| 217 |
+
<X size={14} />
|
| 218 |
+
</button>
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
<div className="space-y-6">
|
| 222 |
+
{/* Language Mode Section */}
|
| 223 |
+
<div>
|
| 224 |
+
<label className="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3 block">
|
| 225 |
+
Language Complexity
|
| 226 |
+
</label>
|
| 227 |
+
<div className="grid grid-cols-2 gap-2">
|
| 228 |
+
<button
|
| 229 |
+
onClick={() => onPreferencesChange({ ...preferences, eli5Mode: true })}
|
| 230 |
+
className={`
|
| 231 |
+
p-3 rounded-xl border-2 transition-all
|
| 232 |
+
${preferences.eli5Mode
|
| 233 |
+
? 'border-amber-500 bg-amber-50 dark:bg-amber-900/20'
|
| 234 |
+
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
| 235 |
+
}
|
| 236 |
+
`}
|
| 237 |
+
>
|
| 238 |
+
<div className="flex items-center gap-2 mb-1">
|
| 239 |
+
<Lightbulb size={16} className={preferences.eli5Mode ? 'text-amber-500' : 'text-gray-400'} />
|
| 240 |
+
<span className={`font-medium text-sm ${preferences.eli5Mode ? 'text-amber-700 dark:text-amber-300' : 'text-gray-600 dark:text-gray-400'}`}>
|
| 241 |
+
Simple
|
| 242 |
+
</span>
|
| 243 |
+
</div>
|
| 244 |
+
<p className="text-xs text-gray-500 dark:text-gray-400">
|
| 245 |
+
Everyday language with analogies
|
| 246 |
+
</p>
|
| 247 |
+
</button>
|
| 248 |
+
<button
|
| 249 |
+
onClick={() => onPreferencesChange({ ...preferences, eli5Mode: false })}
|
| 250 |
+
className={`
|
| 251 |
+
p-3 rounded-xl border-2 transition-all
|
| 252 |
+
${!preferences.eli5Mode
|
| 253 |
+
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
| 254 |
+
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
| 255 |
+
}
|
| 256 |
+
`}
|
| 257 |
+
>
|
| 258 |
+
<div className="flex items-center gap-2 mb-1">
|
| 259 |
+
<GraduationCap size={16} className={!preferences.eli5Mode ? 'text-brand-500' : 'text-gray-400'} />
|
| 260 |
+
<span className={`font-medium text-sm ${!preferences.eli5Mode ? 'text-brand-700 dark:text-brand-300' : 'text-gray-600 dark:text-gray-400'}`}>
|
| 261 |
+
Academic
|
| 262 |
+
</span>
|
| 263 |
+
</div>
|
| 264 |
+
<p className="text-xs text-gray-500 dark:text-gray-400">
|
| 265 |
+
Technical terminology
|
| 266 |
+
</p>
|
| 267 |
+
</button>
|
| 268 |
+
</div>
|
| 269 |
+
</div>
|
| 270 |
+
|
| 271 |
+
{/* Typography Section */}
|
| 272 |
+
<div>
|
| 273 |
+
<label className="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3 block">
|
| 274 |
+
Typography Aids
|
| 275 |
+
</label>
|
| 276 |
+
<div className="space-y-2">
|
| 277 |
+
<label className="flex items-center justify-between p-3 rounded-xl bg-gray-50 dark:bg-gray-700/50 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
| 278 |
+
<div className="flex items-center gap-3">
|
| 279 |
+
<Type size={18} className={preferences.bionicReading ? 'text-emerald-500' : 'text-gray-400'} />
|
| 280 |
+
<div>
|
| 281 |
+
<span className="font-medium text-sm text-gray-700 dark:text-gray-300">Bionic Reading</span>
|
| 282 |
+
<p className="text-xs text-gray-500">Bo**ld** fi**rst** le**tters**</p>
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
<input
|
| 286 |
+
type="checkbox"
|
| 287 |
+
checked={preferences.bionicReading}
|
| 288 |
+
onChange={() => togglePreference('bionicReading')}
|
| 289 |
+
className="w-5 h-5 rounded border-gray-300 text-emerald-500 focus:ring-emerald-500"
|
| 290 |
+
/>
|
| 291 |
+
</label>
|
| 292 |
+
|
| 293 |
+
<label className="flex items-center justify-between p-3 rounded-xl bg-gray-50 dark:bg-gray-700/50 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
| 294 |
+
<div className="flex items-center gap-3">
|
| 295 |
+
<Sparkles size={18} className={preferences.highlightKeyTerms ? 'text-purple-500' : 'text-gray-400'} />
|
| 296 |
+
<div>
|
| 297 |
+
<span className="font-medium text-sm text-gray-700 dark:text-gray-300">Highlight Key Terms</span>
|
| 298 |
+
<p className="text-xs text-gray-500">Color-code technical terms</p>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
<input
|
| 302 |
+
type="checkbox"
|
| 303 |
+
checked={preferences.highlightKeyTerms}
|
| 304 |
+
onChange={() => togglePreference('highlightKeyTerms')}
|
| 305 |
+
className="w-5 h-5 rounded border-gray-300 text-purple-500 focus:ring-purple-500"
|
| 306 |
+
/>
|
| 307 |
+
</label>
|
| 308 |
+
</div>
|
| 309 |
+
</div>
|
| 310 |
+
|
| 311 |
+
{/* Audio Section */}
|
| 312 |
+
<div>
|
| 313 |
+
<label className="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3 block">
|
| 314 |
+
Audio Playback
|
| 315 |
+
</label>
|
| 316 |
+
<div className="space-y-3">
|
| 317 |
+
<label className="flex items-center justify-between p-3 rounded-xl bg-gray-50 dark:bg-gray-700/50 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
| 318 |
+
<div className="flex items-center gap-3">
|
| 319 |
+
<Volume2 size={18} className={preferences.ttsEnabled ? 'text-brand-500' : 'text-gray-400'} />
|
| 320 |
+
<div>
|
| 321 |
+
<span className="font-medium text-sm text-gray-700 dark:text-gray-300">Text-to-Speech</span>
|
| 322 |
+
<p className="text-xs text-gray-500">Listen while reading</p>
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
<input
|
| 326 |
+
type="checkbox"
|
| 327 |
+
checked={preferences.ttsEnabled}
|
| 328 |
+
onChange={() => togglePreference('ttsEnabled')}
|
| 329 |
+
className="w-5 h-5 rounded border-gray-300 text-brand-500 focus:ring-brand-500"
|
| 330 |
+
/>
|
| 331 |
+
</label>
|
| 332 |
+
|
| 333 |
+
{preferences.ttsEnabled && (
|
| 334 |
+
<div className="px-3">
|
| 335 |
+
<div className="flex items-center justify-between text-xs text-gray-500 mb-2">
|
| 336 |
+
<span>Speech Speed</span>
|
| 337 |
+
<span className="font-mono">{preferences.ttsSpeed.toFixed(1)}x</span>
|
| 338 |
+
</div>
|
| 339 |
+
<input
|
| 340 |
+
type="range"
|
| 341 |
+
min="0.5"
|
| 342 |
+
max="2"
|
| 343 |
+
step="0.1"
|
| 344 |
+
value={preferences.ttsSpeed}
|
| 345 |
+
onChange={(e) => handleSpeedChange(parseFloat(e.target.value))}
|
| 346 |
+
className="w-full h-2 bg-gray-200 dark:bg-gray-600 rounded-lg appearance-none cursor-pointer accent-brand-500"
|
| 347 |
+
/>
|
| 348 |
+
<div className="flex justify-between text-[10px] text-gray-400 mt-1">
|
| 349 |
+
<span>Slow</span>
|
| 350 |
+
<span>Normal</span>
|
| 351 |
+
<span>Fast</span>
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
)}
|
| 355 |
+
</div>
|
| 356 |
+
</div>
|
| 357 |
+
</div>
|
| 358 |
+
</div>
|
| 359 |
+
)}
|
| 360 |
+
</div>
|
| 361 |
+
);
|
| 362 |
+
};
|
| 363 |
+
|
| 364 |
+
export default ReadingToolbar;
|
components/Sidebar.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
-
import React, { useState } from 'react';
|
| 2 |
-
import { BookOpen, ChevronRight, Loader2, CheckCircle } from 'lucide-react';
|
| 3 |
import { BlogSection } from '../types';
|
| 4 |
|
| 5 |
interface Props {
|
|
@@ -10,6 +10,24 @@ interface Props {
|
|
| 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);
|
|
@@ -167,39 +185,110 @@ const Sidebar: React.FC<Props> = ({ sections, activeSection, onSectionClick }) =
|
|
| 167 |
</span>
|
| 168 |
</div>
|
| 169 |
<div className="h-1.5 bg-brand-100 dark:bg-brand-900/30 rounded-full overflow-hidden">
|
| 170 |
-
<div
|
| 171 |
-
className="h-full bg-gradient-to-r from-brand-500 to-purple-500 rounded-full transition-all duration-500"
|
| 172 |
-
style={{
|
| 173 |
-
width: `${(sections.filter(s => !s.isLoading).length / sections.length) * 100}%`
|
| 174 |
-
}}
|
| 175 |
-
/>
|
| 176 |
-
</div>
|
| 177 |
-
</div>
|
| 178 |
-
)}
|
| 179 |
-
|
| 180 |
-
{/* Reading Progress */}
|
| 181 |
-
<div>
|
| 182 |
-
<div className="flex items-center justify-between text-xs text-gray-500 mb-2">
|
| 183 |
-
<span>Reading</span>
|
| 184 |
-
<span>
|
| 185 |
-
{sections.findIndex(s => s.id === activeSection) + 1}/{sections.length}
|
| 186 |
-
</span>
|
| 187 |
-
</div>
|
| 188 |
-
<div className="h-1.5 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
| 189 |
<div
|
| 190 |
-
className="h-full bg-gradient-to-r from-
|
| 191 |
style={{
|
| 192 |
-
width: `${(
|
| 193 |
}}
|
| 194 |
/>
|
| 195 |
</div>
|
| 196 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
</div>
|
| 198 |
-
)}
|
| 199 |
-
</div>
|
| 200 |
-
</nav>
|
| 201 |
-
);
|
| 202 |
-
};
|
| 203 |
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
|
|
|
|
|
|
| 1 |
+
import React, { useState, useMemo } from 'react';
|
| 2 |
+
import { BookOpen, ChevronRight, Loader2, CheckCircle, Map, Clock, Eye, Target } from 'lucide-react';
|
| 3 |
import { BlogSection } from '../types';
|
| 4 |
|
| 5 |
interface Props {
|
|
|
|
| 10 |
|
| 11 |
const Sidebar: React.FC<Props> = ({ sections, activeSection, onSectionClick }) => {
|
| 12 |
const [isCollapsed, setIsCollapsed] = useState(false);
|
| 13 |
+
const [showMinimap, setShowMinimap] = useState(true);
|
| 14 |
+
|
| 15 |
+
// Calculate reading progress
|
| 16 |
+
const readingStats = useMemo(() => {
|
| 17 |
+
const activeIndex = sections.findIndex(s => s.id === activeSection);
|
| 18 |
+
const completedCount = sections.filter(s => !s.isLoading && !s.error && s.content).length;
|
| 19 |
+
const totalWords = sections.reduce((acc, s) => acc + (s.content?.split(/\s+/).length || 0), 0);
|
| 20 |
+
const readingTimeMin = Math.max(1, Math.ceil(totalWords / 200));
|
| 21 |
+
const progressPercent = activeIndex >= 0 ? ((activeIndex + 1) / sections.length) * 100 : 0;
|
| 22 |
+
|
| 23 |
+
return {
|
| 24 |
+
activeIndex,
|
| 25 |
+
completedCount,
|
| 26 |
+
totalWords,
|
| 27 |
+
readingTimeMin,
|
| 28 |
+
progressPercent
|
| 29 |
+
};
|
| 30 |
+
}, [sections, activeSection]);
|
| 31 |
|
| 32 |
const handleClick = (id: string) => {
|
| 33 |
onSectionClick(id);
|
|
|
|
| 185 |
</span>
|
| 186 |
</div>
|
| 187 |
<div className="h-1.5 bg-brand-100 dark:bg-brand-900/30 rounded-full overflow-hidden">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
<div
|
| 189 |
+
className="h-full bg-gradient-to-r from-brand-500 to-purple-500 rounded-full transition-all duration-500"
|
| 190 |
style={{
|
| 191 |
+
width: `${(sections.filter(s => !s.isLoading).length / sections.length) * 100}%`
|
| 192 |
}}
|
| 193 |
/>
|
| 194 |
</div>
|
| 195 |
</div>
|
| 196 |
+
)}
|
| 197 |
+
|
| 198 |
+
{/* Reading Progress */}
|
| 199 |
+
<div>
|
| 200 |
+
<div className="flex items-center justify-between text-xs text-gray-500 mb-2">
|
| 201 |
+
<span className="flex items-center gap-1">
|
| 202 |
+
<Eye size={10} />
|
| 203 |
+
Reading
|
| 204 |
+
</span>
|
| 205 |
+
<span>
|
| 206 |
+
{sections.findIndex(s => s.id === activeSection) + 1}/{sections.length}
|
| 207 |
+
</span>
|
| 208 |
+
</div>
|
| 209 |
+
<div className="h-1.5 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
| 210 |
+
<div
|
| 211 |
+
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"
|
| 212 |
+
style={{
|
| 213 |
+
width: `${((sections.findIndex(s => s.id === activeSection) + 1) / sections.length) * 100}%`
|
| 214 |
+
}}
|
| 215 |
+
/>
|
| 216 |
+
</div>
|
| 217 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
+
{/* Mini-map Toggle */}
|
| 220 |
+
<button
|
| 221 |
+
onClick={() => setShowMinimap(!showMinimap)}
|
| 222 |
+
className={`
|
| 223 |
+
w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs font-medium transition-colors
|
| 224 |
+
${showMinimap
|
| 225 |
+
? 'bg-brand-50 dark:bg-brand-900/20 text-brand-600 dark:text-brand-400'
|
| 226 |
+
: 'bg-gray-100 dark:bg-gray-800 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
|
| 227 |
+
}
|
| 228 |
+
`}
|
| 229 |
+
>
|
| 230 |
+
<span className="flex items-center gap-1.5">
|
| 231 |
+
<Map size={12} />
|
| 232 |
+
Mini-map
|
| 233 |
+
</span>
|
| 234 |
+
<span className="text-[10px] opacity-60">
|
| 235 |
+
{showMinimap ? 'On' : 'Off'}
|
| 236 |
+
</span>
|
| 237 |
+
</button>
|
| 238 |
+
|
| 239 |
+
{/* Visual Mini-map */}
|
| 240 |
+
{showMinimap && (
|
| 241 |
+
<div className="p-2 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800">
|
| 242 |
+
<div className="flex gap-1 flex-wrap">
|
| 243 |
+
{sections.map((section, index) => {
|
| 244 |
+
const isActive = activeSection === section.id;
|
| 245 |
+
const isPast = sections.findIndex(s => s.id === activeSection) > index;
|
| 246 |
+
const isLoading = section.isLoading;
|
| 247 |
+
const hasError = section.error;
|
| 248 |
+
|
| 249 |
+
return (
|
| 250 |
+
<button
|
| 251 |
+
key={section.id}
|
| 252 |
+
onClick={() => handleClick(section.id)}
|
| 253 |
+
disabled={isLoading}
|
| 254 |
+
className={`
|
| 255 |
+
w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-bold transition-all
|
| 256 |
+
${isActive
|
| 257 |
+
? 'bg-brand-500 text-white ring-2 ring-brand-500/30 scale-110'
|
| 258 |
+
: isPast
|
| 259 |
+
? 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400'
|
| 260 |
+
: isLoading
|
| 261 |
+
? 'bg-gray-100 dark:bg-gray-800 text-gray-400 animate-pulse'
|
| 262 |
+
: hasError
|
| 263 |
+
? 'bg-red-100 dark:bg-red-900/30 text-red-500'
|
| 264 |
+
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
| 265 |
+
}
|
| 266 |
+
`}
|
| 267 |
+
title={section.title}
|
| 268 |
+
>
|
| 269 |
+
{isActive ? <Target size={10} /> : index + 1}
|
| 270 |
+
</button>
|
| 271 |
+
);
|
| 272 |
+
})}
|
| 273 |
+
</div>
|
| 274 |
+
<p className="text-[10px] text-gray-400 text-center mt-2">
|
| 275 |
+
You are here: Section {readingStats.activeIndex + 1}
|
| 276 |
+
</p>
|
| 277 |
+
</div>
|
| 278 |
+
)}
|
| 279 |
+
|
| 280 |
+
{/* Estimated Time Remaining */}
|
| 281 |
+
<div className="flex items-center justify-center gap-2 text-[10px] text-gray-400 py-1">
|
| 282 |
+
<Clock size={10} />
|
| 283 |
+
<span>
|
| 284 |
+
~{Math.max(1, readingStats.readingTimeMin - Math.ceil(readingStats.readingTimeMin * (readingStats.progressPercent / 100)))} min remaining
|
| 285 |
+
</span>
|
| 286 |
+
</div>
|
| 287 |
+
</div>
|
| 288 |
+
)}
|
| 289 |
+
</div>
|
| 290 |
+
</nav>
|
| 291 |
+
);
|
| 292 |
+
};
|
| 293 |
|
| 294 |
+
export default Sidebar;
|
components/SourceAnchor.tsx
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { ExternalLink, Quote, X, CheckCircle, AlertTriangle, HelpCircle, FileText } from 'lucide-react';
|
| 3 |
+
import { SourceAnchor as SourceAnchorType, ConfidenceLevel } from '../types';
|
| 4 |
+
|
| 5 |
+
interface Props {
|
| 6 |
+
anchor: SourceAnchorType;
|
| 7 |
+
children: React.ReactNode;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
const confidenceConfig: Record<ConfidenceLevel, {
|
| 11 |
+
label: string;
|
| 12 |
+
color: string;
|
| 13 |
+
bgColor: string;
|
| 14 |
+
borderColor: string;
|
| 15 |
+
icon: React.ReactNode;
|
| 16 |
+
description: string;
|
| 17 |
+
}> = {
|
| 18 |
+
'direct-quote': {
|
| 19 |
+
label: 'Direct Quote',
|
| 20 |
+
color: 'text-green-700 dark:text-green-300',
|
| 21 |
+
bgColor: 'bg-green-50 dark:bg-green-900/30',
|
| 22 |
+
borderColor: 'border-green-200 dark:border-green-800',
|
| 23 |
+
icon: <Quote size={12} />,
|
| 24 |
+
description: 'This is a direct quote from the paper'
|
| 25 |
+
},
|
| 26 |
+
'paraphrase': {
|
| 27 |
+
label: 'Paraphrased',
|
| 28 |
+
color: 'text-blue-700 dark:text-blue-300',
|
| 29 |
+
bgColor: 'bg-blue-50 dark:bg-blue-900/30',
|
| 30 |
+
borderColor: 'border-blue-200 dark:border-blue-800',
|
| 31 |
+
icon: <FileText size={12} />,
|
| 32 |
+
description: 'Closely paraphrased from source text'
|
| 33 |
+
},
|
| 34 |
+
'synthesis': {
|
| 35 |
+
label: 'Synthesized',
|
| 36 |
+
color: 'text-purple-700 dark:text-purple-300',
|
| 37 |
+
bgColor: 'bg-purple-50 dark:bg-purple-900/30',
|
| 38 |
+
borderColor: 'border-purple-200 dark:border-purple-800',
|
| 39 |
+
icon: <CheckCircle size={12} />,
|
| 40 |
+
description: 'Combined from multiple parts of the paper'
|
| 41 |
+
},
|
| 42 |
+
'interpretation': {
|
| 43 |
+
label: 'AI Analysis',
|
| 44 |
+
color: 'text-amber-700 dark:text-amber-300',
|
| 45 |
+
bgColor: 'bg-amber-50 dark:bg-amber-900/30',
|
| 46 |
+
borderColor: 'border-amber-200 dark:border-amber-800',
|
| 47 |
+
icon: <AlertTriangle size={12} />,
|
| 48 |
+
description: 'AI interpretation - verify with source'
|
| 49 |
+
}
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const SourceAnchorComponent: React.FC<Props> = ({ anchor, children }) => {
|
| 53 |
+
const [isOpen, setIsOpen] = useState(false);
|
| 54 |
+
const config = confidenceConfig[anchor.confidence];
|
| 55 |
+
|
| 56 |
+
return (
|
| 57 |
+
<span className="relative inline">
|
| 58 |
+
{/* Anchor trigger - the highlighted claim text */}
|
| 59 |
+
<span
|
| 60 |
+
onClick={() => setIsOpen(!isOpen)}
|
| 61 |
+
className={`
|
| 62 |
+
inline cursor-pointer border-b-2 border-dashed transition-all
|
| 63 |
+
hover:border-solid
|
| 64 |
+
${config.borderColor}
|
| 65 |
+
`}
|
| 66 |
+
style={{
|
| 67 |
+
borderBottomColor: anchor.highlightColor || undefined,
|
| 68 |
+
backgroundColor: isOpen ? `${anchor.highlightColor}15` : undefined
|
| 69 |
+
}}
|
| 70 |
+
>
|
| 71 |
+
{children}
|
| 72 |
+
<sup
|
| 73 |
+
className={`
|
| 74 |
+
ml-0.5 text-[10px] font-bold px-1 py-0.5 rounded
|
| 75 |
+
${config.bgColor} ${config.color}
|
| 76 |
+
`}
|
| 77 |
+
>
|
| 78 |
+
{anchor.pageNumber ? `p.${anchor.pageNumber}` : '↗'}
|
| 79 |
+
</sup>
|
| 80 |
+
</span>
|
| 81 |
+
|
| 82 |
+
{/* Popup with source text */}
|
| 83 |
+
{isOpen && (
|
| 84 |
+
<>
|
| 85 |
+
{/* Backdrop */}
|
| 86 |
+
<div
|
| 87 |
+
className="fixed inset-0 z-40"
|
| 88 |
+
onClick={() => setIsOpen(false)}
|
| 89 |
+
/>
|
| 90 |
+
|
| 91 |
+
{/* Popup */}
|
| 92 |
+
<div className={`
|
| 93 |
+
absolute z-50 left-0 top-full mt-2
|
| 94 |
+
w-80 sm:w-96
|
| 95 |
+
rounded-xl shadow-2xl border-2
|
| 96 |
+
${config.bgColor} ${config.borderColor}
|
| 97 |
+
animate-in fade-in zoom-in-95 slide-in-from-top-2 duration-200
|
| 98 |
+
`}>
|
| 99 |
+
{/* Header */}
|
| 100 |
+
<div className={`
|
| 101 |
+
flex items-center justify-between px-4 py-3 border-b ${config.borderColor}
|
| 102 |
+
`}>
|
| 103 |
+
<div className="flex items-center gap-2">
|
| 104 |
+
<span className={config.color}>{config.icon}</span>
|
| 105 |
+
<span className={`text-sm font-bold ${config.color}`}>
|
| 106 |
+
{config.label}
|
| 107 |
+
</span>
|
| 108 |
+
{anchor.pageNumber && (
|
| 109 |
+
<span className="text-xs text-gray-500 dark:text-gray-400">
|
| 110 |
+
Page {anchor.pageNumber}
|
| 111 |
+
</span>
|
| 112 |
+
)}
|
| 113 |
+
</div>
|
| 114 |
+
<button
|
| 115 |
+
onClick={() => setIsOpen(false)}
|
| 116 |
+
className="p-1 rounded-lg hover:bg-white/50 dark:hover:bg-black/20 transition-colors"
|
| 117 |
+
>
|
| 118 |
+
<X size={14} className="text-gray-500" />
|
| 119 |
+
</button>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
{/* Content */}
|
| 123 |
+
<div className="p-4 space-y-3">
|
| 124 |
+
{/* Original source text */}
|
| 125 |
+
<div>
|
| 126 |
+
<label className="text-[10px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1 block">
|
| 127 |
+
Original Text
|
| 128 |
+
</label>
|
| 129 |
+
<blockquote className="text-sm text-gray-700 dark:text-gray-300 bg-white/50 dark:bg-black/20 p-3 rounded-lg border-l-4 border-gray-300 dark:border-gray-600 italic">
|
| 130 |
+
"{anchor.sourceText}"
|
| 131 |
+
</blockquote>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
{/* Confidence explanation */}
|
| 135 |
+
<div className={`
|
| 136 |
+
flex items-start gap-2 p-2 rounded-lg
|
| 137 |
+
${anchor.confidence === 'interpretation'
|
| 138 |
+
? 'bg-amber-100/50 dark:bg-amber-900/20'
|
| 139 |
+
: 'bg-white/30 dark:bg-black/10'
|
| 140 |
+
}
|
| 141 |
+
`}>
|
| 142 |
+
<HelpCircle size={14} className="flex-shrink-0 mt-0.5 text-gray-400" />
|
| 143 |
+
<p className="text-xs text-gray-600 dark:text-gray-400">
|
| 144 |
+
{config.description}
|
| 145 |
+
</p>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
{/* Footer */}
|
| 150 |
+
<div className="px-4 py-3 border-t border-gray-200/50 dark:border-gray-700/50 flex items-center justify-between">
|
| 151 |
+
<span className="text-[10px] text-gray-400">
|
| 152 |
+
Click outside to close
|
| 153 |
+
</span>
|
| 154 |
+
<button
|
| 155 |
+
onClick={() => {
|
| 156 |
+
// In a real implementation, this would scroll to the PDF location
|
| 157 |
+
console.log('Navigate to source:', anchor);
|
| 158 |
+
}}
|
| 159 |
+
className="flex items-center gap-1 text-xs font-medium text-brand-600 dark:text-brand-400 hover:underline"
|
| 160 |
+
>
|
| 161 |
+
<ExternalLink size={12} />
|
| 162 |
+
View in PDF
|
| 163 |
+
</button>
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
</>
|
| 167 |
+
)}
|
| 168 |
+
</span>
|
| 169 |
+
);
|
| 170 |
+
};
|
| 171 |
+
|
| 172 |
+
// Inline confidence badge (for use in content)
|
| 173 |
+
export const ConfidenceBadge: React.FC<{ level: ConfidenceLevel; compact?: boolean }> = ({
|
| 174 |
+
level,
|
| 175 |
+
compact = false
|
| 176 |
+
}) => {
|
| 177 |
+
const config = confidenceConfig[level];
|
| 178 |
+
|
| 179 |
+
if (compact) {
|
| 180 |
+
return (
|
| 181 |
+
<span
|
| 182 |
+
className={`inline-flex items-center justify-center w-4 h-4 rounded-full ${config.bgColor} ${config.color}`}
|
| 183 |
+
title={config.label}
|
| 184 |
+
>
|
| 185 |
+
{config.icon}
|
| 186 |
+
</span>
|
| 187 |
+
);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
return (
|
| 191 |
+
<span className={`
|
| 192 |
+
inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium
|
| 193 |
+
${config.bgColor} ${config.color}
|
| 194 |
+
`}>
|
| 195 |
+
{config.icon}
|
| 196 |
+
<span>{config.label}</span>
|
| 197 |
+
</span>
|
| 198 |
+
);
|
| 199 |
+
};
|
| 200 |
+
|
| 201 |
+
export default SourceAnchorComponent;
|
components/TextToSpeech.tsx
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
| 2 |
+
import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Settings, X } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
interface Props {
|
| 5 |
+
text: string;
|
| 6 |
+
sectionId: string;
|
| 7 |
+
onWordChange?: (wordIndex: number) => void;
|
| 8 |
+
onPlayingChange?: (isPlaying: boolean) => void;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
interface TTSSettings {
|
| 12 |
+
rate: number;
|
| 13 |
+
pitch: number;
|
| 14 |
+
voice: SpeechSynthesisVoice | null;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const TextToSpeech: React.FC<Props> = ({
|
| 18 |
+
text,
|
| 19 |
+
sectionId,
|
| 20 |
+
onWordChange,
|
| 21 |
+
onPlayingChange
|
| 22 |
+
}) => {
|
| 23 |
+
const [isPlaying, setIsPlaying] = useState(false);
|
| 24 |
+
const [isPaused, setIsPaused] = useState(false);
|
| 25 |
+
const [currentWordIndex, setCurrentWordIndex] = useState(0);
|
| 26 |
+
const [showSettings, setShowSettings] = useState(false);
|
| 27 |
+
const [settings, setSettings] = useState<TTSSettings>({
|
| 28 |
+
rate: 1.0,
|
| 29 |
+
pitch: 1.0,
|
| 30 |
+
voice: null
|
| 31 |
+
});
|
| 32 |
+
const [availableVoices, setAvailableVoices] = useState<SpeechSynthesisVoice[]>([]);
|
| 33 |
+
const [progress, setProgress] = useState(0);
|
| 34 |
+
|
| 35 |
+
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
|
| 36 |
+
const wordsRef = useRef<string[]>([]);
|
| 37 |
+
|
| 38 |
+
// Parse text into words
|
| 39 |
+
useEffect(() => {
|
| 40 |
+
// Strip markdown and split into words
|
| 41 |
+
const cleanText = text
|
| 42 |
+
.replace(/[#*_`\[\]()]/g, '')
|
| 43 |
+
.replace(/\n+/g, ' ')
|
| 44 |
+
.trim();
|
| 45 |
+
wordsRef.current = cleanText.split(/\s+/).filter(w => w.length > 0);
|
| 46 |
+
}, [text]);
|
| 47 |
+
|
| 48 |
+
// Load available voices
|
| 49 |
+
useEffect(() => {
|
| 50 |
+
const loadVoices = () => {
|
| 51 |
+
const voices = window.speechSynthesis.getVoices();
|
| 52 |
+
const englishVoices = voices.filter(v => v.lang.startsWith('en'));
|
| 53 |
+
setAvailableVoices(englishVoices);
|
| 54 |
+
|
| 55 |
+
// Set default voice (prefer a natural-sounding one)
|
| 56 |
+
if (!settings.voice && englishVoices.length > 0) {
|
| 57 |
+
const preferredVoice = englishVoices.find(v =>
|
| 58 |
+
v.name.includes('Samantha') ||
|
| 59 |
+
v.name.includes('Alex') ||
|
| 60 |
+
v.name.includes('Google') ||
|
| 61 |
+
v.name.includes('Natural')
|
| 62 |
+
) || englishVoices[0];
|
| 63 |
+
setSettings(prev => ({ ...prev, voice: preferredVoice }));
|
| 64 |
+
}
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
loadVoices();
|
| 68 |
+
window.speechSynthesis.onvoiceschanged = loadVoices;
|
| 69 |
+
|
| 70 |
+
return () => {
|
| 71 |
+
window.speechSynthesis.cancel();
|
| 72 |
+
};
|
| 73 |
+
}, []);
|
| 74 |
+
|
| 75 |
+
// Notify parent of word changes
|
| 76 |
+
useEffect(() => {
|
| 77 |
+
onWordChange?.(currentWordIndex);
|
| 78 |
+
}, [currentWordIndex, onWordChange]);
|
| 79 |
+
|
| 80 |
+
// Notify parent of playing state changes
|
| 81 |
+
useEffect(() => {
|
| 82 |
+
onPlayingChange?.(isPlaying);
|
| 83 |
+
}, [isPlaying, onPlayingChange]);
|
| 84 |
+
|
| 85 |
+
const handlePlay = useCallback(() => {
|
| 86 |
+
if (isPaused) {
|
| 87 |
+
window.speechSynthesis.resume();
|
| 88 |
+
setIsPaused(false);
|
| 89 |
+
setIsPlaying(true);
|
| 90 |
+
return;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// Cancel any existing speech
|
| 94 |
+
window.speechSynthesis.cancel();
|
| 95 |
+
|
| 96 |
+
// Clean text for speech
|
| 97 |
+
const cleanText = text
|
| 98 |
+
.replace(/[#*_`\[\]()]/g, '')
|
| 99 |
+
.replace(/\n+/g, '. ')
|
| 100 |
+
.trim();
|
| 101 |
+
|
| 102 |
+
const utterance = new SpeechSynthesisUtterance(cleanText);
|
| 103 |
+
utterance.rate = settings.rate;
|
| 104 |
+
utterance.pitch = settings.pitch;
|
| 105 |
+
if (settings.voice) {
|
| 106 |
+
utterance.voice = settings.voice;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// Track word boundaries
|
| 110 |
+
let charIndex = 0;
|
| 111 |
+
utterance.onboundary = (event) => {
|
| 112 |
+
if (event.name === 'word') {
|
| 113 |
+
charIndex = event.charIndex;
|
| 114 |
+
// Estimate word index based on character position
|
| 115 |
+
const spokenText = cleanText.substring(0, charIndex);
|
| 116 |
+
const wordIndex = spokenText.split(/\s+/).length - 1;
|
| 117 |
+
setCurrentWordIndex(Math.max(0, wordIndex));
|
| 118 |
+
setProgress((charIndex / cleanText.length) * 100);
|
| 119 |
+
}
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
utterance.onend = () => {
|
| 123 |
+
setIsPlaying(false);
|
| 124 |
+
setIsPaused(false);
|
| 125 |
+
setCurrentWordIndex(0);
|
| 126 |
+
setProgress(0);
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
utterance.onerror = () => {
|
| 130 |
+
setIsPlaying(false);
|
| 131 |
+
setIsPaused(false);
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
utteranceRef.current = utterance;
|
| 135 |
+
window.speechSynthesis.speak(utterance);
|
| 136 |
+
setIsPlaying(true);
|
| 137 |
+
setIsPaused(false);
|
| 138 |
+
}, [text, settings, isPaused]);
|
| 139 |
+
|
| 140 |
+
const handlePause = useCallback(() => {
|
| 141 |
+
if (isPlaying) {
|
| 142 |
+
window.speechSynthesis.pause();
|
| 143 |
+
setIsPaused(true);
|
| 144 |
+
setIsPlaying(false);
|
| 145 |
+
}
|
| 146 |
+
}, [isPlaying]);
|
| 147 |
+
|
| 148 |
+
const handleStop = useCallback(() => {
|
| 149 |
+
window.speechSynthesis.cancel();
|
| 150 |
+
setIsPlaying(false);
|
| 151 |
+
setIsPaused(false);
|
| 152 |
+
setCurrentWordIndex(0);
|
| 153 |
+
setProgress(0);
|
| 154 |
+
}, []);
|
| 155 |
+
|
| 156 |
+
const handleSkipBack = useCallback(() => {
|
| 157 |
+
// Restart from beginning
|
| 158 |
+
handleStop();
|
| 159 |
+
setTimeout(handlePlay, 100);
|
| 160 |
+
}, [handleStop, handlePlay]);
|
| 161 |
+
|
| 162 |
+
const handleSkipForward = useCallback(() => {
|
| 163 |
+
// Skip to next section (stop current)
|
| 164 |
+
handleStop();
|
| 165 |
+
}, [handleStop]);
|
| 166 |
+
|
| 167 |
+
return (
|
| 168 |
+
<div className="relative">
|
| 169 |
+
{/* Main TTS Control Bar */}
|
| 170 |
+
<div className="flex items-center gap-3 p-3 rounded-xl bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
| 171 |
+
{/* Play/Pause Button */}
|
| 172 |
+
<button
|
| 173 |
+
onClick={isPlaying ? handlePause : handlePlay}
|
| 174 |
+
className={`
|
| 175 |
+
p-3 rounded-full transition-all duration-200
|
| 176 |
+
${isPlaying
|
| 177 |
+
? 'bg-brand-500 text-white shadow-lg shadow-brand-500/30 hover:bg-brand-600'
|
| 178 |
+
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 shadow-sm'
|
| 179 |
+
}
|
| 180 |
+
`}
|
| 181 |
+
title={isPlaying ? 'Pause' : 'Play'}
|
| 182 |
+
>
|
| 183 |
+
{isPlaying ? <Pause size={20} /> : <Play size={20} className="ml-0.5" />}
|
| 184 |
+
</button>
|
| 185 |
+
|
| 186 |
+
{/* Skip Controls */}
|
| 187 |
+
<div className="flex items-center gap-1">
|
| 188 |
+
<button
|
| 189 |
+
onClick={handleSkipBack}
|
| 190 |
+
className="p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
| 191 |
+
title="Restart"
|
| 192 |
+
>
|
| 193 |
+
<SkipBack size={16} />
|
| 194 |
+
</button>
|
| 195 |
+
<button
|
| 196 |
+
onClick={handleSkipForward}
|
| 197 |
+
className="p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
| 198 |
+
title="Stop"
|
| 199 |
+
>
|
| 200 |
+
<SkipForward size={16} />
|
| 201 |
+
</button>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
{/* Progress Bar */}
|
| 205 |
+
<div className="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
| 206 |
+
<div
|
| 207 |
+
className="h-full bg-gradient-to-r from-brand-500 to-purple-500 transition-all duration-150"
|
| 208 |
+
style={{ width: `${progress}%` }}
|
| 209 |
+
/>
|
| 210 |
+
</div>
|
| 211 |
+
|
| 212 |
+
{/* Speed Indicator */}
|
| 213 |
+
<span className="text-xs font-mono text-gray-500 dark:text-gray-400 min-w-[3rem] text-center">
|
| 214 |
+
{settings.rate.toFixed(1)}x
|
| 215 |
+
</span>
|
| 216 |
+
|
| 217 |
+
{/* Settings Button */}
|
| 218 |
+
<button
|
| 219 |
+
onClick={() => setShowSettings(!showSettings)}
|
| 220 |
+
className={`
|
| 221 |
+
p-2 rounded-lg transition-colors
|
| 222 |
+
${showSettings
|
| 223 |
+
? 'bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400'
|
| 224 |
+
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
| 225 |
+
}
|
| 226 |
+
`}
|
| 227 |
+
title="Settings"
|
| 228 |
+
>
|
| 229 |
+
<Settings size={16} />
|
| 230 |
+
</button>
|
| 231 |
+
</div>
|
| 232 |
+
|
| 233 |
+
{/* Settings Panel */}
|
| 234 |
+
{showSettings && (
|
| 235 |
+
<div className="absolute top-full left-0 right-0 mt-2 p-4 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-xl z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
| 236 |
+
<div className="flex items-center justify-between mb-4">
|
| 237 |
+
<h4 className="text-sm font-bold text-gray-900 dark:text-white">Audio Settings</h4>
|
| 238 |
+
<button
|
| 239 |
+
onClick={() => setShowSettings(false)}
|
| 240 |
+
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
| 241 |
+
>
|
| 242 |
+
<X size={14} />
|
| 243 |
+
</button>
|
| 244 |
+
</div>
|
| 245 |
+
|
| 246 |
+
<div className="space-y-4">
|
| 247 |
+
{/* Speed Control */}
|
| 248 |
+
<div>
|
| 249 |
+
<label className="flex items-center justify-between text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">
|
| 250 |
+
<span>Speed</span>
|
| 251 |
+
<span className="font-mono">{settings.rate.toFixed(1)}x</span>
|
| 252 |
+
</label>
|
| 253 |
+
<input
|
| 254 |
+
type="range"
|
| 255 |
+
min="0.5"
|
| 256 |
+
max="2"
|
| 257 |
+
step="0.1"
|
| 258 |
+
value={settings.rate}
|
| 259 |
+
onChange={(e) => setSettings(prev => ({ ...prev, rate: parseFloat(e.target.value) }))}
|
| 260 |
+
className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-brand-500"
|
| 261 |
+
/>
|
| 262 |
+
<div className="flex justify-between text-[10px] text-gray-400 mt-1">
|
| 263 |
+
<span>0.5x</span>
|
| 264 |
+
<span>1x</span>
|
| 265 |
+
<span>1.5x</span>
|
| 266 |
+
<span>2x</span>
|
| 267 |
+
</div>
|
| 268 |
+
</div>
|
| 269 |
+
|
| 270 |
+
{/* Pitch Control */}
|
| 271 |
+
<div>
|
| 272 |
+
<label className="flex items-center justify-between text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">
|
| 273 |
+
<span>Pitch</span>
|
| 274 |
+
<span className="font-mono">{settings.pitch.toFixed(1)}</span>
|
| 275 |
+
</label>
|
| 276 |
+
<input
|
| 277 |
+
type="range"
|
| 278 |
+
min="0.5"
|
| 279 |
+
max="1.5"
|
| 280 |
+
step="0.1"
|
| 281 |
+
value={settings.pitch}
|
| 282 |
+
onChange={(e) => setSettings(prev => ({ ...prev, pitch: parseFloat(e.target.value) }))}
|
| 283 |
+
className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-brand-500"
|
| 284 |
+
/>
|
| 285 |
+
</div>
|
| 286 |
+
|
| 287 |
+
{/* Voice Selection */}
|
| 288 |
+
{availableVoices.length > 0 && (
|
| 289 |
+
<div>
|
| 290 |
+
<label className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-2 block">
|
| 291 |
+
Voice
|
| 292 |
+
</label>
|
| 293 |
+
<select
|
| 294 |
+
value={settings.voice?.name || ''}
|
| 295 |
+
onChange={(e) => {
|
| 296 |
+
const voice = availableVoices.find(v => v.name === e.target.value) || null;
|
| 297 |
+
setSettings(prev => ({ ...prev, voice }));
|
| 298 |
+
}}
|
| 299 |
+
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-brand-500 outline-none"
|
| 300 |
+
>
|
| 301 |
+
{availableVoices.map((voice) => (
|
| 302 |
+
<option key={voice.name} value={voice.name}>
|
| 303 |
+
{voice.name} ({voice.lang})
|
| 304 |
+
</option>
|
| 305 |
+
))}
|
| 306 |
+
</select>
|
| 307 |
+
</div>
|
| 308 |
+
)}
|
| 309 |
+
</div>
|
| 310 |
+
</div>
|
| 311 |
+
)}
|
| 312 |
+
|
| 313 |
+
{/* Current word indicator (for debugging, usually hidden) */}
|
| 314 |
+
{/* <div className="mt-2 text-xs text-gray-400">
|
| 315 |
+
Word {currentWordIndex + 1} of {wordsRef.current.length}
|
| 316 |
+
</div> */}
|
| 317 |
+
</div>
|
| 318 |
+
);
|
| 319 |
+
};
|
| 320 |
+
|
| 321 |
+
export default TextToSpeech;
|
services/geminiService.ts
CHANGED
|
@@ -369,10 +369,34 @@ const PAPER_STRUCTURE_SCHEMA = {
|
|
| 369 |
required: ['paperTitle', 'paperAbstract', 'mainContribution', 'sections', 'keyTerms']
|
| 370 |
};
|
| 371 |
|
| 372 |
-
// Single Blog Section Schema
|
| 373 |
const SINGLE_SECTION_SCHEMA = {
|
| 374 |
type: Type.OBJECT,
|
| 375 |
properties: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
content: { type: Type.STRING, description: "Rich Markdown content (300-500 words). Use **bold**, *italics*, bullet points, numbered lists, and ### subheadings." },
|
| 377 |
visualizationType: {
|
| 378 |
type: Type.STRING,
|
|
@@ -608,6 +632,25 @@ export const generateSingleBlogSection = async (
|
|
| 608 |
WRITING INSTRUCTIONS:
|
| 609 |
${sectionTypeGuide[sectionPlan.sectionType]}
|
| 610 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 611 |
CONTENT REQUIREMENTS:
|
| 612 |
- Write in distill.pub style: accessible yet technically accurate
|
| 613 |
- Use Markdown formatting: **bold** for emphasis, bullet points for lists, ### for subheadings
|
|
@@ -627,6 +670,7 @@ export const generateSingleBlogSection = async (
|
|
| 627 |
- Use real numbers from tables/figures
|
| 628 |
- Choose the right chart type (bar for comparisons, line for trends)
|
| 629 |
- Include axis labels
|
|
|
|
| 630 |
` : ''}
|
| 631 |
${sectionPlan.suggestedVisualization === 'equation' ? `
|
| 632 |
Write the key equation in LaTeX-style notation:
|
|
@@ -672,7 +716,13 @@ export const generateSingleBlogSection = async (
|
|
| 672 |
return {
|
| 673 |
id: sectionPlan.id,
|
| 674 |
title: sectionPlan.title,
|
| 675 |
-
content: parsed.content,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 676 |
visualizationType: parsed.visualizationType,
|
| 677 |
visualizationData: parsed.visualizationData,
|
| 678 |
chartData: parsed.chartData,
|
|
|
|
| 369 |
required: ['paperTitle', 'paperAbstract', 'mainContribution', 'sections', 'keyTerms']
|
| 370 |
};
|
| 371 |
|
| 372 |
+
// Single Blog Section Schema with Progressive Disclosure Layers
|
| 373 |
const SINGLE_SECTION_SCHEMA = {
|
| 374 |
type: Type.OBJECT,
|
| 375 |
properties: {
|
| 376 |
+
// Progressive Disclosure Layers (ADHD-Friendly)
|
| 377 |
+
layers: {
|
| 378 |
+
type: Type.OBJECT,
|
| 379 |
+
properties: {
|
| 380 |
+
thesis: {
|
| 381 |
+
type: Type.STRING,
|
| 382 |
+
description: "Tweet-length core thesis of this section (< 280 characters). The one key insight."
|
| 383 |
+
},
|
| 384 |
+
takeaways: {
|
| 385 |
+
type: Type.ARRAY,
|
| 386 |
+
items: { type: Type.STRING },
|
| 387 |
+
description: "3-5 key bullet points that summarize this section"
|
| 388 |
+
},
|
| 389 |
+
detailed: {
|
| 390 |
+
type: Type.STRING,
|
| 391 |
+
description: "Full detailed content in rich Markdown (300-500 words)"
|
| 392 |
+
},
|
| 393 |
+
eli5Content: {
|
| 394 |
+
type: Type.STRING,
|
| 395 |
+
description: "Same content but explained using simple everyday language and analogies, avoiding jargon"
|
| 396 |
+
}
|
| 397 |
+
},
|
| 398 |
+
required: ['thesis', 'takeaways', 'detailed']
|
| 399 |
+
},
|
| 400 |
content: { type: Type.STRING, description: "Rich Markdown content (300-500 words). Use **bold**, *italics*, bullet points, numbered lists, and ### subheadings." },
|
| 401 |
visualizationType: {
|
| 402 |
type: Type.STRING,
|
|
|
|
| 632 |
WRITING INSTRUCTIONS:
|
| 633 |
${sectionTypeGuide[sectionPlan.sectionType]}
|
| 634 |
|
| 635 |
+
===== CRITICAL: PROGRESSIVE DISCLOSURE LAYERS (ADHD-FRIENDLY) =====
|
| 636 |
+
You MUST generate content in THREE LAYERS for this section:
|
| 637 |
+
|
| 638 |
+
LAYER 1 - THESIS (Tweet-Length < 280 chars):
|
| 639 |
+
Write the ONE key insight of this section that a reader absolutely must know.
|
| 640 |
+
Example: "Attention mechanisms let models focus on relevant parts of input, like a spotlight on important words."
|
| 641 |
+
|
| 642 |
+
LAYER 2 - TAKEAWAYS (3-5 Bullet Points):
|
| 643 |
+
Write 3-5 key bullet points that expand on the thesis.
|
| 644 |
+
Each bullet should be one actionable/memorable piece of information.
|
| 645 |
+
|
| 646 |
+
LAYER 3 - DETAILED (Full Markdown Content):
|
| 647 |
+
The full explanation with all the context, examples, and depth.
|
| 648 |
+
|
| 649 |
+
BONUS - ELI5 CONTENT (Simple Language Version):
|
| 650 |
+
Rewrite the detailed content using everyday language, analogies, and no jargon.
|
| 651 |
+
Imagine explaining to a smart 12-year-old.
|
| 652 |
+
=================================================================
|
| 653 |
+
|
| 654 |
CONTENT REQUIREMENTS:
|
| 655 |
- Write in distill.pub style: accessible yet technically accurate
|
| 656 |
- Use Markdown formatting: **bold** for emphasis, bullet points for lists, ### for subheadings
|
|
|
|
| 670 |
- Use real numbers from tables/figures
|
| 671 |
- Choose the right chart type (bar for comparisons, line for trends)
|
| 672 |
- Include axis labels
|
| 673 |
+
- Make it interactive: if appropriate, add slider config for "what-if" exploration
|
| 674 |
` : ''}
|
| 675 |
${sectionPlan.suggestedVisualization === 'equation' ? `
|
| 676 |
Write the key equation in LaTeX-style notation:
|
|
|
|
| 716 |
return {
|
| 717 |
id: sectionPlan.id,
|
| 718 |
title: sectionPlan.title,
|
| 719 |
+
content: parsed.content || parsed.layers?.detailed || '',
|
| 720 |
+
layers: parsed.layers ? {
|
| 721 |
+
thesis: parsed.layers.thesis || '',
|
| 722 |
+
takeaways: parsed.layers.takeaways || [],
|
| 723 |
+
detailed: parsed.layers.detailed || parsed.content || '',
|
| 724 |
+
eli5Content: parsed.layers.eli5Content
|
| 725 |
+
} : undefined,
|
| 726 |
visualizationType: parsed.visualizationType,
|
| 727 |
visualizationData: parsed.visualizationData,
|
| 728 |
chartData: parsed.chartData,
|
style.css
CHANGED
|
@@ -20,3 +20,115 @@ body {
|
|
| 20 |
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 21 |
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5);
|
| 22 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 21 |
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5);
|
| 22 |
}
|
| 23 |
+
|
| 24 |
+
/* ADHD-Friendly Features */
|
| 25 |
+
|
| 26 |
+
/* Bionic Reading Support */
|
| 27 |
+
.bionic-text {
|
| 28 |
+
font-feature-settings: 'kern' 1;
|
| 29 |
+
letter-spacing: 0.01em;
|
| 30 |
+
word-spacing: 0.05em;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/* TTS Karaoke Highlight */
|
| 34 |
+
.tts-highlight {
|
| 35 |
+
background: linear-gradient(120deg, rgba(14, 165, 233, 0.2) 0%, rgba(139, 92, 246, 0.2) 100%);
|
| 36 |
+
padding: 2px 4px;
|
| 37 |
+
border-radius: 4px;
|
| 38 |
+
transition: all 150ms ease;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/* Progressive Disclosure Animations */
|
| 42 |
+
@keyframes layer-expand {
|
| 43 |
+
from {
|
| 44 |
+
opacity: 0;
|
| 45 |
+
transform: translateY(-10px) scale(0.98);
|
| 46 |
+
}
|
| 47 |
+
to {
|
| 48 |
+
opacity: 1;
|
| 49 |
+
transform: translateY(0) scale(1);
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.layer-content {
|
| 54 |
+
animation: layer-expand 0.3s ease-out forwards;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/* Focus Indicators for Accessibility */
|
| 58 |
+
button:focus-visible,
|
| 59 |
+
a:focus-visible {
|
| 60 |
+
outline: 2px solid #0ea5e9;
|
| 61 |
+
outline-offset: 2px;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/* Reduce motion for users who prefer it */
|
| 65 |
+
@media (prefers-reduced-motion: reduce) {
|
| 66 |
+
*,
|
| 67 |
+
*::before,
|
| 68 |
+
*::after {
|
| 69 |
+
animation-duration: 0.01ms !important;
|
| 70 |
+
animation-iteration-count: 1 !important;
|
| 71 |
+
transition-duration: 0.01ms !important;
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/* Custom scrollbar for the sidebar */
|
| 76 |
+
.custom-scrollbar {
|
| 77 |
+
scrollbar-width: thin;
|
| 78 |
+
scrollbar-color: rgba(156, 163, 175, 0.3) transparent;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.custom-scrollbar::-webkit-scrollbar {
|
| 82 |
+
width: 6px;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.custom-scrollbar::-webkit-scrollbar-track {
|
| 86 |
+
background: transparent;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.custom-scrollbar::-webkit-scrollbar-thumb {
|
| 90 |
+
background-color: rgba(156, 163, 175, 0.3);
|
| 91 |
+
border-radius: 3px;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
| 95 |
+
background-color: rgba(156, 163, 175, 0.5);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/* Source anchor highlight */
|
| 99 |
+
.source-anchor-highlight {
|
| 100 |
+
background: linear-gradient(
|
| 101 |
+
to bottom,
|
| 102 |
+
transparent 60%,
|
| 103 |
+
rgba(14, 165, 233, 0.3) 60%
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/* Interactive chart hover effects */
|
| 108 |
+
.chart-interactive:hover {
|
| 109 |
+
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1));
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
/* Reading progress gradient */
|
| 113 |
+
.reading-progress-bar {
|
| 114 |
+
background: linear-gradient(
|
| 115 |
+
90deg,
|
| 116 |
+
#0ea5e9 0%,
|
| 117 |
+
#8b5cf6 50%,
|
| 118 |
+
#ec4899 100%
|
| 119 |
+
);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/* Mini-map active indicator pulse */
|
| 123 |
+
@keyframes minimap-pulse {
|
| 124 |
+
0%, 100% {
|
| 125 |
+
box-shadow: 0 0 0 0 rgba(14, 165, 233, 0.4);
|
| 126 |
+
}
|
| 127 |
+
50% {
|
| 128 |
+
box-shadow: 0 0 0 4px rgba(14, 165, 233, 0);
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.minimap-active {
|
| 133 |
+
animation: minimap-pulse 2s ease-in-out infinite;
|
| 134 |
+
}
|
types.ts
CHANGED
|
@@ -24,12 +24,35 @@ export interface ChatMessage {
|
|
| 24 |
// Model types
|
| 25 |
export type GeminiModel = 'gemini-2.5-flash' | 'gemini-3-pro-preview';
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
export interface AppSettings {
|
| 28 |
apiKey: string;
|
| 29 |
model: GeminiModel;
|
| 30 |
theme: 'light' | 'dark';
|
| 31 |
layoutMode: 'auto' | 'grid' | 'list';
|
| 32 |
useThinking: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
}
|
| 34 |
|
| 35 |
export interface ProcessingStatus {
|
|
@@ -37,11 +60,25 @@ export interface ProcessingStatus {
|
|
| 37 |
message?: string;
|
| 38 |
}
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
// Blog View Types
|
| 41 |
export interface BlogSection {
|
| 42 |
id: string;
|
| 43 |
title: string;
|
| 44 |
-
content: string; // Markdown
|
|
|
|
|
|
|
| 45 |
visualizationType?: 'mermaid' | 'chart' | 'equation' | 'none';
|
| 46 |
visualizationData?: string;
|
| 47 |
chartData?: ChartData;
|
|
@@ -52,6 +89,24 @@ export interface BlogSection {
|
|
| 52 |
error?: string;
|
| 53 |
// Validation status
|
| 54 |
validationStatus?: ValidationStatus;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
export interface ValidationStatus {
|
|
@@ -98,6 +153,7 @@ export interface MarginNote {
|
|
| 98 |
export interface TechnicalTerm {
|
| 99 |
term: string;
|
| 100 |
definition: string;
|
|
|
|
| 101 |
}
|
| 102 |
|
| 103 |
export interface CollapsibleContent {
|
|
@@ -106,7 +162,7 @@ export interface CollapsibleContent {
|
|
| 106 |
content: string;
|
| 107 |
}
|
| 108 |
|
| 109 |
-
// Chart Types
|
| 110 |
export interface ChartData {
|
| 111 |
type: 'bar' | 'line' | 'pie' | 'scatter' | 'area';
|
| 112 |
title: string;
|
|
@@ -114,6 +170,39 @@ export interface ChartData {
|
|
| 114 |
xAxis?: string;
|
| 115 |
yAxis?: string;
|
| 116 |
colors?: string[];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
}
|
| 118 |
|
| 119 |
export type ViewMode = 'grid' | 'blog';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
// Model types
|
| 25 |
export type GeminiModel = 'gemini-2.5-flash' | 'gemini-3-pro-preview';
|
| 26 |
|
| 27 |
+
// ADHD-Friendly Reading Preferences
|
| 28 |
+
export interface ReadingPreferences {
|
| 29 |
+
bionicReading: boolean;
|
| 30 |
+
eli5Mode: boolean; // Simple language mode
|
| 31 |
+
highlightKeyTerms: boolean;
|
| 32 |
+
ttsEnabled: boolean;
|
| 33 |
+
ttsSpeed: number; // 0.5 to 2.0
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
export interface AppSettings {
|
| 37 |
apiKey: string;
|
| 38 |
model: GeminiModel;
|
| 39 |
theme: 'light' | 'dark';
|
| 40 |
layoutMode: 'auto' | 'grid' | 'list';
|
| 41 |
useThinking: boolean;
|
| 42 |
+
readingPreferences: ReadingPreferences;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// Confidence level for AI-generated content
|
| 46 |
+
export type ConfidenceLevel = 'direct-quote' | 'paraphrase' | 'synthesis' | 'interpretation';
|
| 47 |
+
|
| 48 |
+
// Source anchor for zero-latency verification
|
| 49 |
+
export interface SourceAnchor {
|
| 50 |
+
id: string;
|
| 51 |
+
claimText: string;
|
| 52 |
+
sourceText: string;
|
| 53 |
+
pageNumber?: number;
|
| 54 |
+
confidence: ConfidenceLevel;
|
| 55 |
+
highlightColor: string;
|
| 56 |
}
|
| 57 |
|
| 58 |
export interface ProcessingStatus {
|
|
|
|
| 60 |
message?: string;
|
| 61 |
}
|
| 62 |
|
| 63 |
+
// Progressive Disclosure Layers (ADHD-Friendly)
|
| 64 |
+
export interface ContentLayers {
|
| 65 |
+
// Layer 1: Tweet-length core thesis (< 280 chars)
|
| 66 |
+
thesis: string;
|
| 67 |
+
// Layer 2: Key takeaways (3-5 bullet points)
|
| 68 |
+
takeaways: string[];
|
| 69 |
+
// Layer 3: Full detailed content (markdown)
|
| 70 |
+
detailed: string;
|
| 71 |
+
// ELI5 version for simple language mode
|
| 72 |
+
eli5Content?: string;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
// Blog View Types
|
| 76 |
export interface BlogSection {
|
| 77 |
id: string;
|
| 78 |
title: string;
|
| 79 |
+
content: string; // Markdown (Layer 3 - detailed)
|
| 80 |
+
// Progressive disclosure layers
|
| 81 |
+
layers?: ContentLayers;
|
| 82 |
visualizationType?: 'mermaid' | 'chart' | 'equation' | 'none';
|
| 83 |
visualizationData?: string;
|
| 84 |
chartData?: ChartData;
|
|
|
|
| 89 |
error?: string;
|
| 90 |
// Validation status
|
| 91 |
validationStatus?: ValidationStatus;
|
| 92 |
+
// Source anchors for verification
|
| 93 |
+
sourceAnchors?: SourceAnchor[];
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
export interface ValidationStatus {
|
| 97 |
+
isValidated: boolean;
|
| 98 |
+
contentRelevance: ValidationResult;
|
| 99 |
+
visualizationValidity: ValidationResult;
|
| 100 |
+
overallScore: number; // 0-100
|
| 101 |
+
wasRepaired?: boolean;
|
| 102 |
+
repairAttempts?: number;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
export interface ValidationResult {
|
| 106 |
+
passed: boolean;
|
| 107 |
+
score: number; // 0-100
|
| 108 |
+
issues: string[];
|
| 109 |
+
suggestions?: string[];
|
| 110 |
}
|
| 111 |
|
| 112 |
export interface ValidationStatus {
|
|
|
|
| 153 |
export interface TechnicalTerm {
|
| 154 |
term: string;
|
| 155 |
definition: string;
|
| 156 |
+
highlightColor?: string; // Consistent color for this term throughout
|
| 157 |
}
|
| 158 |
|
| 159 |
export interface CollapsibleContent {
|
|
|
|
| 162 |
content: string;
|
| 163 |
}
|
| 164 |
|
| 165 |
+
// Interactive Chart Types (Playable Visualizations)
|
| 166 |
export interface ChartData {
|
| 167 |
type: 'bar' | 'line' | 'pie' | 'scatter' | 'area';
|
| 168 |
title: string;
|
|
|
|
| 170 |
xAxis?: string;
|
| 171 |
yAxis?: string;
|
| 172 |
colors?: string[];
|
| 173 |
+
// Interactive features
|
| 174 |
+
interactive?: {
|
| 175 |
+
enableSliders?: boolean;
|
| 176 |
+
sliderConfig?: SliderConfig[];
|
| 177 |
+
enableToggles?: boolean;
|
| 178 |
+
toggleableDatasets?: string[];
|
| 179 |
+
};
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// Slider configuration for interactive charts
|
| 183 |
+
export interface SliderConfig {
|
| 184 |
+
id: string;
|
| 185 |
+
label: string;
|
| 186 |
+
min: number;
|
| 187 |
+
max: number;
|
| 188 |
+
step: number;
|
| 189 |
+
defaultValue: number;
|
| 190 |
+
affectsVariable: string; // Which variable this slider modifies
|
| 191 |
}
|
| 192 |
|
| 193 |
export type ViewMode = 'grid' | 'blog';
|
| 194 |
+
|
| 195 |
+
// Text-to-Speech state
|
| 196 |
+
export interface TTSState {
|
| 197 |
+
isPlaying: boolean;
|
| 198 |
+
currentSectionId: string | null;
|
| 199 |
+
currentWordIndex: number;
|
| 200 |
+
speed: number;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
// Wayfinding / Progress tracking
|
| 204 |
+
export interface ReadingProgress {
|
| 205 |
+
sectionId: string;
|
| 206 |
+
percentComplete: number;
|
| 207 |
+
timeSpent: number; // in seconds
|
| 208 |
+
}
|