Add files using upload-large-folder tool
Browse files- .gitignore +27 -0
- .vscode/settings.json +3 -0
- README.md +175 -0
- app.txt +757 -0
- package-lock.json +0 -0
- package.json +59 -0
- public/index.html +185 -0
- public/sample.html +203 -0
- server/README.md +164 -0
- server/package-lock.json +0 -0
- server/package.json +23 -0
- server/server.js +693 -0
- src/App.js +420 -0
- src/components/AHoleLoader.css +265 -0
- src/components/AHoleLoader.js +404 -0
- src/components/DocumentViewer.js +552 -0
- src/components/FileUploader.js +342 -0
- src/components/FlowingMenu.js +98 -0
- src/components/LoadingScreen.js +90 -0
- src/components/Orb.js +300 -0
- src/components/PreviewPanel.js +543 -0
- src/components/ProcessingProgress.js +122 -0
- src/components/ResultsPanel.js +1340 -0
- src/components/TextViewer.js +809 -0
- src/components/TrueFocus.js +114 -0
- src/index.css +2241 -0
- src/index.js +10 -0
- src/utils/encryption.js +132 -0
- start-dev.js +46 -0
.gitignore
ADDED
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Dependencies
|
2 |
+
node_modules/
|
3 |
+
npm-debug.log*
|
4 |
+
yarn-debug.log*
|
5 |
+
yarn-error.log*
|
6 |
+
|
7 |
+
# Production builds
|
8 |
+
build/
|
9 |
+
dist/
|
10 |
+
|
11 |
+
# Environment variables
|
12 |
+
.env
|
13 |
+
.env.local
|
14 |
+
.env.development.local
|
15 |
+
.env.test.local
|
16 |
+
.env.production.local
|
17 |
+
|
18 |
+
# IDE
|
19 |
+
.vscode/
|
20 |
+
.idea/
|
21 |
+
|
22 |
+
# OS
|
23 |
+
.DS_Store
|
24 |
+
Thumbs.db
|
25 |
+
|
26 |
+
# Logs
|
27 |
+
*.log
|
.vscode/settings.json
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"html.autoClosingTags": false
|
3 |
+
}
|
README.md
ADDED
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
---
|
2 |
+
title: Luna OCR
|
3 |
+
emoji: 🌙
|
4 |
+
colorFrom: blue
|
5 |
+
colorTo: purple
|
6 |
+
sdk: static
|
7 |
+
pinned: false
|
8 |
+
license: mit
|
9 |
+
---
|
10 |
+
|
11 |
+
# 🌙 Luna OCR - AI-Powered Text Extraction
|
12 |
+
|
13 |
+
**The most accurate OCR solution for daily use** - Transform images and PDFs into perfectly formatted text with AI intelligence.
|
14 |
+
|
15 |
+
## ✨ What Makes Luna OCR Special
|
16 |
+
|
17 |
+
Luna OCR isn't just another text extraction tool. It's an **AI-enhanced OCR system** that understands context, preserves formatting, and delivers **insanely accurate results** for your daily workflow.
|
18 |
+
|
19 |
+
### 🎯 Key Features
|
20 |
+
|
21 |
+
- 🤖 **AI-Powered Processing**: Google Gemini 2.5 Flash & Pro integration
|
22 |
+
- 📊 **Smart Formatting**: Automatic tables, bold text, and structure detection
|
23 |
+
- 🎨 **Multiple Output Formats**: JSON, HTML, Markdown, and plain text
|
24 |
+
- � **Data Visgualization**: Interactive tables with sorting and categorization
|
25 |
+
- 🖼️ **Universal Support**: Images (JPEG, PNG, WebP) and multi-page PDFs
|
26 |
+
- ⚡ **Lightning Fast**: Real-time processing with live progress tracking
|
27 |
+
- 🎭 **Beautiful UI**: Modern glassmorphism design with smooth animations
|
28 |
+
|
29 |
+
## 🚀 Live Demo
|
30 |
+
|
31 |
+
Visit: **https://huggingface.co/veela4/luna_ocr**
|
32 |
+
|
33 |
+
## 🛠️ Technology Stack
|
34 |
+
|
35 |
+
- **Frontend**: React 19, Three.js, Framer Motion, Radix UI
|
36 |
+
- **Backend**: Node.js, Express, Sharp, Tesseract OCR
|
37 |
+
- **AI Engine**: Google Gemini 2.5 Flash & Pro
|
38 |
+
- **Design**: Glassmorphism UI with advanced animations
|
39 |
+
|
40 |
+
## 📦 Quick Start
|
41 |
+
|
42 |
+
1. **Clone the repository**
|
43 |
+
```bash
|
44 |
+
git clone https://huggingface.co/veela4/luna_ocr
|
45 |
+
cd luna_ocr
|
46 |
+
```
|
47 |
+
|
48 |
+
2. **Install dependencies**
|
49 |
+
```bash
|
50 |
+
npm install
|
51 |
+
cd server && npm install && cd ..
|
52 |
+
```
|
53 |
+
|
54 |
+
3. **Set up environment variables**
|
55 |
+
```bash
|
56 |
+
# Create .env file in root directory
|
57 |
+
echo "GEMINI_API_KEY=your_google_gemini_api_key_here" > .env
|
58 |
+
```
|
59 |
+
Get your API key from: https://makersuite.google.com/app/apikey
|
60 |
+
|
61 |
+
4. **Start the application**
|
62 |
+
```bash
|
63 |
+
npm start
|
64 |
+
```
|
65 |
+
|
66 |
+
Access the app at `http://localhost:3000`
|
67 |
+
|
68 |
+
## 🎯 Use Cases & Examples
|
69 |
+
|
70 |
+
### 📋 Document Processing
|
71 |
+
- **Receipts & Invoices**: Extract structured data with automatic categorization
|
72 |
+
- **Business Cards**: Convert to organized contact information
|
73 |
+
- **Forms & Applications**: Preserve table structures and formatting
|
74 |
+
|
75 |
+
### 📚 Academic & Research
|
76 |
+
- **Research Papers**: Maintain citations, footnotes, and formatting
|
77 |
+
- **Handwritten Notes**: Convert to searchable, editable text
|
78 |
+
- **Textbooks**: Extract with proper headings and structure
|
79 |
+
|
80 |
+
### 💼 Professional Workflows
|
81 |
+
- **Meeting Notes**: Convert whiteboards to formatted documents
|
82 |
+
- **Legal Documents**: Preserve critical formatting and structure
|
83 |
+
- **Technical Manuals**: Maintain code blocks and technical formatting
|
84 |
+
|
85 |
+
## 🔧 Output Formats
|
86 |
+
|
87 |
+
### 📄 Plain Text
|
88 |
+
Clean, readable text extraction
|
89 |
+
|
90 |
+
### 📊 JSON Structure
|
91 |
+
```json
|
92 |
+
{
|
93 |
+
"title": "Document Title",
|
94 |
+
"sections": [
|
95 |
+
{
|
96 |
+
"heading": "Section 1",
|
97 |
+
"content": "Formatted content...",
|
98 |
+
"tables": [...],
|
99 |
+
"metadata": {...}
|
100 |
+
}
|
101 |
+
]
|
102 |
+
}
|
103 |
+
```
|
104 |
+
|
105 |
+
### 🌐 HTML Format
|
106 |
+
Fully formatted HTML with proper tags, tables, and styling
|
107 |
+
|
108 |
+
### 📝 Markdown
|
109 |
+
GitHub-flavored markdown with tables, headers, and formatting
|
110 |
+
|
111 |
+
## 🎨 Advanced Features
|
112 |
+
|
113 |
+
### 🧠 AI Intelligence
|
114 |
+
- **Context Understanding**: Recognizes document types and adjusts processing
|
115 |
+
- **Smart Formatting**: Preserves original layout and styling
|
116 |
+
- **Error Correction**: Fixes common OCR mistakes using AI
|
117 |
+
|
118 |
+
### 📊 Data Processing
|
119 |
+
- **Table Recognition**: Converts complex tables to structured data
|
120 |
+
- **Sorting & Filtering**: Interactive data manipulation
|
121 |
+
- **Export Options**: Multiple format support for different use cases
|
122 |
+
|
123 |
+
### 🎭 User Experience
|
124 |
+
- **Real-time Preview**: See results as they're processed
|
125 |
+
- **Progress Tracking**: Visual feedback for long operations
|
126 |
+
- **Responsive Design**: Works perfectly on all devices
|
127 |
+
|
128 |
+
## 📡 API Reference
|
129 |
+
|
130 |
+
### Process Document
|
131 |
+
```bash
|
132 |
+
POST /api/ocr
|
133 |
+
Content-Type: multipart/form-data
|
134 |
+
|
135 |
+
# Response includes structured data in requested format
|
136 |
+
```
|
137 |
+
|
138 |
+
### Health Check
|
139 |
+
```bash
|
140 |
+
GET /api/health
|
141 |
+
# Returns system status and capabilities
|
142 |
+
```
|
143 |
+
|
144 |
+
### Processing Status
|
145 |
+
```bash
|
146 |
+
GET /api/progress/:sessionId
|
147 |
+
# Real-time processing updates
|
148 |
+
```
|
149 |
+
|
150 |
+
## 🎯 Why Choose Luna OCR?
|
151 |
+
|
152 |
+
- ✅ **Accuracy**: AI-enhanced recognition beats traditional OCR
|
153 |
+
- ✅ **Speed**: Optimized for daily use with fast processing
|
154 |
+
- ✅ **Flexibility**: Multiple output formats for any workflow
|
155 |
+
- ✅ **Intelligence**: Context-aware processing and formatting
|
156 |
+
- ✅ **Modern**: Beautiful, intuitive interface
|
157 |
+
- ✅ **Open Source**: MIT license, customize as needed
|
158 |
+
|
159 |
+
## 🤝 Contributing
|
160 |
+
|
161 |
+
We welcome contributions! Feel free to:
|
162 |
+
- Report bugs and suggest features
|
163 |
+
- Submit pull requests
|
164 |
+
- Improve documentation
|
165 |
+
- Share your use cases
|
166 |
+
|
167 |
+
## 📝 License
|
168 |
+
|
169 |
+
MIT License - Use freely in personal and commercial projects
|
170 |
+
|
171 |
+
---
|
172 |
+
|
173 |
+
**Built with ❤️ for developers who demand accuracy**
|
174 |
+
|
175 |
+
*Transform your document workflow today with Luna OCR*
|
app.txt
ADDED
@@ -0,0 +1,757 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
🌙💛 Luna's OCR Studio - Clean & Professional
|
3 |
+
Modern OCR with perfect readability and clean design
|
4 |
+
"""
|
5 |
+
|
6 |
+
import gradio as gr
|
7 |
+
import os
|
8 |
+
from google import genai
|
9 |
+
import re
|
10 |
+
from PIL import Image, ImageEnhance, ImageFilter
|
11 |
+
from pdf2image import convert_from_path
|
12 |
+
import tempfile
|
13 |
+
import json
|
14 |
+
from datetime import datetime
|
15 |
+
import time
|
16 |
+
import markdown
|
17 |
+
import base64
|
18 |
+
from io import BytesIO
|
19 |
+
import zipfile
|
20 |
+
import shutil
|
21 |
+
|
22 |
+
# === CORE FUNCTIONS ===
|
23 |
+
|
24 |
+
def enhance_image_quality(image):
|
25 |
+
"""Real OCR-optimized image enhancement"""
|
26 |
+
try:
|
27 |
+
import numpy as np
|
28 |
+
from PIL import ImageOps, ImageStat
|
29 |
+
|
30 |
+
if image.mode != 'RGB':
|
31 |
+
image = image.convert('RGB')
|
32 |
+
|
33 |
+
# Calculate adaptive enhancement
|
34 |
+
stat = ImageStat.Stat(image)
|
35 |
+
mean_brightness = sum(stat.mean) / len(stat.mean)
|
36 |
+
|
37 |
+
if mean_brightness < 100: # Dark image
|
38 |
+
contrast_factor = 1.5
|
39 |
+
brightness_factor = 1.2
|
40 |
+
elif mean_brightness > 180: # Bright image
|
41 |
+
contrast_factor = 1.3
|
42 |
+
brightness_factor = 0.9
|
43 |
+
else: # Normal image
|
44 |
+
contrast_factor = 1.2
|
45 |
+
brightness_factor = 1.0
|
46 |
+
|
47 |
+
# Apply enhancements
|
48 |
+
if brightness_factor != 1.0:
|
49 |
+
enhancer = ImageEnhance.Brightness(image)
|
50 |
+
image = enhancer.enhance(brightness_factor)
|
51 |
+
|
52 |
+
enhancer = ImageEnhance.Contrast(image)
|
53 |
+
image = enhancer.enhance(contrast_factor)
|
54 |
+
|
55 |
+
enhancer = ImageEnhance.Sharpness(image)
|
56 |
+
image = enhancer.enhance(1.3)
|
57 |
+
|
58 |
+
return image
|
59 |
+
|
60 |
+
except:
|
61 |
+
# Fallback enhancement
|
62 |
+
try:
|
63 |
+
enhancer = ImageEnhance.Contrast(image)
|
64 |
+
image = enhancer.enhance(1.2)
|
65 |
+
enhancer = ImageEnhance.Sharpness(image)
|
66 |
+
image = enhancer.enhance(1.1)
|
67 |
+
return image
|
68 |
+
except:
|
69 |
+
return image
|
70 |
+
|
71 |
+
def process_with_gemini(image, api_key, use_structured=False, enhance_quality=True):
|
72 |
+
"""Process image with Gemini AI"""
|
73 |
+
if not api_key:
|
74 |
+
return "❌ Please enter your Google API Key"
|
75 |
+
|
76 |
+
try:
|
77 |
+
client = genai.Client(api_key=api_key)
|
78 |
+
|
79 |
+
# Enhance image if requested
|
80 |
+
if enhance_quality:
|
81 |
+
image = enhance_image_quality(image)
|
82 |
+
|
83 |
+
# Choose prompt and model
|
84 |
+
if use_structured:
|
85 |
+
prompt = """วิเคราะห์และแยกข้อความในรูปภาพนี้อย่างละเอียดและจัดรูปแบบเป็น Markdown:
|
86 |
+
|
87 |
+
🎯 **วิธีการประมวลผล:**
|
88 |
+
1. อ่านข้อความทั้งหมดตามลำดับที่ปรากฏ
|
89 |
+
2. แยกประเภทข้อมูล: หัวข้อ, รายละเอียด, ตัวเลข, วันที่, ชื่อ, ที่อยู่
|
90 |
+
3. แก้ไขข้อผิดพลาด OCR และปรับปรุงการเว้นวรรคภาษาไทย
|
91 |
+
4. จัดรูปแบบด้วย Markdown ที่สวยงาม
|
92 |
+
|
93 |
+
📋 **รูปแบบที่ต้องการ:**
|
94 |
+
- ใช้ ## สำหรับหัวข้อหลัก
|
95 |
+
- ใช้ ### สำหรับหัวข้อย่อย
|
96 |
+
- ใช้ **text** สำหรับข้อความสำคัญ
|
97 |
+
- ใช้ - สำหรับรายการ
|
98 |
+
- ใช้ตารางสำหรับข้อมูลที่เป็นระบบ
|
99 |
+
|
100 |
+
⚡ **ให้เฉพาะเนื้อหาที่อ่านได้ ไม่ต้องอธิบายกระบวนการ**"""
|
101 |
+
model = "gemini-2.5-pro"
|
102 |
+
else:
|
103 |
+
prompt = """อ่านและแยกข้อความจากรูปภาพนี้อย่างละเอียดและแม่นยำ:
|
104 |
+
|
105 |
+
🎯 **คำสั่งการประมวลผล:**
|
106 |
+
1. อ่านข้อความทั้งหมดตามลำดับที่ปรากฏในรูป
|
107 |
+
2. แก้ไขข้อผิดพลาดที่เกิดจาก OCR (ตัวอักษรผิด, การเว้นวรรค)
|
108 |
+
3. จัดรูปแบบให้อ่านง่าย โดยใช้การขึ้นบรรทัดใหม่ที่เหมาะสม
|
109 |
+
4. สำหรับภาษาไทย: ใส่ช่องว่างระหว่างคำให้ถูกต้อง
|
110 |
+
5. สำหรับตัวเลข วันที่ และข้อมูลสำคัญ: แยกบรรทัดให้ชัดเจน
|
111 |
+
6. รักษาโครงสร้างและลำดับเดิมของเอกสาร
|
112 |
+
7. ถ้ามีตาราง: จัดเรียงข้อมูลให้เป็นระเบียบ
|
113 |
+
|
114 |
+
⚡ **ให้ผลลัพธ์เป็นข้อความธรรมดาที่สะอาด ไม่ใช้ markdown formatting**"""
|
115 |
+
model = "gemini-2.5-flash"
|
116 |
+
|
117 |
+
# Process with Gemini
|
118 |
+
response = client.models.generate_content(
|
119 |
+
model=model,
|
120 |
+
contents=[prompt, image]
|
121 |
+
)
|
122 |
+
|
123 |
+
result_text = response.text
|
124 |
+
|
125 |
+
# Clean up result if needed
|
126 |
+
if not use_structured:
|
127 |
+
result_text = re.sub(r'\*\*(.*?)\*\*', r'\1', result_text)
|
128 |
+
result_text = re.sub(r'\*(.*?)\*', r'\1', result_text)
|
129 |
+
result_text = re.sub(r'^#+\s*', '', result_text, flags=re.MULTILINE)
|
130 |
+
|
131 |
+
return result_text
|
132 |
+
|
133 |
+
except Exception as e:
|
134 |
+
return f"❌ Error: {str(e)}"
|
135 |
+
|
136 |
+
def create_beautiful_html(content, filename, is_structured=False, processing_info=None, image=None):
|
137 |
+
"""Create beautiful HTML file"""
|
138 |
+
|
139 |
+
# Convert image to base64 if provided
|
140 |
+
image_html = ""
|
141 |
+
if image:
|
142 |
+
try:
|
143 |
+
buffer = BytesIO()
|
144 |
+
image.save(buffer, format='PNG')
|
145 |
+
img_str = base64.b64encode(buffer.getvalue()).decode()
|
146 |
+
image_html = f"""
|
147 |
+
<div class="image-container">
|
148 |
+
<h3>📷 Processed Image</h3>
|
149 |
+
<img src="data:image/png;base64,{img_str}" alt="Processed Image" style="max-width: 100%; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.2);">
|
150 |
+
</div>
|
151 |
+
"""
|
152 |
+
except:
|
153 |
+
pass
|
154 |
+
|
155 |
+
# Convert markdown to HTML if structured
|
156 |
+
if is_structured:
|
157 |
+
try:
|
158 |
+
html_content = markdown.markdown(content, extensions=['tables', 'fenced_code'])
|
159 |
+
except:
|
160 |
+
html_content = content.replace('\n', '<br>')
|
161 |
+
else:
|
162 |
+
html_content = content.replace('\n', '<br>')
|
163 |
+
|
164 |
+
# Processing info section
|
165 |
+
info_html = ""
|
166 |
+
if processing_info:
|
167 |
+
info_html = f"""
|
168 |
+
<div class="processing-info">
|
169 |
+
<h3>📊 Processing Information</h3>
|
170 |
+
<div class="info-grid">
|
171 |
+
<div class="info-item"><strong>📁 File:</strong> {processing_info.get('filename', 'Unknown')}</div>
|
172 |
+
<div class="info-item"><strong>🤖 Model:</strong> {processing_info.get('model', 'Unknown')}</div>
|
173 |
+
<div class="info-item"><strong>⏱️ Time:</strong> {processing_info.get('processing_time', 0):.2f}s</div>
|
174 |
+
<div class="info-item"><strong>📝 Characters:</strong> {processing_info.get('characters', 0):,}</div>
|
175 |
+
<div class="info-item"><strong>✨ Enhanced:</strong> {'Yes' if processing_info.get('enhanced') else 'No'}</div>
|
176 |
+
<div class="info-item"><strong>📅 Processed:</strong> {processing_info.get('timestamp', '')[:19]}</div>
|
177 |
+
</div>
|
178 |
+
</div>
|
179 |
+
"""
|
180 |
+
|
181 |
+
html_template = f"""
|
182 |
+
<!DOCTYPE html>
|
183 |
+
<html lang="en">
|
184 |
+
<head>
|
185 |
+
<meta charset="UTF-8">
|
186 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
187 |
+
<title>OCR Results - {filename}</title>
|
188 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
189 |
+
<style>
|
190 |
+
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
191 |
+
body {{
|
192 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
193 |
+
background: #1a1a1a; color: #e0e0e0; line-height: 1.6; min-height: 100vh;
|
194 |
+
}}
|
195 |
+
.container {{ max-width: 1000px; margin: 0 auto; padding: 32px 20px; }}
|
196 |
+
.header {{
|
197 |
+
background: #2a2a2a; border: 1px solid #404040; padding: 48px 32px;
|
198 |
+
border-radius: 16px; text-align: center; margin-bottom: 32px;
|
199 |
+
}}
|
200 |
+
.header h1 {{ color: #ffffff; font-size: 2.5em; font-weight: 700; margin-bottom: 16px; }}
|
201 |
+
.header p {{ color: #b0b0b0; font-size: 1.2em; font-weight: 500; }}
|
202 |
+
.content-section {{
|
203 |
+
background: #2a2a2a; border: 1px solid #404040; padding: 32px;
|
204 |
+
border-radius: 16px; margin-bottom: 24px;
|
205 |
+
}}
|
206 |
+
.content-section h2 {{ color: #ffffff; font-weight: 600; margin-bottom: 24px;
|
207 |
+
padding-bottom: 12px; border-bottom: 1px solid #404040; }}
|
208 |
+
.processing-info {{ background: #333333; border: 1px solid #404040; padding: 24px;
|
209 |
+
border-radius: 12px; margin-bottom: 24px; }}
|
210 |
+
.info-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
211 |
+
gap: 16px; margin-top: 16px; }}
|
212 |
+
.info-item {{ background: #2a2a2a; border: 1px solid #404040; padding: 16px; border-radius: 8px; }}
|
213 |
+
.content {{ background: #333333; padding: 24px; border-radius: 12px;
|
214 |
+
border: 1px solid #404040; font-size: 16px; line-height: 1.8; }}
|
215 |
+
.image-container {{ text-align: center; margin: 24px 0; padding: 24px;
|
216 |
+
background: #333333; border: 1px solid #404040; border-radius: 12px; }}
|
217 |
+
.image-container h3 {{ color: #ffffff; font-weight: 600; margin-bottom: 16px; }}
|
218 |
+
.footer {{ text-align: center; padding: 32px; margin-top: 40px; background: #2a2a2a;
|
219 |
+
border: 1px solid #404040; border-radius: 16px; }}
|
220 |
+
.footer h3 {{ color: #ffffff; font-weight: 600; margin-bottom: 16px; }}
|
221 |
+
.footer p {{ color: #b0b0b0; font-size: 1.1em; }}
|
222 |
+
</style>
|
223 |
+
</head>
|
224 |
+
<body>
|
225 |
+
<div class="container">
|
226 |
+
<div class="header">
|
227 |
+
<h1>📄 OCR Results</h1>
|
228 |
+
<p>Generated by Luna's OCR Studio</p>
|
229 |
+
</div>
|
230 |
+
{info_html}
|
231 |
+
{image_html}
|
232 |
+
<div class="content-section">
|
233 |
+
<h2>Extracted Content</h2>
|
234 |
+
<div class="content">{html_content}</div>
|
235 |
+
</div>
|
236 |
+
<div class="footer">
|
237 |
+
<h3>Made with Luna's OCR Studio</h3>
|
238 |
+
<p>Powered by Google Gemini 2.5</p>
|
239 |
+
<p style="margin-top: 16px; font-size: 0.9em; color: #888;">
|
240 |
+
Generated on {datetime.now().strftime('%Y-%m-%d at %H:%M:%S')}
|
241 |
+
</p>
|
242 |
+
</div>
|
243 |
+
</div>
|
244 |
+
</body>
|
245 |
+
</html>
|
246 |
+
"""
|
247 |
+
return html_template
|
248 |
+
|
249 |
+
def create_all_downloads(content, filename, is_structured=False, processing_info=None, image=None):
|
250 |
+
"""Create all download formats"""
|
251 |
+
|
252 |
+
base_filename = os.path.splitext(filename)[0]
|
253 |
+
files = {}
|
254 |
+
|
255 |
+
try:
|
256 |
+
# 1. Create .txt file (clean text)
|
257 |
+
txt_content = content
|
258 |
+
if is_structured:
|
259 |
+
txt_content = re.sub(r'\*\*(.*?)\*\*', r'\1', txt_content)
|
260 |
+
txt_content = re.sub(r'\*(.*?)\*', r'\1', txt_content)
|
261 |
+
txt_content = re.sub(r'^#+\s*', '', txt_content, flags=re.MULTILINE)
|
262 |
+
|
263 |
+
txt_path = tempfile.mktemp(suffix='.txt')
|
264 |
+
with open(txt_path, 'w', encoding='utf-8') as f:
|
265 |
+
f.write(f"Generated by Luna's OCR Studio\n")
|
266 |
+
f.write(f"File: {filename}\n")
|
267 |
+
f.write(f"Processed: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
268 |
+
f.write("="*50 + "\n\n")
|
269 |
+
f.write(txt_content)
|
270 |
+
files['txt'] = txt_path
|
271 |
+
|
272 |
+
# 2. Create .md file
|
273 |
+
md_content = content if is_structured else f"# OCR Results\n\n{content}"
|
274 |
+
md_path = tempfile.mktemp(suffix='.md')
|
275 |
+
with open(md_path, 'w', encoding='utf-8') as f:
|
276 |
+
f.write(f"<!-- Generated by Luna's OCR Studio -->\n")
|
277 |
+
f.write(f"<!-- File: {filename} -->\n")
|
278 |
+
f.write(f"<!-- Processed: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} -->\n\n")
|
279 |
+
f.write(md_content)
|
280 |
+
files['md'] = md_path
|
281 |
+
|
282 |
+
# 3. Create .html file
|
283 |
+
html_content = create_beautiful_html(content, filename, is_structured, processing_info, image)
|
284 |
+
html_path = tempfile.mktemp(suffix='.html')
|
285 |
+
with open(html_path, 'w', encoding='utf-8') as f:
|
286 |
+
f.write(html_content)
|
287 |
+
files['html'] = html_path
|
288 |
+
|
289 |
+
return files
|
290 |
+
|
291 |
+
except Exception as e:
|
292 |
+
print(f"Error creating files: {e}")
|
293 |
+
return None
|
294 |
+
|
295 |
+
def process_document(file, image, api_key, use_structured, enhance_quality):
|
296 |
+
"""Main processing function - handles both files and images"""
|
297 |
+
if not api_key:
|
298 |
+
return "❌ Please enter your Google API Key", None, None, None, None, "❌ No API key"
|
299 |
+
|
300 |
+
if not file and not image:
|
301 |
+
return "❌ Please upload a file or paste an image", None, None, None, None, "❌ No input"
|
302 |
+
|
303 |
+
start_time = time.time()
|
304 |
+
|
305 |
+
try:
|
306 |
+
# Handle file upload
|
307 |
+
if file:
|
308 |
+
file_path = file.name
|
309 |
+
file_ext = os.path.splitext(file_path)[1].lower()
|
310 |
+
preview_image = None
|
311 |
+
|
312 |
+
if file_ext == '.pdf':
|
313 |
+
# Convert PDF
|
314 |
+
dpi = 400 if enhance_quality else 300
|
315 |
+
images = convert_from_path(file_path, dpi=dpi)
|
316 |
+
if not images:
|
317 |
+
return "❌ Failed to convert PDF", None, None, None, None, "❌ PDF conversion failed"
|
318 |
+
|
319 |
+
preview_image = images[0]
|
320 |
+
all_text = []
|
321 |
+
|
322 |
+
for i, img in enumerate(images):
|
323 |
+
page_text = process_with_gemini(img, api_key, use_structured, enhance_quality)
|
324 |
+
if use_structured:
|
325 |
+
page_text = f"## 📄 หน้า {i + 1}\n\n{page_text}"
|
326 |
+
else:
|
327 |
+
page_text = f"=== หน้า {i + 1} ===\n{page_text}"
|
328 |
+
all_text.append(page_text)
|
329 |
+
|
330 |
+
result_text = "\n\n".join(all_text)
|
331 |
+
filename = os.path.basename(file_path)
|
332 |
+
|
333 |
+
elif file_ext in ['.png', '.jpg', '.jpeg', '.webp', '.bmp']:
|
334 |
+
img = Image.open(file_path)
|
335 |
+
preview_image = img
|
336 |
+
result_text = process_with_gemini(img, api_key, use_structured, enhance_quality)
|
337 |
+
filename = os.path.basename(file_path)
|
338 |
+
else:
|
339 |
+
return "❌ Unsupported format. Use PDF, PNG, JPG", None, None, None, None, "❌ Unsupported format"
|
340 |
+
|
341 |
+
# Handle pasted image
|
342 |
+
else:
|
343 |
+
preview_image = image
|
344 |
+
result_text = process_with_gemini(image, api_key, use_structured, enhance_quality)
|
345 |
+
filename = "Clipboard_Image.png"
|
346 |
+
|
347 |
+
# Create downloads
|
348 |
+
processing_time = time.time() - start_time
|
349 |
+
processing_info = {
|
350 |
+
'filename': filename,
|
351 |
+
'model': 'Gemini 2.5 Pro' if use_structured else 'Gemini 2.5 Flash',
|
352 |
+
'processing_time': processing_time,
|
353 |
+
'characters': len(result_text),
|
354 |
+
'enhanced': enhance_quality,
|
355 |
+
'timestamp': datetime.now().isoformat()
|
356 |
+
}
|
357 |
+
|
358 |
+
download_files = create_all_downloads(
|
359 |
+
result_text,
|
360 |
+
filename,
|
361 |
+
is_structured=use_structured,
|
362 |
+
processing_info=processing_info,
|
363 |
+
image=preview_image
|
364 |
+
)
|
365 |
+
|
366 |
+
status = f"✅ Success! File: {filename} • Time: {processing_time:.1f}s • Characters: {len(result_text):,}"
|
367 |
+
|
368 |
+
if download_files:
|
369 |
+
return (
|
370 |
+
result_text,
|
371 |
+
download_files.get('txt'),
|
372 |
+
download_files.get('md'),
|
373 |
+
download_files.get('html'),
|
374 |
+
preview_image,
|
375 |
+
status
|
376 |
+
)
|
377 |
+
else:
|
378 |
+
return result_text, None, None, None, preview_image, status
|
379 |
+
|
380 |
+
except Exception as e:
|
381 |
+
return f"❌ Error: {str(e)}", None, None, None, None, f"❌ Error processing"
|
382 |
+
|
383 |
+
# === CLEAN, READABLE DESIGN ===
|
384 |
+
|
385 |
+
css = """
|
386 |
+
/* Clean, readable theme with proper contrast */
|
387 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
388 |
+
|
389 |
+
:root {
|
390 |
+
/* Dark theme with excellent readability */
|
391 |
+
--bg-primary: #1a1a1a;
|
392 |
+
--bg-secondary: #2a2a2a;
|
393 |
+
--bg-accent: #333333;
|
394 |
+
--text-primary: #ffffff;
|
395 |
+
--text-secondary: #b0b0b0;
|
396 |
+
--text-muted: #888888;
|
397 |
+
--accent-color: #4a9eff;
|
398 |
+
--accent-hover: #3d8bdb;
|
399 |
+
--success-color: #4caf50;
|
400 |
+
--error-color: #f44336;
|
401 |
+
--border-color: #404040;
|
402 |
+
--border-light: #555555;
|
403 |
+
|
404 |
+
/* Typography */
|
405 |
+
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
406 |
+
--font-size-xs: 12px;
|
407 |
+
--font-size-sm: 14px;
|
408 |
+
--font-size-md: 16px;
|
409 |
+
--font-size-lg: 18px;
|
410 |
+
--font-size-xl: 24px;
|
411 |
+
|
412 |
+
/* Spacing */
|
413 |
+
--spacing-xs: 4px;
|
414 |
+
--spacing-sm: 8px;
|
415 |
+
--spacing-md: 16px;
|
416 |
+
--spacing-lg: 24px;
|
417 |
+
--spacing-xl: 32px;
|
418 |
+
|
419 |
+
/* Radius */
|
420 |
+
--radius-sm: 6px;
|
421 |
+
--radius-md: 8px;
|
422 |
+
--radius-lg: 12px;
|
423 |
+
--radius-xl: 16px;
|
424 |
+
}
|
425 |
+
|
426 |
+
/* Base styles */
|
427 |
+
.gradio-container {
|
428 |
+
background: var(--bg-primary) !important;
|
429 |
+
color: var(--text-primary) !important;
|
430 |
+
font-family: var(--font-family) !important;
|
431 |
+
}
|
432 |
+
|
433 |
+
/* Typography */
|
434 |
+
h1, h2, h3, h4, h5, h6 {
|
435 |
+
font-family: var(--font-family) !important;
|
436 |
+
color: var(--text-primary) !important;
|
437 |
+
font-weight: 600 !important;
|
438 |
+
}
|
439 |
+
|
440 |
+
/* Clean sections */
|
441 |
+
.input-section {
|
442 |
+
background: var(--bg-secondary) !important;
|
443 |
+
border: 1px solid var(--border-color) !important;
|
444 |
+
border-radius: var(--radius-xl) !important;
|
445 |
+
padding: var(--spacing-xl) !important;
|
446 |
+
margin-bottom: var(--spacing-lg) !important;
|
447 |
+
}
|
448 |
+
|
449 |
+
.output-section {
|
450 |
+
background: var(--bg-secondary) !important;
|
451 |
+
border: 1px solid var(--border-color) !important;
|
452 |
+
border-radius: var(--radius-xl) !important;
|
453 |
+
padding: var(--spacing-xl) !important;
|
454 |
+
}
|
455 |
+
|
456 |
+
/* Button styling */
|
457 |
+
.primary-button {
|
458 |
+
background: var(--accent-color) !important;
|
459 |
+
border: none !important;
|
460 |
+
color: white !important;
|
461 |
+
font-weight: 500 !important;
|
462 |
+
font-family: var(--font-family) !important;
|
463 |
+
border-radius: var(--radius-lg) !important;
|
464 |
+
padding: var(--spacing-md) var(--spacing-xl) !important;
|
465 |
+
font-size: var(--font-size-lg) !important;
|
466 |
+
transition: all 0.2s ease !important;
|
467 |
+
}
|
468 |
+
|
469 |
+
.primary-button:hover {
|
470 |
+
background: var(--accent-hover) !important;
|
471 |
+
transform: translateY(-1px) !important;
|
472 |
+
}
|
473 |
+
|
474 |
+
/* Input styling */
|
475 |
+
.gradio-textbox textarea,
|
476 |
+
.gradio-textbox input {
|
477 |
+
background: var(--bg-accent) !important;
|
478 |
+
border: 1px solid var(--border-color) !important;
|
479 |
+
border-radius: var(--radius-md) !important;
|
480 |
+
color: var(--text-primary) !important;
|
481 |
+
font-family: var(--font-family) !important;
|
482 |
+
font-size: var(--font-size-md) !important;
|
483 |
+
padding: var(--spacing-md) !important;
|
484 |
+
}
|
485 |
+
|
486 |
+
.gradio-textbox textarea:focus,
|
487 |
+
.gradio-textbox input:focus {
|
488 |
+
border-color: var(--accent-color) !important;
|
489 |
+
outline: none !important;
|
490 |
+
}
|
491 |
+
|
492 |
+
.gradio-textbox textarea::placeholder,
|
493 |
+
.gradio-textbox input::placeholder {
|
494 |
+
color: var(--text-muted) !important;
|
495 |
+
}
|
496 |
+
|
497 |
+
/* File upload */
|
498 |
+
.gradio-file {
|
499 |
+
border: 2px dashed var(--border-light) !important;
|
500 |
+
border-radius: var(--radius-xl) !important;
|
501 |
+
background: var(--bg-accent) !important;
|
502 |
+
padding: var(--spacing-xl) !important;
|
503 |
+
transition: all 0.2s ease !important;
|
504 |
+
}
|
505 |
+
|
506 |
+
.gradio-file:hover {
|
507 |
+
border-color: var(--accent-color) !important;
|
508 |
+
background: rgba(74, 158, 255, 0.1) !important;
|
509 |
+
}
|
510 |
+
|
511 |
+
/* Image upload */
|
512 |
+
.gradio-image {
|
513 |
+
border: 1px solid var(--border-color) !important;
|
514 |
+
border-radius: var(--radius-lg) !important;
|
515 |
+
background: var(--bg-accent) !important;
|
516 |
+
}
|
517 |
+
|
518 |
+
/* Checkboxes */
|
519 |
+
.gradio-checkbox {
|
520 |
+
background: var(--bg-accent) !important;
|
521 |
+
border: 1px solid var(--border-color) !important;
|
522 |
+
border-radius: var(--radius-md) !important;
|
523 |
+
padding: var(--spacing-sm) !important;
|
524 |
+
}
|
525 |
+
|
526 |
+
/* Download section */
|
527 |
+
.download-container {
|
528 |
+
background: var(--bg-secondary) !important;
|
529 |
+
border: 1px solid var(--border-color) !important;
|
530 |
+
border-radius: var(--radius-lg) !important;
|
531 |
+
padding: var(--spacing-lg) !important;
|
532 |
+
margin-top: var(--spacing-lg) !important;
|
533 |
+
}
|
534 |
+
|
535 |
+
.download-item {
|
536 |
+
display: flex !important;
|
537 |
+
justify-content: space-between !important;
|
538 |
+
align-items: center !important;
|
539 |
+
padding: var(--spacing-md) 0 !important;
|
540 |
+
border-bottom: 1px solid var(--border-color) !important;
|
541 |
+
}
|
542 |
+
|
543 |
+
.download-item:last-child {
|
544 |
+
border-bottom: none !important;
|
545 |
+
}
|
546 |
+
|
547 |
+
.download-item:hover {
|
548 |
+
background: var(--bg-accent) !important;
|
549 |
+
border-radius: var(--radius-sm) !important;
|
550 |
+
margin: 0 calc(-1 * var(--spacing-sm)) !important;
|
551 |
+
padding: var(--spacing-md) var(--spacing-sm) !important;
|
552 |
+
}
|
553 |
+
|
554 |
+
/* Status styling */
|
555 |
+
.status-success {
|
556 |
+
color: var(--success-color) !important;
|
557 |
+
background: rgba(76, 175, 80, 0.1) !important;
|
558 |
+
border: 1px solid rgba(76, 175, 80, 0.3) !important;
|
559 |
+
border-radius: var(--radius-md) !important;
|
560 |
+
padding: var(--spacing-sm) var(--spacing-md) !important;
|
561 |
+
}
|
562 |
+
|
563 |
+
.status-error {
|
564 |
+
color: var(--error-color) !important;
|
565 |
+
background: rgba(244, 67, 54, 0.1) !important;
|
566 |
+
border: 1px solid rgba(244, 67, 54, 0.3) !important;
|
567 |
+
border-radius: var(--radius-md) !important;
|
568 |
+
padding: var(--spacing-sm) var(--spacing-md) !important;
|
569 |
+
}
|
570 |
+
|
571 |
+
/* Responsive */
|
572 |
+
@media (max-width: 768px) {
|
573 |
+
.input-section, .output-section {
|
574 |
+
padding: var(--spacing-lg) !important;
|
575 |
+
}
|
576 |
+
|
577 |
+
.primary-button {
|
578 |
+
width: 100% !important;
|
579 |
+
padding: var(--spacing-md) !important;
|
580 |
+
}
|
581 |
+
}
|
582 |
+
"""
|
583 |
+
|
584 |
+
with gr.Blocks(title="Luna's OCR Studio", css=css, theme=gr.themes.Soft()) as demo:
|
585 |
+
|
586 |
+
# Clean Header
|
587 |
+
gr.HTML("""
|
588 |
+
<div style="
|
589 |
+
text-align: center;
|
590 |
+
padding: 48px 32px;
|
591 |
+
background: var(--bg-secondary);
|
592 |
+
border: 1px solid var(--border-color);
|
593 |
+
border-radius: var(--radius-xl);
|
594 |
+
margin-bottom: var(--spacing-xl);
|
595 |
+
">
|
596 |
+
<h1 style="
|
597 |
+
color: var(--text-primary);
|
598 |
+
font-size: var(--font-size-xl);
|
599 |
+
font-weight: 700;
|
600 |
+
font-family: var(--font-family);
|
601 |
+
margin-bottom: var(--spacing-md);
|
602 |
+
">
|
603 |
+
📄 Luna's OCR Studio
|
604 |
+
</h1>
|
605 |
+
<p style="
|
606 |
+
color: var(--text-secondary);
|
607 |
+
font-size: var(--font-size-lg);
|
608 |
+
font-family: var(--font-family);
|
609 |
+
">
|
610 |
+
Extract text from PDFs and images with AI precision
|
611 |
+
</p>
|
612 |
+
</div>
|
613 |
+
""")
|
614 |
+
|
615 |
+
# Main Interface
|
616 |
+
with gr.Row():
|
617 |
+
# Input Panel
|
618 |
+
with gr.Column(scale=1, elem_classes=["input-section"]):
|
619 |
+
gr.Markdown("## API Configuration")
|
620 |
+
api_key = gr.Textbox(
|
621 |
+
label="Google API Key",
|
622 |
+
placeholder="Enter your Google API key here...",
|
623 |
+
type="password"
|
624 |
+
)
|
625 |
+
|
626 |
+
gr.Markdown("## Upload Document")
|
627 |
+
file_input = gr.File(
|
628 |
+
label="Upload PDF or Image File",
|
629 |
+
file_types=[".pdf", ".png", ".jpg", ".jpeg", ".webp", ".bmp"]
|
630 |
+
)
|
631 |
+
|
632 |
+
gr.Markdown("**OR**")
|
633 |
+
|
634 |
+
gr.Markdown("## Paste Image")
|
635 |
+
image_input = gr.Image(
|
636 |
+
label="Paste from clipboard (Ctrl+V)",
|
637 |
+
type="pil"
|
638 |
+
)
|
639 |
+
|
640 |
+
gr.Markdown("## Processing Options")
|
641 |
+
use_structured = gr.Checkbox(
|
642 |
+
label="Structured Output (Markdown formatting)",
|
643 |
+
value=False
|
644 |
+
)
|
645 |
+
enhance_quality = gr.Checkbox(
|
646 |
+
label="Enhance Image Quality",
|
647 |
+
value=True
|
648 |
+
)
|
649 |
+
|
650 |
+
# Process Button
|
651 |
+
process_btn = gr.Button(
|
652 |
+
"Extract Text",
|
653 |
+
variant="primary",
|
654 |
+
size="lg",
|
655 |
+
elem_classes=["primary-button"]
|
656 |
+
)
|
657 |
+
|
658 |
+
# Output Panel
|
659 |
+
with gr.Column(scale=2, elem_classes=["output-section"]):
|
660 |
+
gr.Markdown("## Status")
|
661 |
+
status_output = gr.Textbox(
|
662 |
+
value="Ready to extract text from your documents",
|
663 |
+
interactive=False,
|
664 |
+
show_label=False
|
665 |
+
)
|
666 |
+
|
667 |
+
gr.Markdown("## Extracted Text")
|
668 |
+
text_output = gr.Textbox(
|
669 |
+
lines=12,
|
670 |
+
max_lines=20,
|
671 |
+
interactive=False,
|
672 |
+
show_label=False,
|
673 |
+
placeholder="Your extracted text will appear here..."
|
674 |
+
)
|
675 |
+
|
676 |
+
gr.Markdown("## Image Preview")
|
677 |
+
image_preview = gr.Image(
|
678 |
+
height=250,
|
679 |
+
interactive=False,
|
680 |
+
show_label=False
|
681 |
+
)
|
682 |
+
|
683 |
+
# Download Section
|
684 |
+
gr.Markdown("## Download Results")
|
685 |
+
with gr.Column(elem_classes=["download-container"], visible=False) as download_section:
|
686 |
+
gr.HTML("""
|
687 |
+
<div style="margin-bottom: var(--spacing-md);">
|
688 |
+
<h3 style="color: var(--text-primary); font-family: var(--font-family); margin-bottom: var(--spacing-sm);">
|
689 |
+
Available Downloads
|
690 |
+
</h3>
|
691 |
+
<p style="color: var(--text-secondary); font-family: var(--font-family); font-size: var(--font-size-sm);">
|
692 |
+
Choose your preferred format
|
693 |
+
</p>
|
694 |
+
</div>
|
695 |
+
""")
|
696 |
+
|
697 |
+
with gr.Row(elem_classes=["download-item"]):
|
698 |
+
gr.HTML("""
|
699 |
+
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
|
700 |
+
<span style="font-size: var(--font-size-lg);">📄</span>
|
701 |
+
<div>
|
702 |
+
<strong style="color: var(--text-primary); font-family: var(--font-family);">TXT File</strong>
|
703 |
+
<br>
|
704 |
+
<small style="color: var(--text-secondary); font-family: var(--font-family);">Clean text without formatting</small>
|
705 |
+
</div>
|
706 |
+
</div>
|
707 |
+
""")
|
708 |
+
download_txt = gr.File(label="Download", visible=False)
|
709 |
+
|
710 |
+
with gr.Row(elem_classes=["download-item"]):
|
711 |
+
gr.HTML("""
|
712 |
+
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
|
713 |
+
<span style="font-size: var(--font-size-lg);">📋</span>
|
714 |
+
<div>
|
715 |
+
<strong style="color: var(--text-primary); font-family: var(--font-family);">Markdown File</strong>
|
716 |
+
<br>
|
717 |
+
<small style="color: var(--text-secondary); font-family: var(--font-family);">Formatted with markdown syntax</small>
|
718 |
+
</div>
|
719 |
+
</div>
|
720 |
+
""")
|
721 |
+
download_md = gr.File(label="Download", visible=False)
|
722 |
+
|
723 |
+
with gr.Row(elem_classes=["download-item"]):
|
724 |
+
gr.HTML("""
|
725 |
+
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
|
726 |
+
<span style="font-size: var(--font-size-lg);">🌐</span>
|
727 |
+
<div>
|
728 |
+
<strong style="color: var(--text-primary); font-family: var(--font-family);">HTML File</strong>
|
729 |
+
<br>
|
730 |
+
<small style="color: var(--text-secondary); font-family: var(--font-family);">Beautiful styled web page</small>
|
731 |
+
</div>
|
732 |
+
</div>
|
733 |
+
""")
|
734 |
+
download_html = gr.File(label="Download", visible=False)
|
735 |
+
|
736 |
+
# Event Handlers
|
737 |
+
def update_downloads(txt, md, html):
|
738 |
+
container_visible = any([txt, md, html])
|
739 |
+
return (
|
740 |
+
gr.update(visible=container_visible),
|
741 |
+
gr.update(value=txt, visible=txt is not None),
|
742 |
+
gr.update(value=md, visible=md is not None),
|
743 |
+
gr.update(value=html, visible=html is not None)
|
744 |
+
)
|
745 |
+
|
746 |
+
process_btn.click(
|
747 |
+
fn=process_document,
|
748 |
+
inputs=[file_input, image_input, api_key, use_structured, enhance_quality],
|
749 |
+
outputs=[text_output, download_txt, download_md, download_html, image_preview, status_output]
|
750 |
+
).then(
|
751 |
+
fn=update_downloads,
|
752 |
+
inputs=[download_txt, download_md, download_html],
|
753 |
+
outputs=[download_section, download_txt, download_md, download_html]
|
754 |
+
)
|
755 |
+
|
756 |
+
if __name__ == "__main__":
|
757 |
+
demo.launch(server_port=7868, share=False, show_error=True)
|
package-lock.json
ADDED
The diff for this file is too large to render.
See raw diff
|
|
package.json
ADDED
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "luna-ocr-web",
|
3 |
+
"version": "1.0.0",
|
4 |
+
"private": true,
|
5 |
+
"dependencies": {
|
6 |
+
"@huggingface/hub": "^2.4.0",
|
7 |
+
"@radix-ui/react-tabs": "^1.0.4",
|
8 |
+
"@react-three/drei": "^10.6.1",
|
9 |
+
"@react-three/fiber": "^9.3.0",
|
10 |
+
"@react-three/rapier": "^2.1.0",
|
11 |
+
"dotenv": "^17.2.1",
|
12 |
+
"framer-motion": "^10.16.4",
|
13 |
+
"gsap": "^3.13.0",
|
14 |
+
"html2canvas": "^1.4.1",
|
15 |
+
"jspdf": "^2.5.1",
|
16 |
+
"lucide-react": "^0.294.0",
|
17 |
+
"marked": "^9.1.2",
|
18 |
+
"meshline": "^3.3.1",
|
19 |
+
"ogl": "^1.0.11",
|
20 |
+
"react": "^19.1.1",
|
21 |
+
"react-dom": "^19.1.1",
|
22 |
+
"react-dropzone": "^14.2.3",
|
23 |
+
"react-scripts": "5.0.1",
|
24 |
+
"react-syntax-highlighter": "^15.5.0",
|
25 |
+
"three": "^0.178.0"
|
26 |
+
},
|
27 |
+
"scripts": {
|
28 |
+
"start": "concurrently \"npm run server\" \"npm run client\"",
|
29 |
+
"client": "react-scripts start",
|
30 |
+
"server": "cd server && npm start",
|
31 |
+
"build": "react-scripts build",
|
32 |
+
"test": "react-scripts test",
|
33 |
+
"eject": "react-scripts eject",
|
34 |
+
"dev": "concurrently \"npm run server\" \"npm run client\" --names \"SERVER,CLIENT\" --prefix-colors \"blue,green\""
|
35 |
+
},
|
36 |
+
"eslintConfig": {
|
37 |
+
"extends": [
|
38 |
+
"react-app",
|
39 |
+
"react-app/jest"
|
40 |
+
]
|
41 |
+
},
|
42 |
+
"browserslist": {
|
43 |
+
"production": [
|
44 |
+
">0.2%",
|
45 |
+
"not dead",
|
46 |
+
"not op_mini all"
|
47 |
+
],
|
48 |
+
"development": [
|
49 |
+
"last 1 chrome version",
|
50 |
+
"last 1 firefox version",
|
51 |
+
"last 1 safari version"
|
52 |
+
]
|
53 |
+
},
|
54 |
+
"devDependencies": {
|
55 |
+
"@types/react": "^19.1.9",
|
56 |
+
"@types/react-dom": "^19.1.7",
|
57 |
+
"concurrently": "^9.2.0"
|
58 |
+
}
|
59 |
+
}
|
public/index.html
ADDED
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
|
4 |
+
<head>
|
5 |
+
<meta charset="utf-8" />
|
6 |
+
<link rel="icon"
|
7 |
+
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌙💛</text></svg>" />
|
8 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
9 |
+
<meta name="theme-color" content="#0a0a0f" />
|
10 |
+
<meta name="description" content="Luna OCR - Advanced Text Extraction from PDFs and Images" />
|
11 |
+
<title>🌙💛 Luna OCR - Text Extraction</title>
|
12 |
+
|
13 |
+
<!-- Preload fonts -->
|
14 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
15 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
16 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
17 |
+
<link href="https://fonts.googleapis.com/css2?family=Martian+Mono:[email protected]&display=swap" rel="stylesheet">
|
18 |
+
|
19 |
+
<!-- Meta tags for better SEO -->
|
20 |
+
<meta property="og:title" content="Luna OCR - Text Extraction" />
|
21 |
+
<meta property="og:description"
|
22 |
+
content="Extract text from PDFs and images using advanced Luna processing. Support for Thai language and structured formatting." />
|
23 |
+
<meta property="og:type" content="website" />
|
24 |
+
<meta name="twitter:card" content="summary_large_image" />
|
25 |
+
|
26 |
+
<style>
|
27 |
+
/* Prevent flash of unstyled content */
|
28 |
+
body {
|
29 |
+
margin: 0;
|
30 |
+
background: #141414 !important;
|
31 |
+
color: #ffffff;
|
32 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif;
|
33 |
+
overflow: hidden;
|
34 |
+
}
|
35 |
+
|
36 |
+
/* Loading screen styles - applied immediately */
|
37 |
+
#loading {
|
38 |
+
position: fixed !important;
|
39 |
+
top: 0 !important;
|
40 |
+
left: 0 !important;
|
41 |
+
width: 100vw !important;
|
42 |
+
height: 100vh !important;
|
43 |
+
background: #141414 !important;
|
44 |
+
z-index: 99999 !important;
|
45 |
+
display: flex !important;
|
46 |
+
align-items: center;
|
47 |
+
justify-content: center;
|
48 |
+
opacity: 1 !important;
|
49 |
+
}
|
50 |
+
|
51 |
+
/* Hide React root initially */
|
52 |
+
#root {
|
53 |
+
opacity: 0;
|
54 |
+
transition: opacity 0.3s ease;
|
55 |
+
}
|
56 |
+
|
57 |
+
/* Show root when loading is done */
|
58 |
+
body.loaded #root {
|
59 |
+
opacity: 1;
|
60 |
+
}
|
61 |
+
|
62 |
+
body.loaded #loading {
|
63 |
+
display: none !important;
|
64 |
+
}
|
65 |
+
</style>
|
66 |
+
</head>
|
67 |
+
|
68 |
+
<body>
|
69 |
+
<noscript>You need to enable JavaScript to run this app.</noscript>
|
70 |
+
|
71 |
+
<!-- Loading screen -->
|
72 |
+
<div id="loading" class="loading-container">
|
73 |
+
<div class="a-hole">
|
74 |
+
<canvas class="js-canvas"></canvas>
|
75 |
+
<div class="aura"></div>
|
76 |
+
</div>
|
77 |
+
</div>
|
78 |
+
|
79 |
+
<div id="root"></div>
|
80 |
+
<script>
|
81 |
+
// Simple loading screen that won't interfere with React
|
82 |
+
document.addEventListener('DOMContentLoaded', function () {
|
83 |
+
const canvas = document.querySelector('.js-canvas');
|
84 |
+
if (!canvas) return;
|
85 |
+
|
86 |
+
const ctx = canvas.getContext('2d');
|
87 |
+
let animationId;
|
88 |
+
|
89 |
+
// Set canvas size
|
90 |
+
function resizeCanvas() {
|
91 |
+
const rect = canvas.parentElement.getBoundingClientRect();
|
92 |
+
canvas.width = rect.width;
|
93 |
+
canvas.height = rect.height;
|
94 |
+
}
|
95 |
+
|
96 |
+
resizeCanvas();
|
97 |
+
window.addEventListener('resize', resizeCanvas);
|
98 |
+
|
99 |
+
// Optimized particle animation - fewer particles for better performance
|
100 |
+
const particles = [];
|
101 |
+
for (let i = 0; i < 25; i++) {
|
102 |
+
particles.push({
|
103 |
+
x: Math.random() * canvas.width,
|
104 |
+
y: Math.random() * canvas.height,
|
105 |
+
vx: (Math.random() - 0.5) * 1.5,
|
106 |
+
vy: (Math.random() - 0.5) * 1.5,
|
107 |
+
size: Math.random() * 2 + 1,
|
108 |
+
opacity: Math.random() * 0.6 + 0.3
|
109 |
+
});
|
110 |
+
}
|
111 |
+
|
112 |
+
function animate() {
|
113 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
114 |
+
|
115 |
+
// Draw particles - all visible immediately
|
116 |
+
particles.forEach(particle => {
|
117 |
+
particle.x += particle.vx;
|
118 |
+
particle.y += particle.vy;
|
119 |
+
|
120 |
+
// Wrap around edges
|
121 |
+
if (particle.x < 0) particle.x = canvas.width;
|
122 |
+
if (particle.x > canvas.width) particle.x = 0;
|
123 |
+
if (particle.y < 0) particle.y = canvas.height;
|
124 |
+
if (particle.y > canvas.height) particle.y = 0;
|
125 |
+
|
126 |
+
ctx.fillStyle = `rgba(255, 255, 255, ${particle.opacity})`;
|
127 |
+
ctx.beginPath();
|
128 |
+
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
|
129 |
+
ctx.fill();
|
130 |
+
});
|
131 |
+
|
132 |
+
animationId = requestAnimationFrame(animate);
|
133 |
+
}
|
134 |
+
|
135 |
+
// Start animation after 500ms delay
|
136 |
+
setTimeout(() => {
|
137 |
+
animate();
|
138 |
+
}, 500);
|
139 |
+
|
140 |
+
// Hide loading screen when React loads
|
141 |
+
function hideLoading() {
|
142 |
+
cancelAnimationFrame(animationId);
|
143 |
+
document.body.classList.add('loaded');
|
144 |
+
}
|
145 |
+
|
146 |
+
// Fast loading screen removal
|
147 |
+
let reactLoaded = false;
|
148 |
+
|
149 |
+
// Check for actual app content (header with Luna OCR)
|
150 |
+
const checkReactLoaded = () => {
|
151 |
+
const root = document.getElementById('root');
|
152 |
+
const appHeader = root?.querySelector('.app-header');
|
153 |
+
const logoContent = root?.querySelector('.logo');
|
154 |
+
|
155 |
+
if (root && root.children.length > 0 && (appHeader || logoContent) && !reactLoaded) {
|
156 |
+
reactLoaded = true;
|
157 |
+
// Small delay to ensure orb and other components are ready
|
158 |
+
setTimeout(hideLoading, 200);
|
159 |
+
}
|
160 |
+
};
|
161 |
+
|
162 |
+
// Fast polling every 100ms
|
163 |
+
const pollInterval = setInterval(() => {
|
164 |
+
checkReactLoaded();
|
165 |
+
if (reactLoaded) {
|
166 |
+
clearInterval(pollInterval);
|
167 |
+
}
|
168 |
+
}, 100);
|
169 |
+
|
170 |
+
// Much faster fallback - hide after 800ms max
|
171 |
+
setTimeout(() => {
|
172 |
+
if (!reactLoaded) {
|
173 |
+
hideLoading();
|
174 |
+
}
|
175 |
+
}, 800);
|
176 |
+
|
177 |
+
// Immediate check on DOM ready
|
178 |
+
if (document.readyState === 'complete') {
|
179 |
+
setTimeout(checkReactLoaded, 100);
|
180 |
+
}
|
181 |
+
});
|
182 |
+
</script>
|
183 |
+
</body>
|
184 |
+
|
185 |
+
</html>
|
public/sample.html
ADDED
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8">
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<title>Sample Document</title>
|
7 |
+
<style>
|
8 |
+
body {
|
9 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
10 |
+
max-width: 800px;
|
11 |
+
margin: 0 auto;
|
12 |
+
padding: 40px 20px;
|
13 |
+
line-height: 1.6;
|
14 |
+
color: #333;
|
15 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
16 |
+
min-height: 100vh;
|
17 |
+
}
|
18 |
+
|
19 |
+
.container {
|
20 |
+
background: rgba(255, 255, 255, 0.95);
|
21 |
+
backdrop-filter: blur(20px);
|
22 |
+
border-radius: 20px;
|
23 |
+
padding: 40px;
|
24 |
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
25 |
+
}
|
26 |
+
|
27 |
+
h1 {
|
28 |
+
color: #2d3748;
|
29 |
+
font-size: 2.5rem;
|
30 |
+
margin-bottom: 1rem;
|
31 |
+
background: linear-gradient(135deg, #667eea, #764ba2);
|
32 |
+
-webkit-background-clip: text;
|
33 |
+
-webkit-text-fill-color: transparent;
|
34 |
+
background-clip: text;
|
35 |
+
}
|
36 |
+
|
37 |
+
h2 {
|
38 |
+
color: #4a5568;
|
39 |
+
font-size: 1.5rem;
|
40 |
+
margin-top: 2rem;
|
41 |
+
margin-bottom: 1rem;
|
42 |
+
border-bottom: 2px solid #e2e8f0;
|
43 |
+
padding-bottom: 0.5rem;
|
44 |
+
}
|
45 |
+
|
46 |
+
.highlight {
|
47 |
+
background: linear-gradient(135deg, #ffd89b 0%, #19547b 100%);
|
48 |
+
color: white;
|
49 |
+
padding: 20px;
|
50 |
+
border-radius: 12px;
|
51 |
+
margin: 20px 0;
|
52 |
+
}
|
53 |
+
|
54 |
+
.info-grid {
|
55 |
+
display: grid;
|
56 |
+
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
57 |
+
gap: 20px;
|
58 |
+
margin: 30px 0;
|
59 |
+
}
|
60 |
+
|
61 |
+
.info-card {
|
62 |
+
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
|
63 |
+
padding: 20px;
|
64 |
+
border-radius: 12px;
|
65 |
+
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
66 |
+
}
|
67 |
+
|
68 |
+
.info-card h3 {
|
69 |
+
margin-top: 0;
|
70 |
+
color: #2d3748;
|
71 |
+
}
|
72 |
+
|
73 |
+
table {
|
74 |
+
width: 100%;
|
75 |
+
border-collapse: collapse;
|
76 |
+
margin: 20px 0;
|
77 |
+
background: white;
|
78 |
+
border-radius: 8px;
|
79 |
+
overflow: hidden;
|
80 |
+
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
81 |
+
}
|
82 |
+
|
83 |
+
th, td {
|
84 |
+
padding: 12px 15px;
|
85 |
+
text-align: left;
|
86 |
+
border-bottom: 1px solid #e2e8f0;
|
87 |
+
}
|
88 |
+
|
89 |
+
th {
|
90 |
+
background: linear-gradient(135deg, #667eea, #764ba2);
|
91 |
+
color: white;
|
92 |
+
font-weight: 600;
|
93 |
+
}
|
94 |
+
|
95 |
+
tr:hover {
|
96 |
+
background-color: #f7fafc;
|
97 |
+
}
|
98 |
+
|
99 |
+
.footer {
|
100 |
+
margin-top: 40px;
|
101 |
+
padding-top: 20px;
|
102 |
+
border-top: 1px solid #e2e8f0;
|
103 |
+
text-align: center;
|
104 |
+
color: #718096;
|
105 |
+
font-size: 0.9rem;
|
106 |
+
}
|
107 |
+
</style>
|
108 |
+
</head>
|
109 |
+
<body>
|
110 |
+
<div class="container">
|
111 |
+
<h1>🌟 Luna OCR Sample Document</h1>
|
112 |
+
|
113 |
+
<div class="highlight">
|
114 |
+
<h2>📋 Document Information</h2>
|
115 |
+
<p>This is a sample HTML document designed to showcase the glassmorphism styling and document viewer capabilities of Luna OCR. The document features modern CSS styling with gradients, blur effects, and responsive design.</p>
|
116 |
+
</div>
|
117 |
+
|
118 |
+
<h2>📊 Sample Data Table</h2>
|
119 |
+
<table>
|
120 |
+
<thead>
|
121 |
+
<tr>
|
122 |
+
<th>Item</th>
|
123 |
+
<th>Category</th>
|
124 |
+
<th>Status</th>
|
125 |
+
<th>Priority</th>
|
126 |
+
</tr>
|
127 |
+
</thead>
|
128 |
+
<tbody>
|
129 |
+
<tr>
|
130 |
+
<td>Document Processing</td>
|
131 |
+
<td>OCR Technology</td>
|
132 |
+
<td>✅ Active</td>
|
133 |
+
<td>High</td>
|
134 |
+
</tr>
|
135 |
+
<tr>
|
136 |
+
<td>Glassmorphism UI</td>
|
137 |
+
<td>Design System</td>
|
138 |
+
<td>✅ Complete</td>
|
139 |
+
<td>Medium</td>
|
140 |
+
</tr>
|
141 |
+
<tr>
|
142 |
+
<td>PDF Viewer</td>
|
143 |
+
<td>File Handling</td>
|
144 |
+
<td>🔄 In Progress</td>
|
145 |
+
<td>High</td>
|
146 |
+
</tr>
|
147 |
+
<tr>
|
148 |
+
<td>HTML Viewer</td>
|
149 |
+
<td>File Handling</td>
|
150 |
+
<td>✅ Complete</td>
|
151 |
+
<td>Medium</td>
|
152 |
+
</tr>
|
153 |
+
</tbody>
|
154 |
+
</table>
|
155 |
+
|
156 |
+
<div class="info-grid">
|
157 |
+
<div class="info-card">
|
158 |
+
<h3>🎨 Design Features</h3>
|
159 |
+
<ul>
|
160 |
+
<li>Glassmorphism effects</li>
|
161 |
+
<li>Gradient backgrounds</li>
|
162 |
+
<li>Smooth animations</li>
|
163 |
+
<li>Responsive layout</li>
|
164 |
+
</ul>
|
165 |
+
</div>
|
166 |
+
|
167 |
+
<div class="info-card">
|
168 |
+
<h3>⚡ Performance</h3>
|
169 |
+
<ul>
|
170 |
+
<li>Fast rendering</li>
|
171 |
+
<li>Optimized CSS</li>
|
172 |
+
<li>Minimal JavaScript</li>
|
173 |
+
<li>Cross-browser support</li>
|
174 |
+
</ul>
|
175 |
+
</div>
|
176 |
+
|
177 |
+
<div class="info-card">
|
178 |
+
<h3>🔧 Technical Stack</h3>
|
179 |
+
<ul>
|
180 |
+
<li>React.js</li>
|
181 |
+
<li>Framer Motion</li>
|
182 |
+
<li>CSS3 Backdrop Filter</li>
|
183 |
+
<li>Modern ES6+</li>
|
184 |
+
</ul>
|
185 |
+
</div>
|
186 |
+
</div>
|
187 |
+
|
188 |
+
<h2>📝 Sample Content</h2>
|
189 |
+
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
190 |
+
|
191 |
+
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
|
192 |
+
|
193 |
+
<div class="highlight">
|
194 |
+
<h3>💡 Key Features</h3>
|
195 |
+
<p>This document demonstrates the advanced styling capabilities of the Luna OCR system, including glassmorphism effects, gradient overlays, and modern typography. The viewer supports zoom, rotation, and fullscreen modes for optimal document viewing experience.</p>
|
196 |
+
</div>
|
197 |
+
|
198 |
+
<div class="footer">
|
199 |
+
<p>Generated by Luna OCR • Sample HTML Document • Glassmorphism Theme</p>
|
200 |
+
</div>
|
201 |
+
</div>
|
202 |
+
</body>
|
203 |
+
</html>
|
server/README.md
ADDED
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Luna OCR Backend
|
2 |
+
|
3 |
+
Real OCR processing backend using Gemini AI for intelligent text extraction and formatting.
|
4 |
+
|
5 |
+
## 🚀 Quick Start
|
6 |
+
|
7 |
+
### 1. Install Dependencies
|
8 |
+
```bash
|
9 |
+
cd server
|
10 |
+
npm install
|
11 |
+
```
|
12 |
+
|
13 |
+
### 2. Start the Server
|
14 |
+
```bash
|
15 |
+
npm start
|
16 |
+
# or for development with auto-reload:
|
17 |
+
npm run dev
|
18 |
+
```
|
19 |
+
|
20 |
+
### 3. Test the API
|
21 |
+
```bash
|
22 |
+
curl http://localhost:3001/api/health
|
23 |
+
```
|
24 |
+
|
25 |
+
## 📡 API Endpoints
|
26 |
+
|
27 |
+
### Health Check
|
28 |
+
```
|
29 |
+
GET /api/health
|
30 |
+
```
|
31 |
+
|
32 |
+
### OCR Processing
|
33 |
+
```
|
34 |
+
POST /api/ocr
|
35 |
+
Content-Type: multipart/form-data
|
36 |
+
|
37 |
+
Parameters:
|
38 |
+
- file: Image file (PNG, JPG, WebP) or PDF
|
39 |
+
- apiKey: Google Gemini API key
|
40 |
+
- mode: "standard" or "structured"
|
41 |
+
```
|
42 |
+
|
43 |
+
## 🔧 Configuration
|
44 |
+
|
45 |
+
### Environment Variables
|
46 |
+
Create a `.env` file (optional):
|
47 |
+
```
|
48 |
+
PORT=3001
|
49 |
+
MAX_FILE_SIZE=10485760
|
50 |
+
```
|
51 |
+
|
52 |
+
### Supported File Types
|
53 |
+
- **Images**: PNG, JPG, JPEG, WebP
|
54 |
+
- **Documents**: PDF (converted to images)
|
55 |
+
- **Max Size**: 10MB per file
|
56 |
+
|
57 |
+
## 🎯 Processing Modes
|
58 |
+
|
59 |
+
### Standard Mode
|
60 |
+
- Uses Gemini 1.5 Flash (faster)
|
61 |
+
- Returns clean plain text
|
62 |
+
- Good for simple text extraction
|
63 |
+
|
64 |
+
### Structured Mode
|
65 |
+
- Uses Gemini 1.5 Pro (more intelligent)
|
66 |
+
- Returns formatted Markdown
|
67 |
+
- Creates tables, headers, lists automatically
|
68 |
+
- Perfect for complex documents
|
69 |
+
|
70 |
+
## 📊 Response Format
|
71 |
+
|
72 |
+
```json
|
73 |
+
{
|
74 |
+
"success": true,
|
75 |
+
"data": {
|
76 |
+
"fileName": "document.png",
|
77 |
+
"fileSize": 1234567,
|
78 |
+
"processingMode": "structured",
|
79 |
+
"extractedText": "# Document Title\n\n...",
|
80 |
+
"formats": {
|
81 |
+
"txt": "plain text version",
|
82 |
+
"md": "markdown version",
|
83 |
+
"json": { "metadata": {...}, "content": {...} }
|
84 |
+
},
|
85 |
+
"metadata": {
|
86 |
+
"characterCount": 1500,
|
87 |
+
"wordCount": 250,
|
88 |
+
"lineCount": 45,
|
89 |
+
"processedAt": "2024-01-01T12:00:00.000Z"
|
90 |
+
}
|
91 |
+
}
|
92 |
+
}
|
93 |
+
```
|
94 |
+
|
95 |
+
## 🛠️ Development
|
96 |
+
|
97 |
+
### Project Structure
|
98 |
+
```
|
99 |
+
server/
|
100 |
+
├── server.js # Main server file
|
101 |
+
├── package.json # Dependencies
|
102 |
+
├── uploads/ # Temporary file storage
|
103 |
+
└── README.md # This file
|
104 |
+
```
|
105 |
+
|
106 |
+
### Key Features
|
107 |
+
- **Image Enhancement**: Automatic image preprocessing for better OCR
|
108 |
+
- **Smart Formatting**: Gemini AI creates beautiful Markdown output
|
109 |
+
- **Multiple Formats**: Returns TXT, MD, and JSON formats
|
110 |
+
- **Error Handling**: Comprehensive error handling and cleanup
|
111 |
+
- **File Cleanup**: Automatic temporary file cleanup
|
112 |
+
|
113 |
+
## 🔑 Getting Gemini API Key
|
114 |
+
|
115 |
+
1. Go to [Google AI Studio](https://makersuite.google.com/app/apikey)
|
116 |
+
2. Create a new API key
|
117 |
+
3. Copy the key and use it in the frontend
|
118 |
+
|
119 |
+
## 🚨 Troubleshooting
|
120 |
+
|
121 |
+
### Common Issues
|
122 |
+
|
123 |
+
**"Cannot connect to OCR backend"**
|
124 |
+
- Make sure server is running: `npm start`
|
125 |
+
- Check port 3001 is not in use
|
126 |
+
- Verify no firewall blocking
|
127 |
+
|
128 |
+
**"Invalid API key"**
|
129 |
+
- Check your Gemini API key is correct
|
130 |
+
- Ensure API key has proper permissions
|
131 |
+
- Try creating a new API key
|
132 |
+
|
133 |
+
**"File too large"**
|
134 |
+
- Maximum file size is 10MB
|
135 |
+
- Compress images before uploading
|
136 |
+
- For PDFs, try splitting into smaller files
|
137 |
+
|
138 |
+
**"Processing failed"**
|
139 |
+
- Check image quality (not too blurry)
|
140 |
+
- Ensure text is clearly visible
|
141 |
+
- Try different processing mode
|
142 |
+
|
143 |
+
### Debug Mode
|
144 |
+
Set `NODE_ENV=development` for detailed logging:
|
145 |
+
```bash
|
146 |
+
NODE_ENV=development npm start
|
147 |
+
```
|
148 |
+
|
149 |
+
## 📝 Notes
|
150 |
+
|
151 |
+
- Server runs on port 3001 by default
|
152 |
+
- Temporary files are automatically cleaned up
|
153 |
+
- CORS is enabled for frontend integration
|
154 |
+
- Image enhancement improves OCR accuracy
|
155 |
+
- Gemini AI provides intelligent text formatting
|
156 |
+
|
157 |
+
## 🔗 Integration
|
158 |
+
|
159 |
+
The backend integrates seamlessly with the Luna OCR React frontend. Make sure both are running:
|
160 |
+
|
161 |
+
1. **Backend**: `cd server && npm start` (port 3001)
|
162 |
+
2. **Frontend**: `npm start` (port 3000)
|
163 |
+
|
164 |
+
The frontend will automatically call the backend API for real OCR processing!
|
server/package-lock.json
ADDED
The diff for this file is too large to render.
See raw diff
|
|
server/package.json
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "luna-ocr-backend",
|
3 |
+
"version": "1.0.0",
|
4 |
+
"description": "Backend API for Luna OCR with advanced text extraction",
|
5 |
+
"main": "server.js",
|
6 |
+
"scripts": {
|
7 |
+
"start": "node server.js",
|
8 |
+
"dev": "nodemon server.js"
|
9 |
+
},
|
10 |
+
"dependencies": {
|
11 |
+
"@google/generative-ai": "^0.2.1",
|
12 |
+
"cors": "^2.8.5",
|
13 |
+
"express": "^4.18.2",
|
14 |
+
"jimp": "^0.22.10",
|
15 |
+
"multer": "^1.4.5-lts.1",
|
16 |
+
"node-tesseract-ocr": "^2.2.1",
|
17 |
+
"pdf-poppler": "^0.2.1",
|
18 |
+
"sharp": "^0.32.6"
|
19 |
+
},
|
20 |
+
"devDependencies": {
|
21 |
+
"nodemon": "^3.0.2"
|
22 |
+
}
|
23 |
+
}
|
server/server.js
ADDED
@@ -0,0 +1,693 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const express = require('express');
|
2 |
+
const cors = require('cors');
|
3 |
+
const multer = require('multer');
|
4 |
+
const sharp = require('sharp');
|
5 |
+
const { GoogleGenerativeAI } = require('@google/generative-ai');
|
6 |
+
const fs = require('fs');
|
7 |
+
const path = require('path');
|
8 |
+
|
9 |
+
// Server-side API key (more secure for public deployment)
|
10 |
+
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || null;
|
11 |
+
const pdf = require('pdf-poppler');
|
12 |
+
|
13 |
+
const app = express();
|
14 |
+
const PORT = process.env.PORT || 3002;
|
15 |
+
|
16 |
+
// Cleanup function for temporary files
|
17 |
+
function cleanupTempFiles() {
|
18 |
+
const uploadsDir = path.join(__dirname, 'uploads');
|
19 |
+
|
20 |
+
try {
|
21 |
+
if (!fs.existsSync(uploadsDir)) {
|
22 |
+
fs.mkdirSync(uploadsDir, { recursive: true });
|
23 |
+
console.log('📁 Created uploads directory');
|
24 |
+
return;
|
25 |
+
}
|
26 |
+
|
27 |
+
const files = fs.readdirSync(uploadsDir);
|
28 |
+
let cleanedCount = 0;
|
29 |
+
|
30 |
+
files.forEach(file => {
|
31 |
+
const filePath = path.join(uploadsDir, file);
|
32 |
+
const stats = fs.statSync(filePath);
|
33 |
+
const now = new Date();
|
34 |
+
const fileAge = now - stats.mtime; // Age in milliseconds
|
35 |
+
const maxAge = 30 * 60 * 1000; // 30 minutes in milliseconds
|
36 |
+
|
37 |
+
// Clean up files older than 30 minutes
|
38 |
+
if (fileAge > maxAge) {
|
39 |
+
try {
|
40 |
+
fs.unlinkSync(filePath);
|
41 |
+
cleanedCount++;
|
42 |
+
console.log(`🗑️ Cleaned up old temp file: ${file}`);
|
43 |
+
} catch (error) {
|
44 |
+
console.warn(`⚠️ Could not delete ${file}:`, error.message);
|
45 |
+
}
|
46 |
+
}
|
47 |
+
});
|
48 |
+
|
49 |
+
if (cleanedCount > 0) {
|
50 |
+
console.log(`✅ Cleaned up ${cleanedCount} temporary files`);
|
51 |
+
} else {
|
52 |
+
console.log('✅ No temporary files to clean up');
|
53 |
+
}
|
54 |
+
|
55 |
+
} catch (error) {
|
56 |
+
console.error('❌ Error during cleanup:', error.message);
|
57 |
+
}
|
58 |
+
}
|
59 |
+
|
60 |
+
// Run cleanup on server start
|
61 |
+
cleanupTempFiles();
|
62 |
+
|
63 |
+
// Schedule periodic cleanup every 15 minutes
|
64 |
+
setInterval(() => {
|
65 |
+
console.log('🔄 Running periodic cleanup...');
|
66 |
+
cleanupTempFiles();
|
67 |
+
}, 15 * 60 * 1000); // 15 minutes
|
68 |
+
|
69 |
+
// Middleware
|
70 |
+
app.use(cors());
|
71 |
+
app.use(express.json());
|
72 |
+
|
73 |
+
// Configure multer for file uploads
|
74 |
+
const upload = multer({
|
75 |
+
dest: 'uploads/',
|
76 |
+
limits: {
|
77 |
+
fileSize: 10 * 1024 * 1024, // 10MB limit
|
78 |
+
},
|
79 |
+
fileFilter: (req, file, cb) => {
|
80 |
+
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
|
81 |
+
if (allowedTypes.includes(file.mimetype)) {
|
82 |
+
cb(null, true);
|
83 |
+
} else {
|
84 |
+
cb(new Error('Invalid file type. Only JPEG, PNG, WebP, and PDF are allowed.'));
|
85 |
+
}
|
86 |
+
}
|
87 |
+
});
|
88 |
+
|
89 |
+
// PDF to image conversion using pdf-poppler
|
90 |
+
async function convertPdfToImages(pdfPath) {
|
91 |
+
try {
|
92 |
+
const outputDir = path.dirname(pdfPath);
|
93 |
+
|
94 |
+
console.log('Converting PDF to images using pdf-poppler...');
|
95 |
+
|
96 |
+
const options = {
|
97 |
+
format: 'png',
|
98 |
+
out_dir: outputDir,
|
99 |
+
out_prefix: 'page',
|
100 |
+
page: null, // Convert all pages
|
101 |
+
scale: 2048 // High resolution for better OCR
|
102 |
+
};
|
103 |
+
|
104 |
+
console.log('PDF conversion options:', options);
|
105 |
+
|
106 |
+
// Convert PDF to images
|
107 |
+
const results = await pdf.convert(pdfPath, options);
|
108 |
+
|
109 |
+
console.log('PDF conversion results:', results);
|
110 |
+
|
111 |
+
// Build image paths based on the results
|
112 |
+
const imagePaths = [];
|
113 |
+
if (Array.isArray(results)) {
|
114 |
+
for (let i = 0; i < results.length; i++) {
|
115 |
+
const imagePath = path.join(outputDir, `page-${i + 1}.png`);
|
116 |
+
if (fs.existsSync(imagePath)) {
|
117 |
+
imagePaths.push(imagePath);
|
118 |
+
console.log(`Found converted page: ${imagePath}`);
|
119 |
+
}
|
120 |
+
}
|
121 |
+
}
|
122 |
+
|
123 |
+
// If no images found with the expected naming, try alternative naming
|
124 |
+
if (imagePaths.length === 0) {
|
125 |
+
console.log('Trying alternative file naming patterns...');
|
126 |
+
const files = fs.readdirSync(outputDir);
|
127 |
+
const pngFiles = files.filter(file => file.endsWith('.png') && file.startsWith('page'));
|
128 |
+
|
129 |
+
for (const file of pngFiles) {
|
130 |
+
const fullPath = path.join(outputDir, file);
|
131 |
+
imagePaths.push(fullPath);
|
132 |
+
console.log(`Found PNG file: ${fullPath}`);
|
133 |
+
}
|
134 |
+
}
|
135 |
+
|
136 |
+
console.log(`Successfully converted ${imagePaths.length} pages to images`);
|
137 |
+
return imagePaths;
|
138 |
+
} catch (error) {
|
139 |
+
console.error('PDF conversion error:', error);
|
140 |
+
throw new Error(`PDF conversion failed: ${error.message}`);
|
141 |
+
}
|
142 |
+
}
|
143 |
+
|
144 |
+
// Enhanced image preprocessing
|
145 |
+
async function enhanceImageForOCR(imagePath) {
|
146 |
+
try {
|
147 |
+
// Create a unique enhanced filename to avoid input/output conflict
|
148 |
+
const enhancedPath = imagePath + '_enhanced.png';
|
149 |
+
|
150 |
+
await sharp(imagePath)
|
151 |
+
.resize(null, 2000, {
|
152 |
+
withoutEnlargement: false,
|
153 |
+
kernel: sharp.kernel.lanczos3
|
154 |
+
})
|
155 |
+
.normalize()
|
156 |
+
.sharpen({ sigma: 1.2, flat: 1, jagged: 2 })
|
157 |
+
.gamma(1.1)
|
158 |
+
.png({ quality: 95, compressionLevel: 6 })
|
159 |
+
.toFile(enhancedPath);
|
160 |
+
|
161 |
+
return enhancedPath;
|
162 |
+
} catch (error) {
|
163 |
+
console.error('Image enhancement error:', error);
|
164 |
+
return imagePath; // Return original if enhancement fails
|
165 |
+
}
|
166 |
+
}
|
167 |
+
|
168 |
+
// Gemini OCR processing with intelligent formatting
|
169 |
+
async function processWithGemini(imagePath, apiKey, mode = 'standard') {
|
170 |
+
try {
|
171 |
+
const genAI = new GoogleGenerativeAI(apiKey);
|
172 |
+
|
173 |
+
// Choose model based on mode (using 2.5 models as specified)
|
174 |
+
const modelName = mode === 'structured' ? 'gemini-2.5-flash' : 'gemini-2.5-pro';
|
175 |
+
const model = genAI.getGenerativeModel({ model: modelName });
|
176 |
+
|
177 |
+
// Read and prepare image
|
178 |
+
const imageBuffer = fs.readFileSync(imagePath);
|
179 |
+
const imageBase64 = imageBuffer.toString('base64');
|
180 |
+
|
181 |
+
const imagePart = {
|
182 |
+
inlineData: {
|
183 |
+
data: imageBase64,
|
184 |
+
mimeType: 'image/png'
|
185 |
+
}
|
186 |
+
};
|
187 |
+
|
188 |
+
// Choose prompt based on mode
|
189 |
+
let prompt;
|
190 |
+
if (mode === 'structured') {
|
191 |
+
prompt = `🇹🇭 **THAI-FOCUSED MARKDOWN OCR - อ่านข้อความไทยและจัดรูปแบบเป็น Markdown**
|
192 |
+
|
193 |
+
**คำสั่งสำคัญ (CRITICAL INSTRUCTIONS):**
|
194 |
+
1. **อ่านทุกตัวอักษร** - แยกข้อความที่เขียนในรูปภาพทุกตัว
|
195 |
+
2. **ไม่ใช้ตัวอย่างทั่วไป** - ห้ามใช้ "Field | Value" ให้ใช้ข้อความจริงที่เห็น
|
196 |
+
3. **รักษาภาษาเดิม** - ถ้าเป็นไทยให้เป็นไทย ถ้าเป็นอังกฤษให้เป็นอังกฤษ
|
197 |
+
4. **ข้อความจริงเท่านั้น** - ห้ามตีความ แปล หรือสร้างคำอธิบายเอง
|
198 |
+
|
199 |
+
**วิธีการสแกน:**
|
200 |
+
- เริ่มจากซ้ายบน อ่านทีละบรรทัดไปขวาล่าง
|
201 |
+
- รวมข้อความทั้งหมด: หัวข้อ เนื้อหา ตัวเลข วันที่ ข้อความเล็ก
|
202 |
+
- **สำหรับข้อความไทย: เว้นวรรคระหว่างคำให้ถูกต้อง**
|
203 |
+
- สำหรับตาราง: ใช้หัวตารางและข้อมูลจริงที่เห็น
|
204 |
+
- สำหรับรายการ: ใช้รายการจริงที่เห็น
|
205 |
+
|
206 |
+
**กฎการจัดรูปแบบ MARKDOWN:**
|
207 |
+
- ## สำหรับหัวข้อใหญ่ (ใช้หัวข้อจริง)
|
208 |
+
- ### สำหรับหัวข้อย่อย (ใช้หัวข้อจริง)
|
209 |
+
- **ใช้ตารางเสมอสำหรับข้อมูลที่เป็นระบบ:**
|
210 |
+
- เห็นตาราง → สร้างตารางด้วยหัวตารางและข้อมูลจริง
|
211 |
+
- เห็นรายการ → แปลงเป็นตารางด้วยรายการจริง
|
212 |
+
- เห็นคู่ข้อมูล → ใช้คีย์และค่าจริง
|
213 |
+
- ใช้ **ตัวหนา** สำหรับข้อความที่เน้นในรูป
|
214 |
+
- ใช้ > สำหรับหมายเหตุที่ปรากฏในรูป
|
215 |
+
|
216 |
+
**ตัวอย่างตาราง:**
|
217 |
+
| รายการ | จำนวน | ราคา |
|
218 |
+
|--------|--------|------|
|
219 |
+
| กาแฟ | 2 แก้ว | 60 บาท |
|
220 |
+
| ขนมปัง | 1 ชิ้น | 25 บาท |
|
221 |
+
|
222 |
+
**ผลลัพธ์: MARKDOWN ที่มีเนื้อหาจริง - ห้ามใช้แม่แบบทั่วไป**`;
|
223 |
+
} else {
|
224 |
+
prompt = `🇹🇭 **FAST MARKDOWN OCR - SAME FEATURES AS PRO MODE (2.5 FLASH)**
|
225 |
+
|
226 |
+
**คำสั่งสำคัญ (IDENTICAL TO STRUCTURED MODE):**
|
227 |
+
1. **อ่านทุกตัวอักษร** - แยกข้อความที่เขียนในรูปภาพทุกตัว
|
228 |
+
2. **ไม่ใช้ตัวอย่างทั่วไป** - ห้ามใช้ "Field | Value" ให้ใช้ข้อความจริงที่เห็น
|
229 |
+
3. **รักษาภาษาเดิม** - ถ��าเป็นไทยให้เป็นไทย ถ้าเป็นอังกฤษให้เป็นอังกฤษ
|
230 |
+
4. **ข้อความจริงเท่านั้น** - ห้ามตีความ แปล หรือสร้างคำอธิบายเอง
|
231 |
+
|
232 |
+
**วิธีการสแกน (SAME AS PRO MODE):**
|
233 |
+
- เริ่มจากซ้ายบน อ่านทีละบรรทัดไปขวาล่าง
|
234 |
+
- รวมข้อความทั้งหมด: หัวข้อ เนื้อหา ตัวเลข วันที่ ข้อความเล็ก
|
235 |
+
- **สำหรับข้อความไทย: เว้นวรรคระหว่างคำให้ถูกต้อง**
|
236 |
+
- สำหรับตาราง: ใช้หัวตารางและข้อมูลจริงที่เห็น
|
237 |
+
- สำหรับรายการ: ใช้รายการจริงที่เห็น
|
238 |
+
|
239 |
+
**MARKDOWN FORMATTING RULES (IDENTICAL TO PRO MODE):**
|
240 |
+
- ## สำหรับหัวข้อใหญ่ (ใช้หัวข้อจริง)
|
241 |
+
- ### สำหรับหัวข้อย่อย (ใช้หัวข้อจริง)
|
242 |
+
- **ใช้ตารางเสมอสำหรับข้อมูลที่เป็นระบบ:**
|
243 |
+
- เห็นตาราง → สร้างตาราง markdown ด้วยหัวตารางและข้อมูลจริง
|
244 |
+
- เห็นรายการ → แปลงเป็นตาราง markdown ด้วยรายการจริง
|
245 |
+
- เห็นคู่ข้อมูล → ใช้คีย์และค่าจริงในตาราง markdown
|
246 |
+
- ใช้ **ตัวหนา** สำหรับข้อความที่เน้นในรูป
|
247 |
+
- ใช้ > สำหรับหมายเหตุที่ปรากฏในรูป
|
248 |
+
- ใช้ - สำหรับรายการเมื่อเหมาะสม
|
249 |
+
|
250 |
+
**การประมวลผลภาษา (SAME AS PRO MODE):**
|
251 |
+
- ข้อความไทย: เว้นวรรคให้ถูกต้อง แก้ไขข้อผิดพลาด OCR
|
252 |
+
- ข้อความอังกฤษ: รักษาการสะกดและตัวพิมพ์เดิม
|
253 |
+
- ตัวเลข: แยกตัวเลข ทศนิยม เปอร์เซ็นต์ รหัสให้แม่นยำ
|
254 |
+
- ภาษาผสม: รักษาภาษาเดิมของแต่ละส่วน
|
255 |
+
|
256 |
+
**ตัวอย่างตาราง MARKDOWN:**
|
257 |
+
| รายการ | จำนวน | ราคา |
|
258 |
+
|--------|--------|------|
|
259 |
+
| กาแฟ อเมริกาโน่ | 2 แก้ว | 120 บาท |
|
260 |
+
| ขนมปังโฮลวีท | 1 ชิ้น | 45 บาท |
|
261 |
+
|
262 |
+
**ผลลัพธ์: MARKDOWN WITH SAME FEATURES AS PRO MODE - JUST FASTER WITH 2.5 FLASH**`;
|
263 |
+
}
|
264 |
+
|
265 |
+
const result = await model.generateContent([prompt, imagePart]);
|
266 |
+
const response = await result.response;
|
267 |
+
let extractedText = response.text();
|
268 |
+
|
269 |
+
// Both modes now support the same markdown formatting
|
270 |
+
// No cleanup needed - both modes produce markdown output
|
271 |
+
|
272 |
+
return extractedText;
|
273 |
+
|
274 |
+
} catch (error) {
|
275 |
+
console.error('Gemini processing error:', error);
|
276 |
+
throw new Error(`OCR processing failed: ${error.message}`);
|
277 |
+
}
|
278 |
+
}
|
279 |
+
|
280 |
+
// Generate different format outputs
|
281 |
+
function generateFormats(text, fileName, mode) {
|
282 |
+
const baseFileName = fileName.replace(/\.[^/.]+$/, '');
|
283 |
+
const timestamp = new Date().toISOString();
|
284 |
+
|
285 |
+
const formats = {
|
286 |
+
// Plain text
|
287 |
+
txt: text,
|
288 |
+
|
289 |
+
// Markdown (always available for both modes)
|
290 |
+
md: mode === 'structured' ? text : `# ${baseFileName}\n\n${text}`,
|
291 |
+
|
292 |
+
// JSON format
|
293 |
+
json: {
|
294 |
+
metadata: {
|
295 |
+
fileName: fileName,
|
296 |
+
extractedAt: timestamp,
|
297 |
+
characterCount: text.length,
|
298 |
+
lineCount: text.split('\n').length,
|
299 |
+
wordCount: text.split(/\s+/).filter(word => word.length > 0).length,
|
300 |
+
processingMode: mode
|
301 |
+
},
|
302 |
+
content: {
|
303 |
+
rawText: text,
|
304 |
+
lines: text.split('\n'),
|
305 |
+
paragraphs: text.split('\n\n').filter(p => p.trim().length > 0)
|
306 |
+
}
|
307 |
+
}
|
308 |
+
};
|
309 |
+
|
310 |
+
return formats;
|
311 |
+
}
|
312 |
+
|
313 |
+
// Progress tracking
|
314 |
+
const progressTracking = new Map();
|
315 |
+
const consoleLogs = new Map(); // Store console logs per session
|
316 |
+
|
317 |
+
// Progress endpoint
|
318 |
+
app.get('/api/progress/:sessionId', (req, res) => {
|
319 |
+
const sessionId = req.params.sessionId;
|
320 |
+
const progress = progressTracking.get(sessionId) || { current: 0, total: 0, status: 'Not started' };
|
321 |
+
res.json(progress);
|
322 |
+
});
|
323 |
+
|
324 |
+
// Latest progress endpoint (gets the most recent progress)
|
325 |
+
app.get('/api/progress-latest', (req, res) => {
|
326 |
+
if (progressTracking.size === 0) {
|
327 |
+
return res.json({ current: 0, total: 0, status: 'No active processing' });
|
328 |
+
}
|
329 |
+
|
330 |
+
// Get the most recent progress entry
|
331 |
+
const entries = Array.from(progressTracking.entries());
|
332 |
+
const latestEntry = entries[entries.length - 1];
|
333 |
+
// Sending progress data to frontend
|
334 |
+
res.json(latestEntry[1]);
|
335 |
+
});
|
336 |
+
|
337 |
+
// Main OCR endpoint
|
338 |
+
app.post('/api/ocr', upload.single('file'), async (req, res) => {
|
339 |
+
const sessionId = Date.now().toString();
|
340 |
+
|
341 |
+
try {
|
342 |
+
const { mode = 'standard' } = req.body;
|
343 |
+
|
344 |
+
// Use server-side API key if available, otherwise require from client
|
345 |
+
const apiKey = GEMINI_API_KEY || req.body.apiKey;
|
346 |
+
|
347 |
+
if (!apiKey) {
|
348 |
+
return res.status(400).json({
|
349 |
+
success: false,
|
350 |
+
error: GEMINI_API_KEY ? 'Server API key not configured' : 'Google API Key is required'
|
351 |
+
});
|
352 |
+
}
|
353 |
+
|
354 |
+
if (!req.file) {
|
355 |
+
return res.status(400).json({
|
356 |
+
success: false,
|
357 |
+
error: 'No file uploaded'
|
358 |
+
});
|
359 |
+
}
|
360 |
+
|
361 |
+
let extractedText = '';
|
362 |
+
let imagePaths = [];
|
363 |
+
|
364 |
+
// Console log capture helper
|
365 |
+
const addConsoleLog = (message) => {
|
366 |
+
if (!consoleLogs.has(sessionId)) {
|
367 |
+
consoleLogs.set(sessionId, []);
|
368 |
+
}
|
369 |
+
const logs = consoleLogs.get(sessionId);
|
370 |
+
logs.push({
|
371 |
+
timestamp: new Date().toISOString(),
|
372 |
+
message: message
|
373 |
+
});
|
374 |
+
// Keep only last 20 logs to prevent memory issues
|
375 |
+
if (logs.length > 20) {
|
376 |
+
logs.shift();
|
377 |
+
}
|
378 |
+
console.log(message);
|
379 |
+
};
|
380 |
+
|
381 |
+
addConsoleLog(`🚀 Processing file: ${req.file.originalname} in ${mode} mode`);
|
382 |
+
|
383 |
+
// Progress update helper
|
384 |
+
const updateProgress = (current, total, status, details = {}) => {
|
385 |
+
const progressData = {
|
386 |
+
current,
|
387 |
+
total,
|
388 |
+
status,
|
389 |
+
sessionId,
|
390 |
+
fileName: req.file.originalname,
|
391 |
+
consoleLogs: consoleLogs.get(sessionId) || [],
|
392 |
+
...details
|
393 |
+
};
|
394 |
+
progressTracking.set(sessionId, progressData);
|
395 |
+
addConsoleLog(`Progress: ${current}/${total} - ${status}`);
|
396 |
+
};
|
397 |
+
|
398 |
+
// Handle PDF files
|
399 |
+
if (req.file.mimetype === 'application/pdf') {
|
400 |
+
addConsoleLog('🔄 Starting PDF processing...');
|
401 |
+
addConsoleLog(`📄 PDF file: ${req.file.originalname}`);
|
402 |
+
addConsoleLog(`📊 File size: ${(req.file.size / 1024).toFixed(2)} KB`);
|
403 |
+
|
404 |
+
updateProgress(1, 10, '🖼️ Converting PDF to images...');
|
405 |
+
const pdfImagePaths = await convertPdfToImages(req.file.path);
|
406 |
+
|
407 |
+
if (pdfImagePaths.length === 0) {
|
408 |
+
throw new Error('No pages could be extracted from PDF. The PDF might be corrupted or empty.');
|
409 |
+
}
|
410 |
+
|
411 |
+
addConsoleLog(`✅ Converted ${pdfImagePaths.length} pages to images`);
|
412 |
+
|
413 |
+
// Calculate total steps: 2 initial + (2 steps per page)
|
414 |
+
const totalSteps = 2 + (pdfImagePaths.length * 2);
|
415 |
+
updateProgress(2, totalSteps, `📋 Found ${pdfImagePaths.length} pages to process`, {
|
416 |
+
totalPages: pdfImagePaths.length,
|
417 |
+
currentPage: 0,
|
418 |
+
totalCharacters: 0
|
419 |
+
});
|
420 |
+
|
421 |
+
// Process each page with OCR
|
422 |
+
for (let i = 0; i < pdfImagePaths.length; i++) {
|
423 |
+
const currentPage = i + 1;
|
424 |
+
const totalPages = pdfImagePaths.length;
|
425 |
+
|
426 |
+
addConsoleLog(`🔍 Processing page ${currentPage}/${totalPages} (${Math.round((currentPage / totalPages) * 100)}%)`);
|
427 |
+
|
428 |
+
// Update progress for enhancement step
|
429 |
+
const enhanceStep = 2 + (i * 2) + 1;
|
430 |
+
updateProgress(enhanceStep, totalSteps, `🔧 Enhancing page ${currentPage}/${totalPages}`, {
|
431 |
+
totalPages,
|
432 |
+
currentPage,
|
433 |
+
totalCharacters: extractedText.length,
|
434 |
+
phase: 'enhancing'
|
435 |
+
});
|
436 |
+
|
437 |
+
addConsoleLog(`📝 Enhancing image: page-${currentPage}.png`);
|
438 |
+
const enhancedImagePath = await enhanceImageForOCR(pdfImagePaths[i]);
|
439 |
+
|
440 |
+
// Update progress for OCR step
|
441 |
+
const ocrStep = 2 + (i * 2) + 2;
|
442 |
+
updateProgress(ocrStep, totalSteps, `🤖 Running OCR on page ${currentPage}/${totalPages}`, {
|
443 |
+
totalPages,
|
444 |
+
currentPage,
|
445 |
+
totalCharacters: extractedText.length,
|
446 |
+
phase: 'ocr'
|
447 |
+
});
|
448 |
+
|
449 |
+
addConsoleLog(`🤖 Running OCR on page ${currentPage} with Gemini ${mode === 'structured' ? '2.5-Pro' : '2.5-Flash'}`);
|
450 |
+
const pageText = await processWithGemini(enhancedImagePath, apiKey, mode);
|
451 |
+
|
452 |
+
addConsoleLog(`✅ Page ${currentPage} processed - ${pageText.length} characters extracted`);
|
453 |
+
|
454 |
+
if (mode === 'structured') {
|
455 |
+
extractedText += `\n\n## Page ${currentPage}\n\n${pageText}`;
|
456 |
+
} else {
|
457 |
+
extractedText += `\n\n--- Page ${currentPage} ---\n\n${pageText}`;
|
458 |
+
}
|
459 |
+
|
460 |
+
// Update progress with completed page info
|
461 |
+
updateProgress(ocrStep, totalSteps, `✅ Page ${currentPage}/${totalPages} completed - ${pageText.length} chars`, {
|
462 |
+
totalPages,
|
463 |
+
currentPage,
|
464 |
+
totalCharacters: extractedText.length,
|
465 |
+
pageCharacters: pageText.length,
|
466 |
+
phase: 'completed'
|
467 |
+
});
|
468 |
+
|
469 |
+
addConsoleLog(`📊 Total extracted so far: ${extractedText.length} characters`);
|
470 |
+
|
471 |
+
imagePaths.push(enhancedImagePath);
|
472 |
+
}
|
473 |
+
|
474 |
+
addConsoleLog('🧹 Cleaning up temporary files...');
|
475 |
+
// Clean up PDF image files
|
476 |
+
pdfImagePaths.forEach((imagePath, index) => {
|
477 |
+
try {
|
478 |
+
if (fs.existsSync(imagePath)) {
|
479 |
+
fs.unlinkSync(imagePath);
|
480 |
+
addConsoleLog(`🗑️ Cleaned up page-${index + 1}.png`);
|
481 |
+
}
|
482 |
+
} catch (error) {
|
483 |
+
addConsoleLog(`⚠️ Cleanup warning: ${error.message}`);
|
484 |
+
}
|
485 |
+
});
|
486 |
+
|
487 |
+
} else {
|
488 |
+
// Handle regular image files
|
489 |
+
addConsoleLog('🖼️ Processing single image file...');
|
490 |
+
addConsoleLog(`📄 File: ${req.file.originalname}`);
|
491 |
+
addConsoleLog(`📊 Size: ${(req.file.size / 1024).toFixed(2)} KB`);
|
492 |
+
|
493 |
+
updateProgress(1, 3, '🔧 Enhancing image for better OCR...', {
|
494 |
+
totalPages: 1,
|
495 |
+
currentPage: 1,
|
496 |
+
totalCharacters: 0,
|
497 |
+
phase: 'enhancing'
|
498 |
+
});
|
499 |
+
const enhancedImagePath = await enhanceImageForOCR(req.file.path);
|
500 |
+
|
501 |
+
updateProgress(2, 3, `🤖 Running OCR with Gemini ${mode === 'structured' ? '2.5-Pro' : '2.5-Flash'}...`, {
|
502 |
+
totalPages: 1,
|
503 |
+
currentPage: 1,
|
504 |
+
totalCharacters: 0,
|
505 |
+
phase: 'ocr'
|
506 |
+
});
|
507 |
+
extractedText = await processWithGemini(enhancedImagePath, apiKey, mode);
|
508 |
+
|
509 |
+
updateProgress(3, 3, `✅ OCR completed - ${extractedText.length} characters extracted`, {
|
510 |
+
totalPages: 1,
|
511 |
+
currentPage: 1,
|
512 |
+
totalCharacters: extractedText.length,
|
513 |
+
pageCharacters: extractedText.length,
|
514 |
+
phase: 'completed'
|
515 |
+
});
|
516 |
+
addConsoleLog(`✅ OCR completed - ${extractedText.length} characters extracted`);
|
517 |
+
imagePaths.push(enhancedImagePath);
|
518 |
+
}
|
519 |
+
|
520 |
+
// Generate all formats
|
521 |
+
const formats = generateFormats(extractedText, req.file.originalname, mode);
|
522 |
+
|
523 |
+
// Comprehensive cleanup of all temporary files
|
524 |
+
const filesToCleanup = [req.file.path, ...imagePaths];
|
525 |
+
let cleanedFiles = 0;
|
526 |
+
|
527 |
+
filesToCleanup.forEach(filePath => {
|
528 |
+
if (filePath && fs.existsSync(filePath)) {
|
529 |
+
try {
|
530 |
+
fs.unlinkSync(filePath);
|
531 |
+
cleanedFiles++;
|
532 |
+
addConsoleLog(`🗑️ Cleaned up: ${path.basename(filePath)}`);
|
533 |
+
} catch (error) {
|
534 |
+
addConsoleLog(`⚠️ Cleanup warning for ${path.basename(filePath)}: ${error.message}`);
|
535 |
+
}
|
536 |
+
}
|
537 |
+
});
|
538 |
+
|
539 |
+
addConsoleLog(`✅ Cleanup complete: ${cleanedFiles} files removed`);
|
540 |
+
|
541 |
+
// Final progress update
|
542 |
+
const finalProgress = progressTracking.get(sessionId);
|
543 |
+
if (finalProgress) {
|
544 |
+
updateProgress(finalProgress.total, finalProgress.total, '✅ Processing complete!');
|
545 |
+
}
|
546 |
+
|
547 |
+
// Return success response
|
548 |
+
res.json({
|
549 |
+
success: true,
|
550 |
+
sessionId: sessionId,
|
551 |
+
data: {
|
552 |
+
fileName: req.file.originalname,
|
553 |
+
fileSize: req.file.size,
|
554 |
+
processingMode: mode,
|
555 |
+
extractedText: extractedText,
|
556 |
+
formats: formats,
|
557 |
+
metadata: {
|
558 |
+
characterCount: extractedText.length,
|
559 |
+
wordCount: extractedText.split(/\s+/).filter(word => word.length > 0).length,
|
560 |
+
lineCount: extractedText.split('\n').length,
|
561 |
+
processedAt: new Date().toISOString()
|
562 |
+
}
|
563 |
+
}
|
564 |
+
});
|
565 |
+
|
566 |
+
// Clean up progress tracking and console logs after a delay
|
567 |
+
setTimeout(() => {
|
568 |
+
progressTracking.delete(sessionId);
|
569 |
+
consoleLogs.delete(sessionId);
|
570 |
+
}, 30000); // Clean up after 30 seconds
|
571 |
+
|
572 |
+
} catch (error) {
|
573 |
+
console.error('OCR processing error:', error);
|
574 |
+
|
575 |
+
// Comprehensive cleanup on error
|
576 |
+
const filesToCleanup = [];
|
577 |
+
if (req.file && req.file.path) filesToCleanup.push(req.file.path);
|
578 |
+
if (imagePaths && imagePaths.length > 0) filesToCleanup.push(...imagePaths);
|
579 |
+
|
580 |
+
let cleanedFiles = 0;
|
581 |
+
filesToCleanup.forEach(filePath => {
|
582 |
+
if (filePath && fs.existsSync(filePath)) {
|
583 |
+
try {
|
584 |
+
fs.unlinkSync(filePath);
|
585 |
+
cleanedFiles++;
|
586 |
+
console.log(`🗑️ Error cleanup: ${path.basename(filePath)}`);
|
587 |
+
} catch (cleanupError) {
|
588 |
+
console.warn(`⚠️ Error cleanup warning for ${path.basename(filePath)}:`, cleanupError.message);
|
589 |
+
}
|
590 |
+
}
|
591 |
+
});
|
592 |
+
|
593 |
+
if (cleanedFiles > 0) {
|
594 |
+
console.log(`✅ Error cleanup complete: ${cleanedFiles} files removed`);
|
595 |
+
}
|
596 |
+
|
597 |
+
res.status(500).json({
|
598 |
+
success: false,
|
599 |
+
error: error.message || 'Internal server error during OCR processing'
|
600 |
+
});
|
601 |
+
}
|
602 |
+
});
|
603 |
+
|
604 |
+
// Root endpoint
|
605 |
+
app.get('/', (req, res) => {
|
606 |
+
res.json({
|
607 |
+
success: true,
|
608 |
+
message: '🌙 Luna OCR Backend API',
|
609 |
+
version: '1.0.0',
|
610 |
+
endpoints: {
|
611 |
+
health: '/api/health',
|
612 |
+
ocr: '/api/ocr (POST)'
|
613 |
+
},
|
614 |
+
status: 'Running',
|
615 |
+
port: PORT,
|
616 |
+
timestamp: new Date().toISOString()
|
617 |
+
});
|
618 |
+
});
|
619 |
+
|
620 |
+
// Health check endpoint
|
621 |
+
app.get('/api/health', (req, res) => {
|
622 |
+
res.json({
|
623 |
+
success: true,
|
624 |
+
message: 'Luna OCR Backend is running!',
|
625 |
+
hasApiKey: !!GEMINI_API_KEY,
|
626 |
+
requiresUserApiKey: !GEMINI_API_KEY,
|
627 |
+
timestamp: new Date().toISOString()
|
628 |
+
});
|
629 |
+
});
|
630 |
+
|
631 |
+
// Manual cleanup endpoint
|
632 |
+
app.post('/api/cleanup', (req, res) => {
|
633 |
+
try {
|
634 |
+
console.log('🧹 Manual cleanup requested...');
|
635 |
+
cleanupTempFiles();
|
636 |
+
|
637 |
+
res.json({
|
638 |
+
success: true,
|
639 |
+
message: 'Cleanup completed successfully',
|
640 |
+
timestamp: new Date().toISOString()
|
641 |
+
});
|
642 |
+
} catch (error) {
|
643 |
+
console.error('Manual cleanup error:', error);
|
644 |
+
res.status(500).json({
|
645 |
+
success: false,
|
646 |
+
error: 'Cleanup failed: ' + error.message
|
647 |
+
});
|
648 |
+
}
|
649 |
+
});
|
650 |
+
|
651 |
+
// Error handling middleware
|
652 |
+
app.use((error, req, res, next) => {
|
653 |
+
if (error instanceof multer.MulterError) {
|
654 |
+
if (error.code === 'LIMIT_FILE_SIZE') {
|
655 |
+
return res.status(400).json({
|
656 |
+
success: false,
|
657 |
+
error: 'File too large. Maximum size is 10MB.'
|
658 |
+
});
|
659 |
+
}
|
660 |
+
}
|
661 |
+
|
662 |
+
console.error('Unhandled error:', error);
|
663 |
+
res.status(500).json({
|
664 |
+
success: false,
|
665 |
+
error: 'Internal server error'
|
666 |
+
});
|
667 |
+
});
|
668 |
+
|
669 |
+
// Graceful shutdown cleanup
|
670 |
+
process.on('SIGINT', () => {
|
671 |
+
console.log('\n🛑 Received SIGINT, cleaning up before shutdown...');
|
672 |
+
cleanupTempFiles();
|
673 |
+
console.log('� LuRna OCR Backend shutting down gracefully');
|
674 |
+
process.exit(0);
|
675 |
+
});
|
676 |
+
|
677 |
+
process.on('SIGTERM', () => {
|
678 |
+
console.log('\n🛑 Received SIGTERM, cleaning up before shutdown...');
|
679 |
+
cleanupTempFiles();
|
680 |
+
console.log('👋 Luna OCR Backend shutting down gracefully');
|
681 |
+
process.exit(0);
|
682 |
+
});
|
683 |
+
|
684 |
+
// Start server
|
685 |
+
app.listen(PORT, () => {
|
686 |
+
console.log(`🚀 Luna OCR Backend running on port ${PORT}`);
|
687 |
+
console.log(`📡 Health check: http://localhost:${PORT}/api/health`);
|
688 |
+
console.log(`🔍 OCR endpoint: http://localhost:${PORT}/api/ocr`);
|
689 |
+
console.log(`🧹 Cleanup endpoint: http://localhost:${PORT}/api/cleanup`);
|
690 |
+
console.log(`⏰ Automatic cleanup runs every 15 minutes`);
|
691 |
+
});
|
692 |
+
|
693 |
+
module.exports = app;
|
src/App.js
ADDED
@@ -0,0 +1,420 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
3 |
+
import { useDropzone } from 'react-dropzone';
|
4 |
+
import FlowingMenu from './components/FlowingMenu';
|
5 |
+
import FileUploader from './components/FileUploader';
|
6 |
+
import PreviewPanel from './components/PreviewPanel';
|
7 |
+
import ResultsPanel from './components/ResultsPanel';
|
8 |
+
import Orb from './components/Orb';
|
9 |
+
import TrueFocus from './components/TrueFocus';
|
10 |
+
import LoadingScreen from './components/LoadingScreen';
|
11 |
+
import ProcessingProgress from './components/ProcessingProgress';
|
12 |
+
import ApiKeyEncryption from './utils/encryption';
|
13 |
+
import './index.css';
|
14 |
+
|
15 |
+
function App() {
|
16 |
+
const [apiKey, setApiKey] = useState(() => {
|
17 |
+
// Load encrypted API key from localStorage on initialization
|
18 |
+
return ApiKeyEncryption.retrieveApiKey() || '';
|
19 |
+
});
|
20 |
+
|
21 |
+
// Handle API key changes and save encrypted to localStorage
|
22 |
+
const handleApiKeyChange = (newApiKey) => {
|
23 |
+
setApiKey(newApiKey);
|
24 |
+
|
25 |
+
// Store encrypted API key
|
26 |
+
if (newApiKey.trim()) {
|
27 |
+
ApiKeyEncryption.storeApiKey(newApiKey);
|
28 |
+
console.log('🔐 API key encrypted and stored securely');
|
29 |
+
} else {
|
30 |
+
ApiKeyEncryption.clearApiKey();
|
31 |
+
console.log('🗑️ Encrypted API key cleared');
|
32 |
+
}
|
33 |
+
};
|
34 |
+
|
35 |
+
const [uploadedFile, setUploadedFile] = useState(null);
|
36 |
+
const [extractedText, setExtractedText] = useState('');
|
37 |
+
const [isProcessing, setIsProcessing] = useState(false);
|
38 |
+
const [processingProgress, setProcessingProgress] = useState({ current: 0, total: 0, status: '', fileName: '' });
|
39 |
+
const [processingMode, setProcessingMode] = useState('standard');
|
40 |
+
const [activeTab, setActiveTab] = useState('upload');
|
41 |
+
const [previewMode, setPreviewMode] = useState('text');
|
42 |
+
const [showLoading, setShowLoading] = useState(true);
|
43 |
+
const fileInputRef = useRef(null);
|
44 |
+
|
45 |
+
// Check if server has API key configured
|
46 |
+
const [serverHasApiKey, setServerHasApiKey] = useState(false);
|
47 |
+
|
48 |
+
// Migrate old unencrypted API key to encrypted storage
|
49 |
+
useEffect(() => {
|
50 |
+
const migrateOldApiKey = () => {
|
51 |
+
const oldApiKey = localStorage.getItem('gemini-api-key');
|
52 |
+
if (oldApiKey && !ApiKeyEncryption.retrieveApiKey()) {
|
53 |
+
console.log('🔄 Migrating old API key to encrypted storage...');
|
54 |
+
ApiKeyEncryption.storeApiKey(oldApiKey);
|
55 |
+
localStorage.removeItem('gemini-api-key'); // Remove old unencrypted key
|
56 |
+
setApiKey(oldApiKey);
|
57 |
+
console.log('✅ API key migration completed');
|
58 |
+
}
|
59 |
+
};
|
60 |
+
|
61 |
+
migrateOldApiKey();
|
62 |
+
}, []);
|
63 |
+
|
64 |
+
// Cleanup temp files on app load/reload and check server API key
|
65 |
+
useEffect(() => {
|
66 |
+
const initializeApp = async () => {
|
67 |
+
try {
|
68 |
+
// Check if server has API key configured
|
69 |
+
const healthResponse = await fetch('http://localhost:3002/api/health');
|
70 |
+
if (healthResponse.ok) {
|
71 |
+
const healthData = await healthResponse.json();
|
72 |
+
setServerHasApiKey(healthData.hasApiKey || false);
|
73 |
+
}
|
74 |
+
|
75 |
+
// Cleanup temp files
|
76 |
+
await fetch('http://localhost:3002/api/cleanup', { method: 'POST' });
|
77 |
+
console.log('✅ Cleanup completed on app load');
|
78 |
+
} catch (error) {
|
79 |
+
console.log('⚠️ App initialization failed (server might be starting):', error.message);
|
80 |
+
}
|
81 |
+
};
|
82 |
+
|
83 |
+
// Run initialization after a short delay to ensure server is ready
|
84 |
+
setTimeout(initializeApp, 2000);
|
85 |
+
}, []);
|
86 |
+
|
87 |
+
const menuItems = [
|
88 |
+
{ id: 'upload', label: 'Upload', icon: '↑' },
|
89 |
+
{ id: 'preview', label: 'Preview', icon: '○' },
|
90 |
+
{ id: 'results', label: 'RESULTS', icon: '↓' }
|
91 |
+
];
|
92 |
+
|
93 |
+
const onDrop = useCallback(async (acceptedFiles) => {
|
94 |
+
const file = acceptedFiles[0];
|
95 |
+
if (file) {
|
96 |
+
// Trigger cleanup before processing new file
|
97 |
+
try {
|
98 |
+
await fetch('http://localhost:3002/api/cleanup', { method: 'POST' });
|
99 |
+
console.log('✅ Pre-upload cleanup completed');
|
100 |
+
} catch (error) {
|
101 |
+
console.log('⚠️ Pre-upload cleanup failed:', error.message);
|
102 |
+
}
|
103 |
+
|
104 |
+
setUploadedFile(file);
|
105 |
+
setActiveTab('preview');
|
106 |
+
}
|
107 |
+
}, []);
|
108 |
+
|
109 |
+
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
110 |
+
onDrop,
|
111 |
+
accept: {
|
112 |
+
'image/*': ['.png', '.jpg', '.jpeg'],
|
113 |
+
'application/pdf': ['.pdf'],
|
114 |
+
'text/html': ['.html', '.htm']
|
115 |
+
},
|
116 |
+
multiple: false
|
117 |
+
});
|
118 |
+
|
119 |
+
const handlePaste = useCallback((e) => {
|
120 |
+
const items = e.clipboardData?.items;
|
121 |
+
if (items) {
|
122 |
+
for (let item of items) {
|
123 |
+
if (item.type.indexOf('image') !== -1) {
|
124 |
+
const file = item.getAsFile();
|
125 |
+
if (file) {
|
126 |
+
setUploadedFile(file);
|
127 |
+
setActiveTab('preview');
|
128 |
+
}
|
129 |
+
}
|
130 |
+
}
|
131 |
+
}
|
132 |
+
}, []);
|
133 |
+
|
134 |
+
const handleFileSelect = () => {
|
135 |
+
fileInputRef.current?.click();
|
136 |
+
};
|
137 |
+
|
138 |
+
const handleFileChange = (e) => {
|
139 |
+
const file = e.target.files?.[0];
|
140 |
+
if (file) {
|
141 |
+
setUploadedFile(file);
|
142 |
+
setActiveTab('preview');
|
143 |
+
}
|
144 |
+
};
|
145 |
+
|
146 |
+
const processFile = async () => {
|
147 |
+
if (!uploadedFile || !apiKey) return;
|
148 |
+
|
149 |
+
setIsProcessing(true);
|
150 |
+
|
151 |
+
// Initialize progress
|
152 |
+
setProcessingProgress({
|
153 |
+
current: 0,
|
154 |
+
total: 1,
|
155 |
+
status: '🔄 Initializing...',
|
156 |
+
fileName: uploadedFile.name
|
157 |
+
});
|
158 |
+
|
159 |
+
let progressInterval = null;
|
160 |
+
|
161 |
+
try {
|
162 |
+
// Create FormData for file upload
|
163 |
+
const formData = new FormData();
|
164 |
+
formData.append('file', uploadedFile);
|
165 |
+
formData.append('apiKey', apiKey);
|
166 |
+
formData.append('mode', processingMode);
|
167 |
+
|
168 |
+
console.log('Processing file:', uploadedFile.name, 'Mode:', processingMode);
|
169 |
+
|
170 |
+
// Start the OCR request
|
171 |
+
const ocrPromise = fetch('http://localhost:3002/api/ocr', {
|
172 |
+
method: 'POST',
|
173 |
+
body: formData,
|
174 |
+
});
|
175 |
+
|
176 |
+
// Start progress polling immediately
|
177 |
+
progressInterval = setInterval(async () => {
|
178 |
+
try {
|
179 |
+
// We'll get the sessionId from the response, but for now poll a generic endpoint
|
180 |
+
// In a real implementation, you'd start polling after getting the sessionId
|
181 |
+
const progressResponse = await fetch(`http://localhost:3002/api/progress-latest`);
|
182 |
+
if (progressResponse.ok) {
|
183 |
+
const progressData = await progressResponse.json();
|
184 |
+
// Progress data received and processed
|
185 |
+
if (progressData.current > 0) {
|
186 |
+
setProcessingProgress({
|
187 |
+
current: progressData.current,
|
188 |
+
total: progressData.total,
|
189 |
+
status: progressData.status,
|
190 |
+
fileName: progressData.fileName || uploadedFile.name,
|
191 |
+
totalPages: progressData.totalPages || 1,
|
192 |
+
currentPage: progressData.currentPage || 1,
|
193 |
+
totalCharacters: progressData.totalCharacters || 0,
|
194 |
+
pageCharacters: progressData.pageCharacters || 0,
|
195 |
+
phase: progressData.phase || 'processing',
|
196 |
+
consoleLogs: progressData.consoleLogs || []
|
197 |
+
});
|
198 |
+
}
|
199 |
+
}
|
200 |
+
} catch (error) {
|
201 |
+
// Ignore progress polling errors
|
202 |
+
console.log('Progress polling error (ignored):', error.message);
|
203 |
+
}
|
204 |
+
}, 1000); // Poll every 1 second to reduce spam
|
205 |
+
|
206 |
+
// Wait for OCR to complete
|
207 |
+
const response = await ocrPromise;
|
208 |
+
const result = await response.json();
|
209 |
+
|
210 |
+
// Clear progress polling
|
211 |
+
if (progressInterval) {
|
212 |
+
clearInterval(progressInterval);
|
213 |
+
progressInterval = null;
|
214 |
+
}
|
215 |
+
|
216 |
+
if (result.success) {
|
217 |
+
// Final progress update
|
218 |
+
setProcessingProgress(prev => ({
|
219 |
+
...prev,
|
220 |
+
current: prev.total,
|
221 |
+
status: '✅ Processing complete!'
|
222 |
+
}));
|
223 |
+
|
224 |
+
await new Promise(resolve => setTimeout(resolve, 500));
|
225 |
+
|
226 |
+
// Use the extracted text from Gemini
|
227 |
+
setExtractedText(result.data.extractedText);
|
228 |
+
setActiveTab('results');
|
229 |
+
|
230 |
+
console.log('✅ OCR Success:', {
|
231 |
+
fileName: result.data.fileName,
|
232 |
+
characters: result.data.metadata.characterCount,
|
233 |
+
words: result.data.metadata.wordCount,
|
234 |
+
mode: result.data.processingMode
|
235 |
+
});
|
236 |
+
} else {
|
237 |
+
console.error('❌ OCR Error:', result.error);
|
238 |
+
alert(`OCR Error: ${result.error}`);
|
239 |
+
}
|
240 |
+
} catch (error) {
|
241 |
+
console.error('❌ Network Error:', error);
|
242 |
+
|
243 |
+
// Clear progress polling on error
|
244 |
+
if (progressInterval) {
|
245 |
+
clearInterval(progressInterval);
|
246 |
+
progressInterval = null;
|
247 |
+
}
|
248 |
+
|
249 |
+
// Check if backend is running
|
250 |
+
if (error.message.includes('fetch')) {
|
251 |
+
alert(`Network Error: Cannot connect to OCR backend.
|
252 |
+
|
253 |
+
Please make sure:
|
254 |
+
1. Backend server is running on port 3002
|
255 |
+
2. Run: cd server && npm install && npm start
|
256 |
+
3. Check console for any backend errors
|
257 |
+
4. Visit http://localhost:3002 to verify backend is running
|
258 |
+
|
259 |
+
Error: ${error.message}`);
|
260 |
+
} else {
|
261 |
+
alert(`Processing Error: ${error.message}`);
|
262 |
+
}
|
263 |
+
} finally {
|
264 |
+
// Clean up
|
265 |
+
if (progressInterval) {
|
266 |
+
clearInterval(progressInterval);
|
267 |
+
}
|
268 |
+
setIsProcessing(false);
|
269 |
+
setProcessingProgress({ current: 0, total: 0, status: '', fileName: '' });
|
270 |
+
}
|
271 |
+
};
|
272 |
+
|
273 |
+
return (
|
274 |
+
<div className="app" onPaste={handlePaste}>
|
275 |
+
{/* Loading Screen */}
|
276 |
+
{showLoading && (
|
277 |
+
<LoadingScreen onComplete={() => setShowLoading(false)} />
|
278 |
+
)}
|
279 |
+
|
280 |
+
{/* Giant Central Orb - Fades in after loading */}
|
281 |
+
<div className={`giant-orb-background ${!showLoading ? 'loaded' : ''}`}>
|
282 |
+
<div className="giant-orb-container">
|
283 |
+
<Orb hue={0} hoverIntensity={0.5} rotateOnHover={true} forceHoverState={false} />
|
284 |
+
</div>
|
285 |
+
</div>
|
286 |
+
|
287 |
+
<div className="glass-container">
|
288 |
+
<header className="app-header">
|
289 |
+
<motion.div
|
290 |
+
className="logo"
|
291 |
+
initial={{ opacity: 0, y: -20 }}
|
292 |
+
animate={{ opacity: 1, y: 0 }}
|
293 |
+
transition={{ duration: 0.8 }}
|
294 |
+
>
|
295 |
+
<TrueFocus
|
296 |
+
sentence="Luna OCR"
|
297 |
+
manualMode={true}
|
298 |
+
blurAmount={8.5}
|
299 |
+
borderColor="#ffffff"
|
300 |
+
glowColor="rgba(255, 255, 255, 0.8)"
|
301 |
+
animationDuration={0.5}
|
302 |
+
pauseBetweenAnimations={2}
|
303 |
+
/>
|
304 |
+
</motion.div>
|
305 |
+
|
306 |
+
<motion.div
|
307 |
+
className="api-key-input"
|
308 |
+
initial={{ opacity: 0, x: 20 }}
|
309 |
+
animate={{ opacity: 1, x: 0 }}
|
310 |
+
transition={{ duration: 0.8, delay: 0.2 }}
|
311 |
+
>
|
312 |
+
{!serverHasApiKey && (
|
313 |
+
<div className="api-key-container">
|
314 |
+
<input
|
315 |
+
type="password"
|
316 |
+
placeholder={apiKey ? "API Key loaded from storage" : "Enter Google API Key..."}
|
317 |
+
value={apiKey}
|
318 |
+
onChange={(e) => handleApiKeyChange(e.target.value)}
|
319 |
+
className="glass-input"
|
320 |
+
/>
|
321 |
+
{apiKey && (
|
322 |
+
<button
|
323 |
+
type="button"
|
324 |
+
onClick={() => handleApiKeyChange('')}
|
325 |
+
className="clear-api-key-btn"
|
326 |
+
title="Clear saved API key"
|
327 |
+
>
|
328 |
+
×
|
329 |
+
</button>
|
330 |
+
)}
|
331 |
+
</div>
|
332 |
+
)}
|
333 |
+
|
334 |
+
{serverHasApiKey && (
|
335 |
+
<div className="server-api-notice">
|
336 |
+
<p>🔒 API key configured on server - no user key required</p>
|
337 |
+
</div>
|
338 |
+
)}
|
339 |
+
</motion.div>
|
340 |
+
</header>
|
341 |
+
|
342 |
+
<FlowingMenu
|
343 |
+
items={menuItems}
|
344 |
+
activeItem={activeTab}
|
345 |
+
onItemClick={setActiveTab}
|
346 |
+
/>
|
347 |
+
|
348 |
+
<main className="app-main">
|
349 |
+
<AnimatePresence mode="wait">
|
350 |
+
{activeTab === 'upload' && (
|
351 |
+
<motion.div
|
352 |
+
key="upload"
|
353 |
+
initial={{ opacity: 0, x: -20 }}
|
354 |
+
animate={{ opacity: 1, x: 0 }}
|
355 |
+
exit={{ opacity: 0, x: 20 }}
|
356 |
+
className="tab-content"
|
357 |
+
>
|
358 |
+
<FileUploader
|
359 |
+
getRootProps={getRootProps}
|
360 |
+
getInputProps={getInputProps}
|
361 |
+
isDragActive={isDragActive}
|
362 |
+
onFileSelect={handleFileSelect}
|
363 |
+
fileInputRef={fileInputRef}
|
364 |
+
onFileChange={handleFileChange}
|
365 |
+
/>
|
366 |
+
</motion.div>
|
367 |
+
)}
|
368 |
+
|
369 |
+
|
370 |
+
|
371 |
+
{activeTab === 'preview' && uploadedFile && (
|
372 |
+
<motion.div
|
373 |
+
key="preview"
|
374 |
+
initial={{ opacity: 0, x: -20 }}
|
375 |
+
animate={{ opacity: 1, x: 0 }}
|
376 |
+
exit={{ opacity: 0, x: 20 }}
|
377 |
+
className="tab-content"
|
378 |
+
>
|
379 |
+
<PreviewPanel
|
380 |
+
file={uploadedFile}
|
381 |
+
processingMode={processingMode}
|
382 |
+
onModeChange={setProcessingMode}
|
383 |
+
onProcess={processFile}
|
384 |
+
isProcessing={isProcessing}
|
385 |
+
apiKey={apiKey}
|
386 |
+
/>
|
387 |
+
</motion.div>
|
388 |
+
)}
|
389 |
+
|
390 |
+
{activeTab === 'results' && extractedText && (
|
391 |
+
<motion.div
|
392 |
+
key="results"
|
393 |
+
initial={{ opacity: 0, x: -20 }}
|
394 |
+
animate={{ opacity: 1, x: 0 }}
|
395 |
+
exit={{ opacity: 0, x: 20 }}
|
396 |
+
className="tab-content"
|
397 |
+
>
|
398 |
+
<ResultsPanel
|
399 |
+
text={extractedText}
|
400 |
+
previewMode={previewMode}
|
401 |
+
onPreviewModeChange={setPreviewMode}
|
402 |
+
fileName={uploadedFile?.name}
|
403 |
+
/>
|
404 |
+
</motion.div>
|
405 |
+
)}
|
406 |
+
</AnimatePresence>
|
407 |
+
</main>
|
408 |
+
</div>
|
409 |
+
|
410 |
+
{/* Processing Progress Overlay */}
|
411 |
+
<ProcessingProgress
|
412 |
+
progress={processingProgress}
|
413 |
+
isVisible={isProcessing}
|
414 |
+
/>
|
415 |
+
|
416 |
+
</div>
|
417 |
+
);
|
418 |
+
}
|
419 |
+
|
420 |
+
export default App;
|
src/components/AHoleLoader.css
ADDED
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/* AHole Loader Styles - Adapted from CodePen */
|
2 |
+
.a-hole-loader {
|
3 |
+
width: 100%;
|
4 |
+
height: 100%;
|
5 |
+
background: #141414;
|
6 |
+
}
|
7 |
+
|
8 |
+
a-hole {
|
9 |
+
position: absolute;
|
10 |
+
top: 0;
|
11 |
+
left: 0;
|
12 |
+
margin: 0;
|
13 |
+
padding: 0;
|
14 |
+
width: 100%;
|
15 |
+
height: 100%;
|
16 |
+
overflow: hidden;
|
17 |
+
}
|
18 |
+
|
19 |
+
a-hole::before {
|
20 |
+
position: absolute;
|
21 |
+
top: 50%;
|
22 |
+
left: 50%;
|
23 |
+
z-index: 2;
|
24 |
+
display: block;
|
25 |
+
width: 150%;
|
26 |
+
height: 140%;
|
27 |
+
background: radial-gradient(ellipse at 50% 55%, transparent 10%, black 50%);
|
28 |
+
transform: translate3d(-50%, -50%, 0);
|
29 |
+
content: "";
|
30 |
+
}
|
31 |
+
|
32 |
+
a-hole::after {
|
33 |
+
position: absolute;
|
34 |
+
top: 50%;
|
35 |
+
left: 50%;
|
36 |
+
z-index: 5;
|
37 |
+
display: block;
|
38 |
+
width: 100%;
|
39 |
+
height: 100%;
|
40 |
+
background: radial-gradient(ellipse at 50% 75%,
|
41 |
+
#a900ff 20%,
|
42 |
+
transparent 75%);
|
43 |
+
mix-blend-mode: overlay;
|
44 |
+
transform: translate3d(-50%, -50%, 0);
|
45 |
+
content: "";
|
46 |
+
}
|
47 |
+
|
48 |
+
@keyframes aura-glow {
|
49 |
+
0% {
|
50 |
+
background-position: 0 100%;
|
51 |
+
}
|
52 |
+
|
53 |
+
100% {
|
54 |
+
background-position: 0 300%;
|
55 |
+
}
|
56 |
+
}
|
57 |
+
|
58 |
+
a-hole .aura {
|
59 |
+
position: absolute;
|
60 |
+
top: -71.5%;
|
61 |
+
left: 50%;
|
62 |
+
z-index: 3;
|
63 |
+
width: 30%;
|
64 |
+
height: 140%;
|
65 |
+
background: linear-gradient(20deg,
|
66 |
+
#00f8f1,
|
67 |
+
#ffbd1e20 16.5%,
|
68 |
+
#fe848f 33%,
|
69 |
+
#fe848f20 49.5%,
|
70 |
+
#00f8f1 66%,
|
71 |
+
#00f8f160 85.5%,
|
72 |
+
#ffbd1e 100%) 0 100% / 100% 200%;
|
73 |
+
border-radius: 0 0 100% 100%;
|
74 |
+
filter: blur(50px);
|
75 |
+
mix-blend-mode: plus-lighter;
|
76 |
+
opacity: 0.75;
|
77 |
+
transform: translate3d(-50%, 0, 0);
|
78 |
+
animation: aura-glow 5s infinite linear;
|
79 |
+
}
|
80 |
+
|
81 |
+
a-hole .overlay {
|
82 |
+
position: absolute;
|
83 |
+
top: 0;
|
84 |
+
left: 0;
|
85 |
+
z-index: 10;
|
86 |
+
width: 100%;
|
87 |
+
height: 100%;
|
88 |
+
background: repeating-linear-gradient(transparent,
|
89 |
+
transparent 1px,
|
90 |
+
white 1px,
|
91 |
+
white 2px);
|
92 |
+
mix-blend-mode: overlay;
|
93 |
+
opacity: 0.5;
|
94 |
+
}
|
95 |
+
|
96 |
+
a-hole canvas {
|
97 |
+
display: block;
|
98 |
+
width: 100%;
|
99 |
+
height: 100%;
|
100 |
+
}
|
101 |
+
|
102 |
+
/* Loading Screen Integration */
|
103 |
+
.loading-screen {
|
104 |
+
position: fixed;
|
105 |
+
top: 0;
|
106 |
+
left: 0;
|
107 |
+
width: 100%;
|
108 |
+
height: 100%;
|
109 |
+
background: #141414;
|
110 |
+
z-index: 9999;
|
111 |
+
display: flex;
|
112 |
+
flex-direction: column;
|
113 |
+
align-items: center;
|
114 |
+
justify-content: center;
|
115 |
+
}
|
116 |
+
|
117 |
+
.loading-content {
|
118 |
+
position: relative;
|
119 |
+
z-index: 10;
|
120 |
+
text-align: center;
|
121 |
+
color: white;
|
122 |
+
margin-bottom: 40px;
|
123 |
+
}
|
124 |
+
|
125 |
+
.loading-title {
|
126 |
+
font-size: 2.5rem;
|
127 |
+
font-weight: 700;
|
128 |
+
margin-bottom: 16px;
|
129 |
+
background: linear-gradient(45deg, #00f8f1, #a900ff, #fe848f);
|
130 |
+
-webkit-background-clip: text;
|
131 |
+
-webkit-text-fill-color: transparent;
|
132 |
+
background-clip: text;
|
133 |
+
animation: gradient-shift 3s ease-in-out infinite;
|
134 |
+
}
|
135 |
+
|
136 |
+
.loading-subtitle {
|
137 |
+
font-size: 1rem;
|
138 |
+
color: rgba(255, 255, 255, 0.7);
|
139 |
+
font-weight: 400;
|
140 |
+
}
|
141 |
+
|
142 |
+
.a-hole-container {
|
143 |
+
width: 100vw;
|
144 |
+
height: 100vh;
|
145 |
+
position: absolute;
|
146 |
+
top: 0;
|
147 |
+
left: 0;
|
148 |
+
}
|
149 |
+
|
150 |
+
@keyframes gradient-shift {
|
151 |
+
|
152 |
+
0%,
|
153 |
+
100% {
|
154 |
+
background-position: 0% 50%;
|
155 |
+
}
|
156 |
+
|
157 |
+
50% {
|
158 |
+
background-position: 100% 50%;
|
159 |
+
}
|
160 |
+
}
|
161 |
+
|
162 |
+
/* Pixelated Loading Indicator - Bottom Left */
|
163 |
+
.pixelated-loading {
|
164 |
+
position: absolute;
|
165 |
+
bottom: 40px;
|
166 |
+
left: 40px;
|
167 |
+
z-index: 15;
|
168 |
+
display: flex;
|
169 |
+
align-items: center;
|
170 |
+
gap: 12px;
|
171 |
+
font-family: 'Courier New', monospace;
|
172 |
+
}
|
173 |
+
|
174 |
+
.loading-dots {
|
175 |
+
display: flex;
|
176 |
+
gap: 4px;
|
177 |
+
}
|
178 |
+
|
179 |
+
.dot {
|
180 |
+
width: 8px;
|
181 |
+
height: 8px;
|
182 |
+
background: #00f8f1;
|
183 |
+
border-radius: 0;
|
184 |
+
/* Pixelated - no rounded corners */
|
185 |
+
animation: pixel-pulse 1.2s ease-in-out infinite;
|
186 |
+
box-shadow: 0 0 4px #00f8f1;
|
187 |
+
transition: background 0.3s ease, box-shadow 0.3s ease;
|
188 |
+
}
|
189 |
+
|
190 |
+
.dot:nth-child(1) {
|
191 |
+
animation-delay: 0s;
|
192 |
+
}
|
193 |
+
|
194 |
+
.dot:nth-child(2) {
|
195 |
+
animation-delay: 0.3s;
|
196 |
+
}
|
197 |
+
|
198 |
+
.dot:nth-child(3) {
|
199 |
+
animation-delay: 0.6s;
|
200 |
+
}
|
201 |
+
|
202 |
+
.dot:nth-child(4) {
|
203 |
+
animation-delay: 0.9s;
|
204 |
+
}
|
205 |
+
|
206 |
+
.loading-text {
|
207 |
+
color: #00f8f1;
|
208 |
+
font-size: 12px;
|
209 |
+
font-weight: bold;
|
210 |
+
letter-spacing: 2px;
|
211 |
+
text-shadow: 0 0 8px #00f8f1;
|
212 |
+
animation: text-flicker 2s ease-in-out infinite;
|
213 |
+
transition: color 0.3s ease, text-shadow 0.3s ease;
|
214 |
+
}
|
215 |
+
|
216 |
+
@keyframes pixel-pulse {
|
217 |
+
|
218 |
+
0%,
|
219 |
+
100% {
|
220 |
+
opacity: 0.3;
|
221 |
+
transform: scale(1);
|
222 |
+
}
|
223 |
+
|
224 |
+
50% {
|
225 |
+
opacity: 1;
|
226 |
+
transform: scale(1.2);
|
227 |
+
}
|
228 |
+
}
|
229 |
+
|
230 |
+
@keyframes text-flicker {
|
231 |
+
|
232 |
+
0%,
|
233 |
+
100% {
|
234 |
+
opacity: 1;
|
235 |
+
}
|
236 |
+
|
237 |
+
50% {
|
238 |
+
opacity: 0.7;
|
239 |
+
}
|
240 |
+
}
|
241 |
+
|
242 |
+
/* Responsive adjustments */
|
243 |
+
@media (max-width: 768px) {
|
244 |
+
.loading-title {
|
245 |
+
font-size: 2rem;
|
246 |
+
}
|
247 |
+
|
248 |
+
.loading-subtitle {
|
249 |
+
font-size: 0.9rem;
|
250 |
+
}
|
251 |
+
|
252 |
+
.pixelated-loading {
|
253 |
+
bottom: 20px;
|
254 |
+
left: 20px;
|
255 |
+
}
|
256 |
+
|
257 |
+
.dot {
|
258 |
+
width: 6px;
|
259 |
+
height: 6px;
|
260 |
+
}
|
261 |
+
|
262 |
+
.loading-text {
|
263 |
+
font-size: 10px;
|
264 |
+
}
|
265 |
+
}
|
src/components/AHoleLoader.js
ADDED
@@ -0,0 +1,404 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useEffect, useRef } from 'react';
|
2 |
+
|
3 |
+
class AHole extends HTMLElement {
|
4 |
+
connectedCallback() {
|
5 |
+
// Elements
|
6 |
+
this.canvas = this.querySelector(".js-canvas");
|
7 |
+
this.ctx = this.canvas.getContext("2d");
|
8 |
+
|
9 |
+
this.discs = [];
|
10 |
+
this.lines = [];
|
11 |
+
|
12 |
+
// Init
|
13 |
+
this.setSize();
|
14 |
+
this.setDiscs();
|
15 |
+
this.setLines();
|
16 |
+
this.setParticles();
|
17 |
+
|
18 |
+
this.bindEvents();
|
19 |
+
|
20 |
+
// RAF
|
21 |
+
this.animationId = requestAnimationFrame(this.tick.bind(this));
|
22 |
+
}
|
23 |
+
|
24 |
+
disconnectedCallback() {
|
25 |
+
if (this.animationId) {
|
26 |
+
cancelAnimationFrame(this.animationId);
|
27 |
+
}
|
28 |
+
window.removeEventListener("resize", this.onResize.bind(this));
|
29 |
+
}
|
30 |
+
|
31 |
+
bindEvents() {
|
32 |
+
this.onResize = this.onResize.bind(this);
|
33 |
+
window.addEventListener("resize", this.onResize);
|
34 |
+
}
|
35 |
+
|
36 |
+
onResize() {
|
37 |
+
this.setSize();
|
38 |
+
this.setDiscs();
|
39 |
+
this.setLines();
|
40 |
+
this.setParticles();
|
41 |
+
}
|
42 |
+
|
43 |
+
setSize() {
|
44 |
+
this.rect = this.getBoundingClientRect();
|
45 |
+
|
46 |
+
this.render = {
|
47 |
+
width: this.rect.width,
|
48 |
+
height: this.rect.height,
|
49 |
+
dpi: window.devicePixelRatio
|
50 |
+
};
|
51 |
+
|
52 |
+
this.canvas.width = this.render.width * this.render.dpi;
|
53 |
+
this.canvas.height = this.render.height * this.render.dpi;
|
54 |
+
}
|
55 |
+
|
56 |
+
setDiscs() {
|
57 |
+
const { width, height } = this.rect;
|
58 |
+
|
59 |
+
this.discs = [];
|
60 |
+
|
61 |
+
this.startDisc = {
|
62 |
+
x: width * 0.5,
|
63 |
+
y: height * 0.45,
|
64 |
+
w: width * 0.75,
|
65 |
+
h: height * 0.7
|
66 |
+
};
|
67 |
+
|
68 |
+
this.endDisc = {
|
69 |
+
x: width * 0.5,
|
70 |
+
y: height * 0.95,
|
71 |
+
w: 0,
|
72 |
+
h: 0
|
73 |
+
};
|
74 |
+
|
75 |
+
const totalDiscs = 100;
|
76 |
+
|
77 |
+
let prevBottom = height;
|
78 |
+
this.clip = {};
|
79 |
+
|
80 |
+
for (let i = 0; i < totalDiscs; i++) {
|
81 |
+
const p = i / totalDiscs;
|
82 |
+
|
83 |
+
const disc = this.tweenDisc({
|
84 |
+
p
|
85 |
+
});
|
86 |
+
|
87 |
+
const bottom = disc.y + disc.h;
|
88 |
+
|
89 |
+
if (bottom <= prevBottom) {
|
90 |
+
this.clip = {
|
91 |
+
disc: { ...disc },
|
92 |
+
i
|
93 |
+
};
|
94 |
+
}
|
95 |
+
|
96 |
+
prevBottom = bottom;
|
97 |
+
|
98 |
+
this.discs.push(disc);
|
99 |
+
}
|
100 |
+
|
101 |
+
this.clip.path = new Path2D();
|
102 |
+
this.clip.path.ellipse(
|
103 |
+
this.clip.disc.x,
|
104 |
+
this.clip.disc.y,
|
105 |
+
this.clip.disc.w,
|
106 |
+
this.clip.disc.h,
|
107 |
+
0,
|
108 |
+
0,
|
109 |
+
Math.PI * 2
|
110 |
+
);
|
111 |
+
this.clip.path.rect(
|
112 |
+
this.clip.disc.x - this.clip.disc.w,
|
113 |
+
0,
|
114 |
+
this.clip.disc.w * 2,
|
115 |
+
this.clip.disc.y
|
116 |
+
);
|
117 |
+
}
|
118 |
+
|
119 |
+
setLines() {
|
120 |
+
const { width, height } = this.rect;
|
121 |
+
|
122 |
+
this.lines = [];
|
123 |
+
|
124 |
+
const totalLines = 100;
|
125 |
+
const linesAngle = (Math.PI * 2) / totalLines;
|
126 |
+
|
127 |
+
for (let i = 0; i < totalLines; i++) {
|
128 |
+
this.lines.push([]);
|
129 |
+
}
|
130 |
+
|
131 |
+
this.discs.forEach((disc) => {
|
132 |
+
for (let i = 0; i < totalLines; i++) {
|
133 |
+
const angle = i * linesAngle;
|
134 |
+
|
135 |
+
const p = {
|
136 |
+
x: disc.x + Math.cos(angle) * disc.w,
|
137 |
+
y: disc.y + Math.sin(angle) * disc.h
|
138 |
+
};
|
139 |
+
|
140 |
+
this.lines[i].push(p);
|
141 |
+
}
|
142 |
+
});
|
143 |
+
|
144 |
+
this.linesCanvas = new OffscreenCanvas(width, height);
|
145 |
+
const ctx = this.linesCanvas.getContext("2d");
|
146 |
+
|
147 |
+
this.lines.forEach((line) => {
|
148 |
+
ctx.save();
|
149 |
+
|
150 |
+
let lineIsIn = false;
|
151 |
+
line.forEach((p1, j) => {
|
152 |
+
if (j === 0) {
|
153 |
+
return;
|
154 |
+
}
|
155 |
+
|
156 |
+
const p0 = line[j - 1];
|
157 |
+
|
158 |
+
if (
|
159 |
+
!lineIsIn &&
|
160 |
+
(ctx.isPointInPath(this.clip.path, p1.x, p1.y) ||
|
161 |
+
ctx.isPointInStroke(this.clip.path, p1.x, p1.y))
|
162 |
+
) {
|
163 |
+
lineIsIn = true;
|
164 |
+
} else if (lineIsIn) {
|
165 |
+
ctx.clip(this.clip.path);
|
166 |
+
}
|
167 |
+
|
168 |
+
ctx.beginPath();
|
169 |
+
|
170 |
+
ctx.moveTo(p0.x, p0.y);
|
171 |
+
ctx.lineTo(p1.x, p1.y);
|
172 |
+
|
173 |
+
ctx.strokeStyle = "#444";
|
174 |
+
ctx.lineWidth = 2;
|
175 |
+
ctx.stroke();
|
176 |
+
|
177 |
+
ctx.closePath();
|
178 |
+
});
|
179 |
+
|
180 |
+
ctx.restore();
|
181 |
+
});
|
182 |
+
|
183 |
+
this.linesCtx = ctx;
|
184 |
+
}
|
185 |
+
|
186 |
+
setParticles() {
|
187 |
+
const { width, height } = this.rect;
|
188 |
+
|
189 |
+
this.particles = [];
|
190 |
+
|
191 |
+
this.particleArea = {
|
192 |
+
sw: this.clip.disc.w * 0.5,
|
193 |
+
ew: this.clip.disc.w * 2,
|
194 |
+
h: height * 0.85
|
195 |
+
};
|
196 |
+
this.particleArea.sx = (width - this.particleArea.sw) / 2;
|
197 |
+
this.particleArea.ex = (width - this.particleArea.ew) / 2;
|
198 |
+
|
199 |
+
const totalParticles = 100;
|
200 |
+
|
201 |
+
for (let i = 0; i < totalParticles; i++) {
|
202 |
+
const particle = this.initParticle(true);
|
203 |
+
this.particles.push(particle);
|
204 |
+
}
|
205 |
+
}
|
206 |
+
|
207 |
+
initParticle(start = false) {
|
208 |
+
const sx = this.particleArea.sx + this.particleArea.sw * Math.random();
|
209 |
+
const ex = this.particleArea.ex + this.particleArea.ew * Math.random();
|
210 |
+
const dx = ex - sx;
|
211 |
+
const y = start ? this.particleArea.h * Math.random() : this.particleArea.h;
|
212 |
+
const r = 0.5 + Math.random() * 4;
|
213 |
+
const vy = 0.5 + Math.random();
|
214 |
+
|
215 |
+
return {
|
216 |
+
x: sx,
|
217 |
+
sx,
|
218 |
+
dx,
|
219 |
+
y,
|
220 |
+
vy,
|
221 |
+
p: 0,
|
222 |
+
r,
|
223 |
+
c: `rgba(255, 255, 255, ${Math.random()})`
|
224 |
+
};
|
225 |
+
}
|
226 |
+
|
227 |
+
tweenValue(start, end, p, ease = false) {
|
228 |
+
const delta = end - start;
|
229 |
+
|
230 |
+
// Simple easing functions
|
231 |
+
let easeFn;
|
232 |
+
if (ease === "inExpo") {
|
233 |
+
easeFn = (t) => t === 0 ? 0 : Math.pow(2, 10 * (t - 1));
|
234 |
+
} else {
|
235 |
+
easeFn = (t) => t; // linear
|
236 |
+
}
|
237 |
+
|
238 |
+
return start + delta * easeFn(p);
|
239 |
+
}
|
240 |
+
|
241 |
+
drawDiscs() {
|
242 |
+
const { ctx } = this;
|
243 |
+
|
244 |
+
ctx.strokeStyle = "#444";
|
245 |
+
ctx.lineWidth = 2;
|
246 |
+
|
247 |
+
// Outer disc
|
248 |
+
const outerDisc = this.startDisc;
|
249 |
+
|
250 |
+
ctx.beginPath();
|
251 |
+
|
252 |
+
ctx.ellipse(
|
253 |
+
outerDisc.x,
|
254 |
+
outerDisc.y,
|
255 |
+
outerDisc.w,
|
256 |
+
outerDisc.h,
|
257 |
+
0,
|
258 |
+
0,
|
259 |
+
Math.PI * 2
|
260 |
+
);
|
261 |
+
ctx.stroke();
|
262 |
+
|
263 |
+
ctx.closePath();
|
264 |
+
|
265 |
+
// Discs
|
266 |
+
this.discs.forEach((disc, i) => {
|
267 |
+
if (i % 5 !== 0) {
|
268 |
+
return;
|
269 |
+
}
|
270 |
+
|
271 |
+
if (disc.w < this.clip.disc.w - 5) {
|
272 |
+
ctx.save();
|
273 |
+
ctx.clip(this.clip.path);
|
274 |
+
}
|
275 |
+
|
276 |
+
ctx.beginPath();
|
277 |
+
|
278 |
+
ctx.ellipse(disc.x, disc.y, disc.w, disc.h, 0, 0, Math.PI * 2);
|
279 |
+
ctx.stroke();
|
280 |
+
|
281 |
+
ctx.closePath();
|
282 |
+
|
283 |
+
if (disc.w < this.clip.disc.w - 5) {
|
284 |
+
ctx.restore();
|
285 |
+
}
|
286 |
+
});
|
287 |
+
}
|
288 |
+
|
289 |
+
drawLines() {
|
290 |
+
const { ctx, linesCanvas } = this;
|
291 |
+
ctx.drawImage(linesCanvas, 0, 0);
|
292 |
+
}
|
293 |
+
|
294 |
+
drawParticles() {
|
295 |
+
const { ctx } = this;
|
296 |
+
|
297 |
+
ctx.save();
|
298 |
+
ctx.clip(this.clip.path);
|
299 |
+
|
300 |
+
this.particles.forEach((particle) => {
|
301 |
+
ctx.fillStyle = particle.c;
|
302 |
+
ctx.beginPath();
|
303 |
+
ctx.rect(particle.x, particle.y, particle.r, particle.r);
|
304 |
+
ctx.closePath();
|
305 |
+
ctx.fill();
|
306 |
+
});
|
307 |
+
|
308 |
+
ctx.restore();
|
309 |
+
}
|
310 |
+
|
311 |
+
moveDiscs() {
|
312 |
+
this.discs.forEach((disc) => {
|
313 |
+
disc.p = (disc.p + 0.001) % 1;
|
314 |
+
this.tweenDisc(disc);
|
315 |
+
});
|
316 |
+
}
|
317 |
+
|
318 |
+
moveParticles() {
|
319 |
+
this.particles.forEach((particle) => {
|
320 |
+
particle.p = 1 - particle.y / this.particleArea.h;
|
321 |
+
particle.x = particle.sx + particle.dx * particle.p;
|
322 |
+
particle.y -= particle.vy;
|
323 |
+
|
324 |
+
if (particle.y < 0) {
|
325 |
+
particle.y = this.initParticle().y;
|
326 |
+
}
|
327 |
+
});
|
328 |
+
}
|
329 |
+
|
330 |
+
tweenDisc(disc) {
|
331 |
+
disc.x = this.tweenValue(this.startDisc.x, this.endDisc.x, disc.p);
|
332 |
+
disc.y = this.tweenValue(
|
333 |
+
this.startDisc.y,
|
334 |
+
this.endDisc.y,
|
335 |
+
disc.p,
|
336 |
+
"inExpo"
|
337 |
+
);
|
338 |
+
|
339 |
+
disc.w = this.tweenValue(this.startDisc.w, this.endDisc.w, disc.p);
|
340 |
+
disc.h = this.tweenValue(this.startDisc.h, this.endDisc.h, disc.p);
|
341 |
+
|
342 |
+
return disc;
|
343 |
+
}
|
344 |
+
|
345 |
+
tick() {
|
346 |
+
const { ctx } = this;
|
347 |
+
|
348 |
+
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
349 |
+
|
350 |
+
ctx.save();
|
351 |
+
ctx.scale(this.render.dpi, this.render.dpi);
|
352 |
+
|
353 |
+
this.moveDiscs();
|
354 |
+
this.moveParticles();
|
355 |
+
|
356 |
+
this.drawDiscs();
|
357 |
+
this.drawLines();
|
358 |
+
this.drawParticles();
|
359 |
+
|
360 |
+
ctx.restore();
|
361 |
+
|
362 |
+
this.animationId = requestAnimationFrame(this.tick.bind(this));
|
363 |
+
}
|
364 |
+
}
|
365 |
+
|
366 |
+
// Define custom element
|
367 |
+
if (!customElements.get('a-hole')) {
|
368 |
+
customElements.define("a-hole", AHole);
|
369 |
+
}
|
370 |
+
|
371 |
+
const AHoleLoader = ({ className = "", style = {} }) => {
|
372 |
+
const containerRef = useRef(null);
|
373 |
+
|
374 |
+
useEffect(() => {
|
375 |
+
// Force a resize event to ensure proper initialization
|
376 |
+
const timer = setTimeout(() => {
|
377 |
+
window.dispatchEvent(new Event('resize'));
|
378 |
+
}, 100);
|
379 |
+
|
380 |
+
return () => clearTimeout(timer);
|
381 |
+
}, []);
|
382 |
+
|
383 |
+
return (
|
384 |
+
<div
|
385 |
+
ref={containerRef}
|
386 |
+
className={`a-hole-loader ${className}`}
|
387 |
+
style={{
|
388 |
+
position: 'relative',
|
389 |
+
width: '100%',
|
390 |
+
height: '100%',
|
391 |
+
overflow: 'hidden',
|
392 |
+
...style
|
393 |
+
}}
|
394 |
+
>
|
395 |
+
<a-hole>
|
396 |
+
<canvas className="js-canvas"></canvas>
|
397 |
+
<div className="aura"></div>
|
398 |
+
<div className="overlay"></div>
|
399 |
+
</a-hole>
|
400 |
+
</div>
|
401 |
+
);
|
402 |
+
};
|
403 |
+
|
404 |
+
export default AHoleLoader;
|
src/components/DocumentViewer.js
ADDED
@@ -0,0 +1,552 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
3 |
+
import {
|
4 |
+
FileText, Download, Eye, ZoomIn, ZoomOut, RotateCw,
|
5 |
+
Maximize2, ArrowLeft, Settings, Printer, Share2
|
6 |
+
} from 'lucide-react';
|
7 |
+
|
8 |
+
const DocumentViewer = ({ file, onBack }) => {
|
9 |
+
const [viewMode, setViewMode] = useState('preview');
|
10 |
+
const [zoom, setZoom] = useState(1);
|
11 |
+
const [rotation, setRotation] = useState(0);
|
12 |
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
13 |
+
const [documentUrl, setDocumentUrl] = useState(null);
|
14 |
+
const [isLoading, setIsLoading] = useState(true);
|
15 |
+
const viewerRef = useRef(null);
|
16 |
+
|
17 |
+
useEffect(() => {
|
18 |
+
if (file) {
|
19 |
+
const url = URL.createObjectURL(file);
|
20 |
+
setDocumentUrl(url);
|
21 |
+
setIsLoading(false);
|
22 |
+
|
23 |
+
return () => URL.revokeObjectURL(url);
|
24 |
+
}
|
25 |
+
}, [file]);
|
26 |
+
|
27 |
+
const handleZoomIn = () => setZoom(prev => Math.min(prev + 0.25, 3));
|
28 |
+
const handleZoomOut = () => setZoom(prev => Math.max(prev - 0.25, 0.5));
|
29 |
+
const handleRotate = () => setRotation(prev => (prev + 90) % 360);
|
30 |
+
|
31 |
+
const toggleFullscreen = async () => {
|
32 |
+
try {
|
33 |
+
if (!document.fullscreenElement) {
|
34 |
+
await viewerRef.current?.requestFullscreen();
|
35 |
+
setIsFullscreen(true);
|
36 |
+
} else {
|
37 |
+
await document.exitFullscreen();
|
38 |
+
setIsFullscreen(false);
|
39 |
+
}
|
40 |
+
} catch (error) {
|
41 |
+
setIsFullscreen(!isFullscreen);
|
42 |
+
}
|
43 |
+
};
|
44 |
+
|
45 |
+
const handleDownload = () => {
|
46 |
+
if (documentUrl) {
|
47 |
+
const a = document.createElement('a');
|
48 |
+
a.href = documentUrl;
|
49 |
+
a.download = file.name;
|
50 |
+
document.body.appendChild(a);
|
51 |
+
a.click();
|
52 |
+
document.body.removeChild(a);
|
53 |
+
}
|
54 |
+
};
|
55 |
+
|
56 |
+
const renderPDFViewer = () => (
|
57 |
+
<div className="pdf-viewer-container">
|
58 |
+
<div className="pdf-embed-wrapper" style={{
|
59 |
+
transform: `scale(${zoom}) rotate(${rotation}deg)`,
|
60 |
+
transformOrigin: 'center center'
|
61 |
+
}}>
|
62 |
+
<embed
|
63 |
+
src={documentUrl}
|
64 |
+
type="application/pdf"
|
65 |
+
className="pdf-embed"
|
66 |
+
title={file.name}
|
67 |
+
/>
|
68 |
+
</div>
|
69 |
+
</div>
|
70 |
+
);
|
71 |
+
|
72 |
+
const renderHTMLViewer = () => (
|
73 |
+
<div className="html-viewer-container">
|
74 |
+
<div className="html-content-wrapper" style={{
|
75 |
+
transform: `scale(${zoom}) rotate(${rotation}deg)`,
|
76 |
+
transformOrigin: 'center center'
|
77 |
+
}}>
|
78 |
+
<iframe
|
79 |
+
src={documentUrl}
|
80 |
+
className="html-iframe"
|
81 |
+
title={file.name}
|
82 |
+
sandbox="allow-same-origin allow-scripts"
|
83 |
+
/>
|
84 |
+
</div>
|
85 |
+
</div>
|
86 |
+
);
|
87 |
+
|
88 |
+
if (!file) return null;
|
89 |
+
|
90 |
+
const isPDF = file.type === 'application/pdf';
|
91 |
+
const isHTML = file.type === 'text/html' || file.name.toLowerCase().endsWith('.html');
|
92 |
+
|
93 |
+
return (
|
94 |
+
<div className={`document-viewer ${isFullscreen ? 'fullscreen' : ''}`} ref={viewerRef}>
|
95 |
+
{/* Glassmorphism Background */}
|
96 |
+
<div className="viewer-background">
|
97 |
+
<div className="orb-background-container">
|
98 |
+
<div className="floating-orb orb-1"></div>
|
99 |
+
<div className="floating-orb orb-2"></div>
|
100 |
+
<div className="floating-orb orb-3"></div>
|
101 |
+
</div>
|
102 |
+
</div>
|
103 |
+
|
104 |
+
{/* Header Controls */}
|
105 |
+
<motion.header
|
106 |
+
className="viewer-header glass-panel"
|
107 |
+
initial={{ opacity: 0, y: -20 }}
|
108 |
+
animate={{ opacity: 1, y: 0 }}
|
109 |
+
transition={{ duration: 0.6 }}
|
110 |
+
>
|
111 |
+
<div className="header-left">
|
112 |
+
<motion.button
|
113 |
+
className="control-btn back-btn"
|
114 |
+
onClick={onBack}
|
115 |
+
whileHover={{ scale: 1.05 }}
|
116 |
+
whileTap={{ scale: 0.95 }}
|
117 |
+
>
|
118 |
+
<ArrowLeft size={18} />
|
119 |
+
Back
|
120 |
+
</motion.button>
|
121 |
+
|
122 |
+
<div className="document-info">
|
123 |
+
<h2 className="document-title">{file.name}</h2>
|
124 |
+
<div className="document-meta">
|
125 |
+
<span className="format-badge">
|
126 |
+
{isPDF ? 'PDF' : 'HTML'}
|
127 |
+
</span>
|
128 |
+
<span className="file-size">
|
129 |
+
{(file.size / 1024 / 1024).toFixed(2)} MB
|
130 |
+
</span>
|
131 |
+
</div>
|
132 |
+
</div>
|
133 |
+
</div>
|
134 |
+
|
135 |
+
<div className="header-controls">
|
136 |
+
<div className="zoom-controls glass-group">
|
137 |
+
<motion.button
|
138 |
+
className="control-btn"
|
139 |
+
onClick={handleZoomOut}
|
140 |
+
disabled={zoom <= 0.5}
|
141 |
+
whileHover={{ scale: 1.05 }}
|
142 |
+
whileTap={{ scale: 0.95 }}
|
143 |
+
>
|
144 |
+
<ZoomOut size={16} />
|
145 |
+
</motion.button>
|
146 |
+
<span className="zoom-display">{Math.round(zoom * 100)}%</span>
|
147 |
+
<motion.button
|
148 |
+
className="control-btn"
|
149 |
+
onClick={handleZoomIn}
|
150 |
+
disabled={zoom >= 3}
|
151 |
+
whileHover={{ scale: 1.05 }}
|
152 |
+
whileTap={{ scale: 0.95 }}
|
153 |
+
>
|
154 |
+
<ZoomIn size={16} />
|
155 |
+
</motion.button>
|
156 |
+
</div>
|
157 |
+
|
158 |
+
<motion.button
|
159 |
+
className="control-btn"
|
160 |
+
onClick={handleRotate}
|
161 |
+
whileHover={{ scale: 1.05 }}
|
162 |
+
whileTap={{ scale: 0.95 }}
|
163 |
+
>
|
164 |
+
<RotateCw size={16} />
|
165 |
+
</motion.button>
|
166 |
+
|
167 |
+
<motion.button
|
168 |
+
className="control-btn"
|
169 |
+
onClick={toggleFullscreen}
|
170 |
+
whileHover={{ scale: 1.05 }}
|
171 |
+
whileTap={{ scale: 0.95 }}
|
172 |
+
>
|
173 |
+
<Maximize2 size={16} />
|
174 |
+
</motion.button>
|
175 |
+
|
176 |
+
<motion.button
|
177 |
+
className="control-btn primary"
|
178 |
+
onClick={handleDownload}
|
179 |
+
whileHover={{ scale: 1.05 }}
|
180 |
+
whileTap={{ scale: 0.95 }}
|
181 |
+
>
|
182 |
+
<Download size={16} />
|
183 |
+
Download
|
184 |
+
</motion.button>
|
185 |
+
</div>
|
186 |
+
</motion.header>
|
187 |
+
|
188 |
+
{/* Document Content */}
|
189 |
+
<motion.main
|
190 |
+
className="viewer-content glass-panel"
|
191 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
192 |
+
animate={{ opacity: 1, scale: 1 }}
|
193 |
+
transition={{ duration: 0.6, delay: 0.1 }}
|
194 |
+
>
|
195 |
+
<AnimatePresence mode="wait">
|
196 |
+
{isLoading ? (
|
197 |
+
<motion.div
|
198 |
+
key="loading"
|
199 |
+
className="loading-state"
|
200 |
+
initial={{ opacity: 0 }}
|
201 |
+
animate={{ opacity: 1 }}
|
202 |
+
exit={{ opacity: 0 }}
|
203 |
+
>
|
204 |
+
<div className="loading-spinner"></div>
|
205 |
+
<p>Loading document...</p>
|
206 |
+
</motion.div>
|
207 |
+
) : (
|
208 |
+
<motion.div
|
209 |
+
key="content"
|
210 |
+
className="document-content"
|
211 |
+
initial={{ opacity: 0 }}
|
212 |
+
animate={{ opacity: 1 }}
|
213 |
+
exit={{ opacity: 0 }}
|
214 |
+
>
|
215 |
+
{isPDF ? renderPDFViewer() : isHTML ? renderHTMLViewer() : (
|
216 |
+
<div className="unsupported-format">
|
217 |
+
<FileText size={48} />
|
218 |
+
<p>Unsupported file format</p>
|
219 |
+
</div>
|
220 |
+
)}
|
221 |
+
</motion.div>
|
222 |
+
)}
|
223 |
+
</AnimatePresence>
|
224 |
+
</motion.main>
|
225 |
+
|
226 |
+
<style jsx>{`
|
227 |
+
.document-viewer {
|
228 |
+
position: fixed;
|
229 |
+
top: 0;
|
230 |
+
left: 0;
|
231 |
+
width: 100vw;
|
232 |
+
height: 100vh;
|
233 |
+
background: #0f0f23;
|
234 |
+
z-index: 1000;
|
235 |
+
display: flex;
|
236 |
+
flex-direction: column;
|
237 |
+
overflow: hidden;
|
238 |
+
}
|
239 |
+
|
240 |
+
.document-viewer.fullscreen {
|
241 |
+
z-index: 9999;
|
242 |
+
}
|
243 |
+
|
244 |
+
.viewer-background {
|
245 |
+
position: absolute;
|
246 |
+
top: 0;
|
247 |
+
left: 0;
|
248 |
+
width: 100%;
|
249 |
+
height: 100%;
|
250 |
+
pointer-events: none;
|
251 |
+
z-index: -1;
|
252 |
+
}
|
253 |
+
|
254 |
+
.orb-background-container {
|
255 |
+
position: relative;
|
256 |
+
width: 100%;
|
257 |
+
height: 100%;
|
258 |
+
overflow: hidden;
|
259 |
+
}
|
260 |
+
|
261 |
+
.floating-orb {
|
262 |
+
position: absolute;
|
263 |
+
border-radius: 50%;
|
264 |
+
filter: blur(80px);
|
265 |
+
opacity: 0.3;
|
266 |
+
animation: float 20s ease-in-out infinite;
|
267 |
+
}
|
268 |
+
|
269 |
+
.orb-1 {
|
270 |
+
top: 10%;
|
271 |
+
right: 20%;
|
272 |
+
width: 300px;
|
273 |
+
height: 300px;
|
274 |
+
background: linear-gradient(45deg, #4facfe, #8b5cf6);
|
275 |
+
animation-delay: 0s;
|
276 |
+
}
|
277 |
+
|
278 |
+
.orb-2 {
|
279 |
+
bottom: 20%;
|
280 |
+
left: 15%;
|
281 |
+
width: 250px;
|
282 |
+
height: 250px;
|
283 |
+
background: linear-gradient(45deg, #f093fb, #f5576c);
|
284 |
+
animation-delay: -7s;
|
285 |
+
}
|
286 |
+
|
287 |
+
.orb-3 {
|
288 |
+
top: 50%;
|
289 |
+
left: 50%;
|
290 |
+
width: 200px;
|
291 |
+
height: 200px;
|
292 |
+
background: linear-gradient(45deg, #06b6d4, #10b981);
|
293 |
+
animation-delay: -14s;
|
294 |
+
}
|
295 |
+
|
296 |
+
@keyframes float {
|
297 |
+
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
298 |
+
25% { transform: translate(-20px, -30px) rotate(90deg); }
|
299 |
+
50% { transform: translate(10px, -20px) rotate(180deg); }
|
300 |
+
75% { transform: translate(-10px, 10px) rotate(270deg); }
|
301 |
+
}
|
302 |
+
|
303 |
+
.viewer-header {
|
304 |
+
display: flex;
|
305 |
+
justify-content: space-between;
|
306 |
+
align-items: center;
|
307 |
+
padding: 16px 24px;
|
308 |
+
margin: 12px;
|
309 |
+
margin-bottom: 0;
|
310 |
+
border-radius: 16px;
|
311 |
+
backdrop-filter: blur(25px);
|
312 |
+
-webkit-backdrop-filter: blur(25px);
|
313 |
+
background: rgba(0, 0, 0, 0.2);
|
314 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
315 |
+
flex-shrink: 0;
|
316 |
+
}
|
317 |
+
|
318 |
+
.header-left {
|
319 |
+
display: flex;
|
320 |
+
align-items: center;
|
321 |
+
gap: 20px;
|
322 |
+
}
|
323 |
+
|
324 |
+
.back-btn {
|
325 |
+
display: flex;
|
326 |
+
align-items: center;
|
327 |
+
gap: 8px;
|
328 |
+
padding: 8px 16px;
|
329 |
+
background: rgba(255, 255, 255, 0.05);
|
330 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
331 |
+
border-radius: 8px;
|
332 |
+
color: #ffffff;
|
333 |
+
font-size: 0.875rem;
|
334 |
+
cursor: pointer;
|
335 |
+
transition: all 0.3s ease;
|
336 |
+
}
|
337 |
+
|
338 |
+
.back-btn:hover {
|
339 |
+
background: rgba(255, 255, 255, 0.1);
|
340 |
+
border-color: rgba(79, 172, 254, 0.3);
|
341 |
+
}
|
342 |
+
|
343 |
+
.document-info {
|
344 |
+
display: flex;
|
345 |
+
flex-direction: column;
|
346 |
+
gap: 4px;
|
347 |
+
}
|
348 |
+
|
349 |
+
.document-title {
|
350 |
+
font-size: 1.25rem;
|
351 |
+
font-weight: 600;
|
352 |
+
color: #ffffff;
|
353 |
+
margin: 0;
|
354 |
+
}
|
355 |
+
|
356 |
+
.document-meta {
|
357 |
+
display: flex;
|
358 |
+
align-items: center;
|
359 |
+
gap: 12px;
|
360 |
+
font-size: 0.75rem;
|
361 |
+
}
|
362 |
+
|
363 |
+
.format-badge {
|
364 |
+
background: rgba(79, 172, 254, 0.2);
|
365 |
+
color: #4facfe;
|
366 |
+
padding: 4px 12px;
|
367 |
+
border-radius: 6px;
|
368 |
+
font-weight: 500;
|
369 |
+
font-size: 0.75rem;
|
370 |
+
letter-spacing: 0.5px;
|
371 |
+
}
|
372 |
+
|
373 |
+
.file-size {
|
374 |
+
color: rgba(255, 255, 255, 0.6);
|
375 |
+
}
|
376 |
+
|
377 |
+
.header-controls {
|
378 |
+
display: flex;
|
379 |
+
align-items: center;
|
380 |
+
gap: 12px;
|
381 |
+
}
|
382 |
+
|
383 |
+
.glass-group {
|
384 |
+
display: flex;
|
385 |
+
align-items: center;
|
386 |
+
gap: 8px;
|
387 |
+
background: rgba(255, 255, 255, 0.05);
|
388 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
389 |
+
border-radius: 8px;
|
390 |
+
padding: 4px 8px;
|
391 |
+
}
|
392 |
+
|
393 |
+
.control-btn {
|
394 |
+
display: flex;
|
395 |
+
align-items: center;
|
396 |
+
gap: 6px;
|
397 |
+
padding: 8px 12px;
|
398 |
+
background: rgba(255, 255, 255, 0.05);
|
399 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
400 |
+
border-radius: 6px;
|
401 |
+
color: rgba(255, 255, 255, 0.8);
|
402 |
+
font-size: 0.875rem;
|
403 |
+
cursor: pointer;
|
404 |
+
transition: all 0.3s ease;
|
405 |
+
}
|
406 |
+
|
407 |
+
.control-btn:hover:not(:disabled) {
|
408 |
+
background: rgba(255, 255, 255, 0.1);
|
409 |
+
color: #ffffff;
|
410 |
+
border-color: rgba(79, 172, 254, 0.3);
|
411 |
+
}
|
412 |
+
|
413 |
+
.control-btn:disabled {
|
414 |
+
opacity: 0.5;
|
415 |
+
cursor: not-allowed;
|
416 |
+
}
|
417 |
+
|
418 |
+
.control-btn.primary {
|
419 |
+
background: linear-gradient(135deg, #4facfe, #8b5cf6);
|
420 |
+
border-color: rgba(79, 172, 254, 0.3);
|
421 |
+
color: #ffffff;
|
422 |
+
}
|
423 |
+
|
424 |
+
.control-btn.primary:hover {
|
425 |
+
box-shadow: 0 4px 15px rgba(79, 172, 254, 0.3);
|
426 |
+
}
|
427 |
+
|
428 |
+
.zoom-display {
|
429 |
+
font-size: 0.75rem;
|
430 |
+
color: rgba(255, 255, 255, 0.8);
|
431 |
+
min-width: 40px;
|
432 |
+
text-align: center;
|
433 |
+
}
|
434 |
+
|
435 |
+
.viewer-content {
|
436 |
+
flex: 1;
|
437 |
+
margin: 12px;
|
438 |
+
margin-top: 0;
|
439 |
+
border-radius: 16px;
|
440 |
+
backdrop-filter: blur(25px);
|
441 |
+
-webkit-backdrop-filter: blur(25px);
|
442 |
+
background: rgba(0, 0, 0, 0.1);
|
443 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
444 |
+
overflow: hidden;
|
445 |
+
position: relative;
|
446 |
+
}
|
447 |
+
|
448 |
+
.document-content {
|
449 |
+
width: 100%;
|
450 |
+
height: 100%;
|
451 |
+
display: flex;
|
452 |
+
align-items: center;
|
453 |
+
justify-content: center;
|
454 |
+
overflow: auto;
|
455 |
+
}
|
456 |
+
|
457 |
+
.pdf-viewer-container,
|
458 |
+
.html-viewer-container {
|
459 |
+
width: 100%;
|
460 |
+
height: 100%;
|
461 |
+
display: flex;
|
462 |
+
align-items: center;
|
463 |
+
justify-content: center;
|
464 |
+
padding: 20px;
|
465 |
+
}
|
466 |
+
|
467 |
+
.pdf-embed-wrapper,
|
468 |
+
.html-content-wrapper {
|
469 |
+
transition: transform 0.3s ease;
|
470 |
+
border-radius: 12px;
|
471 |
+
overflow: hidden;
|
472 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
473 |
+
}
|
474 |
+
|
475 |
+
.pdf-embed {
|
476 |
+
width: 80vw;
|
477 |
+
height: 80vh;
|
478 |
+
border: none;
|
479 |
+
border-radius: 12px;
|
480 |
+
background: rgba(255, 255, 255, 0.95);
|
481 |
+
}
|
482 |
+
|
483 |
+
.html-iframe {
|
484 |
+
width: 80vw;
|
485 |
+
height: 80vh;
|
486 |
+
border: none;
|
487 |
+
border-radius: 12px;
|
488 |
+
background: rgba(255, 255, 255, 0.95);
|
489 |
+
}
|
490 |
+
|
491 |
+
.loading-state {
|
492 |
+
display: flex;
|
493 |
+
flex-direction: column;
|
494 |
+
align-items: center;
|
495 |
+
justify-content: center;
|
496 |
+
gap: 16px;
|
497 |
+
color: rgba(255, 255, 255, 0.8);
|
498 |
+
}
|
499 |
+
|
500 |
+
.loading-spinner {
|
501 |
+
width: 40px;
|
502 |
+
height: 40px;
|
503 |
+
border: 3px solid rgba(79, 172, 254, 0.3);
|
504 |
+
border-top: 3px solid #4facfe;
|
505 |
+
border-radius: 50%;
|
506 |
+
animation: spin 1s linear infinite;
|
507 |
+
}
|
508 |
+
|
509 |
+
@keyframes spin {
|
510 |
+
0% { transform: rotate(0deg); }
|
511 |
+
100% { transform: rotate(360deg); }
|
512 |
+
}
|
513 |
+
|
514 |
+
.unsupported-format {
|
515 |
+
display: flex;
|
516 |
+
flex-direction: column;
|
517 |
+
align-items: center;
|
518 |
+
justify-content: center;
|
519 |
+
gap: 16px;
|
520 |
+
color: rgba(255, 255, 255, 0.6);
|
521 |
+
}
|
522 |
+
|
523 |
+
@media (max-width: 768px) {
|
524 |
+
.viewer-header {
|
525 |
+
flex-direction: column;
|
526 |
+
gap: 16px;
|
527 |
+
padding: 16px;
|
528 |
+
}
|
529 |
+
|
530 |
+
.header-left {
|
531 |
+
width: 100%;
|
532 |
+
justify-content: space-between;
|
533 |
+
}
|
534 |
+
|
535 |
+
.header-controls {
|
536 |
+
width: 100%;
|
537 |
+
justify-content: center;
|
538 |
+
flex-wrap: wrap;
|
539 |
+
}
|
540 |
+
|
541 |
+
.pdf-embed,
|
542 |
+
.html-iframe {
|
543 |
+
width: 95vw;
|
544 |
+
height: 70vh;
|
545 |
+
}
|
546 |
+
}
|
547 |
+
`}</style>
|
548 |
+
</div>
|
549 |
+
);
|
550 |
+
};
|
551 |
+
|
552 |
+
export default DocumentViewer;
|
src/components/FileUploader.js
ADDED
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react';
|
2 |
+
import { motion } from 'framer-motion';
|
3 |
+
import { Upload, FileText, Clipboard } from 'lucide-react';
|
4 |
+
|
5 |
+
const FileUploader = ({
|
6 |
+
getRootProps,
|
7 |
+
getInputProps,
|
8 |
+
isDragActive,
|
9 |
+
onFileSelect,
|
10 |
+
fileInputRef,
|
11 |
+
onFileChange
|
12 |
+
}) => {
|
13 |
+
|
14 |
+
const uploadMethods = [
|
15 |
+
{
|
16 |
+
id: 'drag',
|
17 |
+
icon: <Upload size={24} />,
|
18 |
+
title: 'Drag & Drop',
|
19 |
+
description: 'Drag files directly here',
|
20 |
+
action: 'drag-drop'
|
21 |
+
},
|
22 |
+
{
|
23 |
+
id: 'browse',
|
24 |
+
icon: <FileText size={24} />,
|
25 |
+
title: 'Browse Files',
|
26 |
+
description: 'Select from computer',
|
27 |
+
action: 'browse'
|
28 |
+
}
|
29 |
+
];
|
30 |
+
|
31 |
+
const handleMethodClick = (method) => {
|
32 |
+
if (method.action === 'browse') {
|
33 |
+
onFileSelect();
|
34 |
+
}
|
35 |
+
};
|
36 |
+
|
37 |
+
return (
|
38 |
+
<div className="file-uploader">
|
39 |
+
<motion.div
|
40 |
+
className={`upload-zone glass-panel ${isDragActive ? 'drag-active' : ''}`}
|
41 |
+
initial={{ opacity: 0, scale: 0.9 }}
|
42 |
+
animate={{ opacity: 1, scale: 1 }}
|
43 |
+
transition={{ duration: 0.5 }}
|
44 |
+
onDragOver={(e) => e.preventDefault()}
|
45 |
+
onDrop={(e) => {
|
46 |
+
e.preventDefault();
|
47 |
+
const files = e.dataTransfer.files;
|
48 |
+
if (files.length > 0) {
|
49 |
+
const file = files[0];
|
50 |
+
onFileChange({ target: { files: [file] } });
|
51 |
+
}
|
52 |
+
}}
|
53 |
+
>
|
54 |
+
<input {...getInputProps()} />
|
55 |
+
<input
|
56 |
+
ref={fileInputRef}
|
57 |
+
type="file"
|
58 |
+
onChange={onFileChange}
|
59 |
+
accept=".pdf,.png,.jpg,.jpeg,.html,.htm"
|
60 |
+
style={{ display: 'none' }}
|
61 |
+
/>
|
62 |
+
|
63 |
+
<div className="upload-content">
|
64 |
+
<div className="upload-text">
|
65 |
+
<h3>{isDragActive ? 'Drop files here' : 'Document Processing'}</h3>
|
66 |
+
<p>{isDragActive ? 'Release to process your document' : 'Drag & drop files or click to browse'}</p>
|
67 |
+
</div>
|
68 |
+
|
69 |
+
<div className="upload-actions">
|
70 |
+
<div className="button-group">
|
71 |
+
<motion.button
|
72 |
+
className="primary-upload-btn"
|
73 |
+
onClick={onFileSelect}
|
74 |
+
whileHover={{ scale: 1.05 }}
|
75 |
+
whileTap={{ scale: 0.95 }}
|
76 |
+
initial={{ opacity: 0, y: 20 }}
|
77 |
+
animate={{ opacity: 1, y: 0 }}
|
78 |
+
transition={{ duration: 0.5 }}
|
79 |
+
>
|
80 |
+
<Upload size={20} />
|
81 |
+
Upload Files
|
82 |
+
</motion.button>
|
83 |
+
|
84 |
+
<div className="button-divider"></div>
|
85 |
+
|
86 |
+
<motion.button
|
87 |
+
className="paste-btn"
|
88 |
+
onClick={() => {
|
89 |
+
// Trigger paste functionality
|
90 |
+
document.execCommand('paste');
|
91 |
+
}}
|
92 |
+
whileHover={{ scale: 1.05 }}
|
93 |
+
whileTap={{ scale: 0.95 }}
|
94 |
+
initial={{ opacity: 0, y: 20 }}
|
95 |
+
animate={{ opacity: 1, y: 0 }}
|
96 |
+
transition={{ duration: 0.5, delay: 0.1 }}
|
97 |
+
>
|
98 |
+
<Clipboard size={18} />
|
99 |
+
Paste Image
|
100 |
+
</motion.button>
|
101 |
+
</div>
|
102 |
+
</div>
|
103 |
+
|
104 |
+
<div className="supported-formats">
|
105 |
+
<p>Supported: PDF, HTML, PNG, JPG, JPEG</p>
|
106 |
+
</div>
|
107 |
+
</div>
|
108 |
+
</motion.div>
|
109 |
+
|
110 |
+
<style jsx>{`
|
111 |
+
.file-uploader {
|
112 |
+
width: 100%;
|
113 |
+
height: 100%;
|
114 |
+
}
|
115 |
+
|
116 |
+
.upload-zone {
|
117 |
+
min-height: 300px;
|
118 |
+
display: flex;
|
119 |
+
flex-direction: column;
|
120 |
+
align-items: center;
|
121 |
+
justify-content: center;
|
122 |
+
transition: all 0.3s ease;
|
123 |
+
position: relative;
|
124 |
+
overflow: hidden;
|
125 |
+
cursor: default;
|
126 |
+
}
|
127 |
+
|
128 |
+
.upload-zone.drag-active {
|
129 |
+
border-color: var(--accent-primary);
|
130 |
+
background: rgba(99, 102, 241, 0.05);
|
131 |
+
transform: scale(1.02);
|
132 |
+
}
|
133 |
+
|
134 |
+
.upload-content {
|
135 |
+
text-align: center;
|
136 |
+
width: 100%;
|
137 |
+
max-width: 400px;
|
138 |
+
}
|
139 |
+
|
140 |
+
.upload-actions {
|
141 |
+
margin: 32px 0;
|
142 |
+
}
|
143 |
+
|
144 |
+
.button-group {
|
145 |
+
display: flex;
|
146 |
+
flex-direction: column;
|
147 |
+
gap: 16px;
|
148 |
+
justify-content: center;
|
149 |
+
align-items: center;
|
150 |
+
}
|
151 |
+
|
152 |
+
.button-divider {
|
153 |
+
width: 60px;
|
154 |
+
height: 1px;
|
155 |
+
background: rgba(255, 255, 255, 0.15);
|
156 |
+
}
|
157 |
+
|
158 |
+
.primary-upload-btn {
|
159 |
+
background: linear-gradient(135deg, #8b5cf6 0%, #3b82f6 100%);
|
160 |
+
border: none;
|
161 |
+
border-radius: 12px;
|
162 |
+
padding: 18px 40px;
|
163 |
+
color: white;
|
164 |
+
font-size: 1.1rem;
|
165 |
+
font-weight: 600;
|
166 |
+
cursor: pointer;
|
167 |
+
display: flex;
|
168 |
+
align-items: center;
|
169 |
+
gap: 14px;
|
170 |
+
box-shadow: 0 4px 20px rgba(139, 92, 246, 0.4);
|
171 |
+
transition: all 0.3s ease;
|
172 |
+
}
|
173 |
+
|
174 |
+
.primary-upload-btn:hover {
|
175 |
+
box-shadow: 0 6px 25px rgba(139, 92, 246, 0.6);
|
176 |
+
transform: translateY(-2px);
|
177 |
+
}
|
178 |
+
|
179 |
+
.paste-btn {
|
180 |
+
background: transparent;
|
181 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
182 |
+
border-radius: 8px;
|
183 |
+
padding: 12px 20px;
|
184 |
+
color: rgba(255, 255, 255, 0.6);
|
185 |
+
font-size: 0.85rem;
|
186 |
+
font-weight: 400;
|
187 |
+
cursor: pointer;
|
188 |
+
display: flex;
|
189 |
+
align-items: center;
|
190 |
+
gap: 8px;
|
191 |
+
transition: all 0.3s ease;
|
192 |
+
}
|
193 |
+
|
194 |
+
.paste-btn:hover {
|
195 |
+
background: rgba(255, 255, 255, 0.05);
|
196 |
+
border-color: rgba(255, 255, 255, 0.15);
|
197 |
+
color: rgba(255, 255, 255, 0.8);
|
198 |
+
}
|
199 |
+
|
200 |
+
.upload-divider {
|
201 |
+
margin: 24px 0;
|
202 |
+
position: relative;
|
203 |
+
color: rgba(255, 255, 255, 0.5);
|
204 |
+
font-size: 0.875rem;
|
205 |
+
}
|
206 |
+
|
207 |
+
.upload-divider::before {
|
208 |
+
content: '';
|
209 |
+
position: absolute;
|
210 |
+
top: 50%;
|
211 |
+
left: 0;
|
212 |
+
right: 0;
|
213 |
+
height: 1px;
|
214 |
+
background: rgba(255, 255, 255, 0.1);
|
215 |
+
z-index: 0;
|
216 |
+
}
|
217 |
+
|
218 |
+
.upload-divider span {
|
219 |
+
background: rgba(0, 0, 0, 0.3);
|
220 |
+
padding: 0 16px;
|
221 |
+
position: relative;
|
222 |
+
z-index: 1;
|
223 |
+
}
|
224 |
+
|
225 |
+
.secondary-upload-btn {
|
226 |
+
background: rgba(255, 255, 255, 0.05);
|
227 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
228 |
+
border-radius: 8px;
|
229 |
+
padding: 12px 20px;
|
230 |
+
color: rgba(255, 255, 255, 0.7);
|
231 |
+
font-size: 0.875rem;
|
232 |
+
cursor: pointer;
|
233 |
+
display: flex;
|
234 |
+
align-items: center;
|
235 |
+
gap: 8px;
|
236 |
+
margin: 0 auto;
|
237 |
+
transition: all 0.3s ease;
|
238 |
+
}
|
239 |
+
|
240 |
+
.secondary-upload-btn:hover {
|
241 |
+
background: rgba(255, 255, 255, 0.08);
|
242 |
+
color: rgba(255, 255, 255, 0.9);
|
243 |
+
border-color: rgba(255, 255, 255, 0.2);
|
244 |
+
}
|
245 |
+
|
246 |
+
.secondary-upload-btn.disabled {
|
247 |
+
opacity: 0.4;
|
248 |
+
cursor: not-allowed;
|
249 |
+
pointer-events: none;
|
250 |
+
}
|
251 |
+
|
252 |
+
.supported-formats {
|
253 |
+
margin-top: 24px;
|
254 |
+
}
|
255 |
+
|
256 |
+
.supported-formats p {
|
257 |
+
color: rgba(255, 255, 255, 0.4);
|
258 |
+
font-size: 0.75rem;
|
259 |
+
margin: 0;
|
260 |
+
}
|
261 |
+
|
262 |
+
.upload-icon {
|
263 |
+
margin-bottom: 16px;
|
264 |
+
color: var(--accent-primary);
|
265 |
+
}
|
266 |
+
|
267 |
+
.drag-indicator {
|
268 |
+
font-size: 3rem;
|
269 |
+
filter: drop-shadow(0 0 20px rgba(99, 102, 241, 0.5));
|
270 |
+
}
|
271 |
+
|
272 |
+
.upload-svg {
|
273 |
+
filter: drop-shadow(0 0 10px rgba(99, 102, 241, 0.3));
|
274 |
+
}
|
275 |
+
|
276 |
+
.upload-text h3 {
|
277 |
+
font-size: 1.25rem;
|
278 |
+
font-weight: 600;
|
279 |
+
margin-bottom: 8px;
|
280 |
+
color: var(--text-primary);
|
281 |
+
}
|
282 |
+
|
283 |
+
.upload-text p {
|
284 |
+
color: var(--text-secondary);
|
285 |
+
font-size: 0.875rem;
|
286 |
+
max-width: 400px;
|
287 |
+
margin: 0 auto;
|
288 |
+
}
|
289 |
+
|
290 |
+
.upload-methods {
|
291 |
+
display: grid;
|
292 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
293 |
+
gap: 16px;
|
294 |
+
width: 100%;
|
295 |
+
max-width: 800px;
|
296 |
+
}
|
297 |
+
|
298 |
+
.upload-method {
|
299 |
+
display: flex;
|
300 |
+
align-items: center;
|
301 |
+
gap: 16px;
|
302 |
+
cursor: pointer;
|
303 |
+
transition: all 0.3s ease;
|
304 |
+
position: relative;
|
305 |
+
overflow: hidden;
|
306 |
+
}
|
307 |
+
|
308 |
+
.method-icon {
|
309 |
+
color: var(--accent-secondary);
|
310 |
+
flex-shrink: 0;
|
311 |
+
}
|
312 |
+
|
313 |
+
.method-content h4 {
|
314 |
+
font-size: 0.875rem;
|
315 |
+
font-weight: 600;
|
316 |
+
margin-bottom: 4px;
|
317 |
+
color: var(--text-primary);
|
318 |
+
}
|
319 |
+
|
320 |
+
.method-content p {
|
321 |
+
font-size: 0.75rem;
|
322 |
+
color: var(--text-muted);
|
323 |
+
}
|
324 |
+
|
325 |
+
@media (max-width: 768px) {
|
326 |
+
.upload-methods {
|
327 |
+
grid-template-columns: 1fr;
|
328 |
+
}
|
329 |
+
|
330 |
+
.upload-method {
|
331 |
+
justify-content: center;
|
332 |
+
text-align: center;
|
333 |
+
flex-direction: column;
|
334 |
+
gap: 8px;
|
335 |
+
}
|
336 |
+
}
|
337 |
+
`}</style>
|
338 |
+
</div>
|
339 |
+
);
|
340 |
+
};
|
341 |
+
|
342 |
+
export default FileUploader;
|
src/components/FlowingMenu.js
ADDED
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react';
|
2 |
+
import { gsap } from 'gsap';
|
3 |
+
|
4 |
+
function FlowingMenu({ items = [], activeItem, onItemClick }) {
|
5 |
+
return (
|
6 |
+
<div className="flowing-menu-container">
|
7 |
+
<nav className="flowing-menu-nav">
|
8 |
+
{items.map((item, idx) => (
|
9 |
+
<MenuItem
|
10 |
+
key={item.id}
|
11 |
+
link="#"
|
12 |
+
text={item.label}
|
13 |
+
image={`https://picsum.photos/600/400?random=${idx + 1}`}
|
14 |
+
isActive={activeItem === item.id}
|
15 |
+
onClick={() => onItemClick(item.id)}
|
16 |
+
icon={item.icon}
|
17 |
+
/>
|
18 |
+
))}
|
19 |
+
</nav>
|
20 |
+
</div>
|
21 |
+
);
|
22 |
+
}
|
23 |
+
|
24 |
+
function MenuItem({ link, text, image, isActive, onClick, icon }) {
|
25 |
+
const itemRef = React.useRef(null);
|
26 |
+
const marqueeRef = React.useRef(null);
|
27 |
+
const marqueeInnerRef = React.useRef(null);
|
28 |
+
|
29 |
+
const animationDefaults = { duration: 0.6, ease: 'expo' };
|
30 |
+
|
31 |
+
const findClosestEdge = (mouseX, mouseY, width, height) => {
|
32 |
+
const topEdgeDist = (mouseX - width / 2) ** 2 + mouseY ** 2;
|
33 |
+
const bottomEdgeDist = (mouseX - width / 2) ** 2 + (mouseY - height) ** 2;
|
34 |
+
return topEdgeDist < bottomEdgeDist ? 'top' : 'bottom';
|
35 |
+
};
|
36 |
+
|
37 |
+
const handleMouseEnter = (ev) => {
|
38 |
+
if (!itemRef.current || !marqueeRef.current || !marqueeInnerRef.current) return;
|
39 |
+
|
40 |
+
const rect = itemRef.current.getBoundingClientRect();
|
41 |
+
const edge = findClosestEdge(
|
42 |
+
ev.clientX - rect.left,
|
43 |
+
ev.clientY - rect.top,
|
44 |
+
rect.width,
|
45 |
+
rect.height
|
46 |
+
);
|
47 |
+
|
48 |
+
gsap.timeline({ defaults: animationDefaults })
|
49 |
+
.set(marqueeRef.current, { y: edge === 'top' ? '-101%' : '101%' })
|
50 |
+
.set(marqueeInnerRef.current, { y: edge === 'top' ? '101%' : '-101%' })
|
51 |
+
.to([marqueeRef.current, marqueeInnerRef.current], { y: '0%' });
|
52 |
+
};
|
53 |
+
|
54 |
+
const handleMouseLeave = (ev) => {
|
55 |
+
if (!itemRef.current || !marqueeRef.current || !marqueeInnerRef.current) return;
|
56 |
+
|
57 |
+
const rect = itemRef.current.getBoundingClientRect();
|
58 |
+
const edge = findClosestEdge(
|
59 |
+
ev.clientX - rect.left,
|
60 |
+
ev.clientY - rect.top,
|
61 |
+
rect.width,
|
62 |
+
rect.height
|
63 |
+
);
|
64 |
+
|
65 |
+
gsap.timeline({ defaults: animationDefaults })
|
66 |
+
.to(marqueeRef.current, { y: edge === 'top' ? '-101%' : '101%' })
|
67 |
+
.to(marqueeInnerRef.current, { y: edge === 'top' ? '101%' : '-101%' });
|
68 |
+
};
|
69 |
+
|
70 |
+
const repeatedMarqueeContent = [];
|
71 |
+
|
72 |
+
return (
|
73 |
+
<div className={`flowing-menu-item ${isActive ? 'active' : ''}`} ref={itemRef}>
|
74 |
+
<a
|
75 |
+
className="menu-item-link"
|
76 |
+
href={link}
|
77 |
+
onClick={(e) => {
|
78 |
+
e.preventDefault();
|
79 |
+
onClick();
|
80 |
+
}}
|
81 |
+
onMouseEnter={handleMouseEnter}
|
82 |
+
onMouseLeave={handleMouseLeave}
|
83 |
+
>
|
84 |
+
<span className="menu-icon">{icon}</span>
|
85 |
+
<span className="menu-text">{text}</span>
|
86 |
+
</a>
|
87 |
+
<div className="marquee-overlay" ref={marqueeRef}>
|
88 |
+
<div className="marquee-inner" ref={marqueeInnerRef}>
|
89 |
+
<div className="marquee-content">
|
90 |
+
{repeatedMarqueeContent}
|
91 |
+
</div>
|
92 |
+
</div>
|
93 |
+
</div>
|
94 |
+
</div>
|
95 |
+
);
|
96 |
+
}
|
97 |
+
|
98 |
+
export default FlowingMenu;
|
src/components/LoadingScreen.js
ADDED
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useEffect, useState } from 'react';
|
2 |
+
import AHoleLoader from './AHoleLoader';
|
3 |
+
import './AHoleLoader.css';
|
4 |
+
|
5 |
+
const LoadingScreen = ({ onComplete }) => {
|
6 |
+
const [isVisible, setIsVisible] = useState(true);
|
7 |
+
|
8 |
+
useEffect(() => {
|
9 |
+
// Cycling loading messages and colors
|
10 |
+
const messages = [
|
11 |
+
{ text: "LOADING...", color: "#00f8f1" },
|
12 |
+
{ text: "MADE BY LUNA...", color: "#ff6b9d" },
|
13 |
+
{ text: "OCR EXTRACTOR...", color: "#4facfe" },
|
14 |
+
{ text: "LUNA POWERED...", color: "#a855f7" },
|
15 |
+
{ text: "TEXT EXTRACTION...", color: "#10b981" }
|
16 |
+
];
|
17 |
+
|
18 |
+
let messageIndex = 0;
|
19 |
+
const textElement = document.getElementById('loading-text');
|
20 |
+
|
21 |
+
const cycleMessages = () => {
|
22 |
+
if (textElement) {
|
23 |
+
const currentMessage = messages[messageIndex];
|
24 |
+
textElement.textContent = currentMessage.text;
|
25 |
+
textElement.style.color = currentMessage.color;
|
26 |
+
textElement.style.textShadow = `0 0 8px ${currentMessage.color}`;
|
27 |
+
|
28 |
+
// Update dots color to match
|
29 |
+
const dots = document.querySelectorAll('.dot');
|
30 |
+
dots.forEach(dot => {
|
31 |
+
dot.style.background = currentMessage.color;
|
32 |
+
dot.style.boxShadow = `0 0 4px ${currentMessage.color}`;
|
33 |
+
});
|
34 |
+
|
35 |
+
messageIndex = (messageIndex + 1) % messages.length;
|
36 |
+
}
|
37 |
+
};
|
38 |
+
|
39 |
+
// Start cycling immediately
|
40 |
+
cycleMessages();
|
41 |
+
const messageTimer = setInterval(cycleMessages, 800);
|
42 |
+
|
43 |
+
// Auto-hide after 4 seconds
|
44 |
+
const timer = setTimeout(() => {
|
45 |
+
setIsVisible(false);
|
46 |
+
setTimeout(() => {
|
47 |
+
onComplete?.();
|
48 |
+
}, 500); // Wait for fade out animation
|
49 |
+
}, 4000);
|
50 |
+
|
51 |
+
return () => {
|
52 |
+
clearTimeout(timer);
|
53 |
+
clearInterval(messageTimer);
|
54 |
+
};
|
55 |
+
}, [onComplete]);
|
56 |
+
|
57 |
+
if (!isVisible) {
|
58 |
+
return (
|
59 |
+
<div
|
60 |
+
className="loading-screen"
|
61 |
+
style={{
|
62 |
+
opacity: 0,
|
63 |
+
transition: 'opacity 0.5s ease',
|
64 |
+
pointerEvents: 'none'
|
65 |
+
}}
|
66 |
+
/>
|
67 |
+
);
|
68 |
+
}
|
69 |
+
|
70 |
+
return (
|
71 |
+
<div className="loading-screen">
|
72 |
+
<div className="a-hole-container">
|
73 |
+
<AHoleLoader />
|
74 |
+
</div>
|
75 |
+
|
76 |
+
{/* Pixelated loading indicator in bottom left */}
|
77 |
+
<div className="pixelated-loading">
|
78 |
+
<div className="loading-dots">
|
79 |
+
<div className="dot"></div>
|
80 |
+
<div className="dot"></div>
|
81 |
+
<div className="dot"></div>
|
82 |
+
<div className="dot"></div>
|
83 |
+
</div>
|
84 |
+
<span className="loading-text" id="loading-text">LOADING...</span>
|
85 |
+
</div>
|
86 |
+
</div>
|
87 |
+
);
|
88 |
+
};
|
89 |
+
|
90 |
+
export default LoadingScreen;
|
src/components/Orb.js
ADDED
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useEffect, useRef } from "react";
|
2 |
+
import { Renderer, Program, Mesh, Triangle, Vec3 } from "ogl";
|
3 |
+
|
4 |
+
const vert = /* glsl */ `
|
5 |
+
precision highp float;
|
6 |
+
attribute vec2 position;
|
7 |
+
attribute vec2 uv;
|
8 |
+
varying vec2 vUv;
|
9 |
+
void main() {
|
10 |
+
vUv = uv;
|
11 |
+
gl_Position = vec4(position, 0.0, 1.0);
|
12 |
+
}
|
13 |
+
`;
|
14 |
+
|
15 |
+
const frag = /* glsl */ `
|
16 |
+
precision highp float;
|
17 |
+
|
18 |
+
uniform float iTime;
|
19 |
+
uniform vec3 iResolution;
|
20 |
+
uniform float hue;
|
21 |
+
uniform float hover;
|
22 |
+
uniform float rot;
|
23 |
+
uniform float hoverIntensity;
|
24 |
+
varying vec2 vUv;
|
25 |
+
|
26 |
+
vec3 rgb2yiq(vec3 c) {
|
27 |
+
float y = dot(c, vec3(0.299, 0.587, 0.114));
|
28 |
+
float i = dot(c, vec3(0.596, -0.274, -0.322));
|
29 |
+
float q = dot(c, vec3(0.211, -0.523, 0.312));
|
30 |
+
return vec3(y, i, q);
|
31 |
+
}
|
32 |
+
|
33 |
+
vec3 yiq2rgb(vec3 c) {
|
34 |
+
float r = c.x + 0.956 * c.y + 0.621 * c.z;
|
35 |
+
float g = c.x - 0.272 * c.y - 0.647 * c.z;
|
36 |
+
float b = c.x - 1.106 * c.y + 1.703 * c.z;
|
37 |
+
return vec3(r, g, b);
|
38 |
+
}
|
39 |
+
|
40 |
+
vec3 adjustHue(vec3 color, float hueDeg) {
|
41 |
+
float hueRad = hueDeg * 3.14159265 / 180.0;
|
42 |
+
vec3 yiq = rgb2yiq(color);
|
43 |
+
float cosA = cos(hueRad);
|
44 |
+
float sinA = sin(hueRad);
|
45 |
+
float i = yiq.y * cosA - yiq.z * sinA;
|
46 |
+
float q = yiq.y * sinA + yiq.z * cosA;
|
47 |
+
yiq.y = i;
|
48 |
+
yiq.z = q;
|
49 |
+
return yiq2rgb(yiq);
|
50 |
+
}
|
51 |
+
|
52 |
+
vec3 hash33(vec3 p3) {
|
53 |
+
p3 = fract(p3 * vec3(0.1031, 0.11369, 0.13787));
|
54 |
+
p3 += dot(p3, p3.yxz + 19.19);
|
55 |
+
return -1.0 + 2.0 * fract(vec3(
|
56 |
+
p3.x + p3.y,
|
57 |
+
p3.x + p3.z,
|
58 |
+
p3.y + p3.z
|
59 |
+
) * p3.zyx);
|
60 |
+
}
|
61 |
+
|
62 |
+
float snoise3(vec3 p) {
|
63 |
+
const float K1 = 0.333333333;
|
64 |
+
const float K2 = 0.166666667;
|
65 |
+
vec3 i = floor(p + (p.x + p.y + p.z) * K1);
|
66 |
+
vec3 d0 = p - (i - (i.x + i.y + i.z) * K2);
|
67 |
+
vec3 e = step(vec3(0.0), d0 - d0.yzx);
|
68 |
+
vec3 i1 = e * (1.0 - e.zxy);
|
69 |
+
vec3 i2 = 1.0 - e.zxy * (1.0 - e);
|
70 |
+
vec3 d1 = d0 - (i1 - K2);
|
71 |
+
vec3 d2 = d0 - (i2 - K1);
|
72 |
+
vec3 d3 = d0 - 0.5;
|
73 |
+
vec4 h = max(0.6 - vec4(
|
74 |
+
dot(d0, d0),
|
75 |
+
dot(d1, d1),
|
76 |
+
dot(d2, d2),
|
77 |
+
dot(d3, d3)
|
78 |
+
), 0.0);
|
79 |
+
vec4 n = h * h * h * h * vec4(
|
80 |
+
dot(d0, hash33(i)),
|
81 |
+
dot(d1, hash33(i + i1)),
|
82 |
+
dot(d2, hash33(i + i2)),
|
83 |
+
dot(d3, hash33(i + 1.0))
|
84 |
+
);
|
85 |
+
return dot(vec4(31.316), n);
|
86 |
+
}
|
87 |
+
|
88 |
+
vec4 extractAlpha(vec3 colorIn) {
|
89 |
+
float a = max(max(colorIn.r, colorIn.g), colorIn.b);
|
90 |
+
return vec4(colorIn.rgb / (a + 1e-5), a);
|
91 |
+
}
|
92 |
+
|
93 |
+
const vec3 baseColor1 = vec3(0.611765, 0.262745, 0.996078);
|
94 |
+
const vec3 baseColor2 = vec3(0.298039, 0.760784, 0.913725);
|
95 |
+
const vec3 baseColor3 = vec3(0.062745, 0.078431, 0.600000);
|
96 |
+
const float innerRadius = 0.6;
|
97 |
+
const float noiseScale = 0.65;
|
98 |
+
|
99 |
+
float light1(float intensity, float attenuation, float dist) {
|
100 |
+
return intensity / (1.0 + dist * attenuation);
|
101 |
+
}
|
102 |
+
float light2(float intensity, float attenuation, float dist) {
|
103 |
+
return intensity / (1.0 + dist * dist * attenuation);
|
104 |
+
}
|
105 |
+
|
106 |
+
vec4 draw(vec2 uv) {
|
107 |
+
vec3 color1 = adjustHue(baseColor1, hue);
|
108 |
+
vec3 color2 = adjustHue(baseColor2, hue);
|
109 |
+
vec3 color3 = adjustHue(baseColor3, hue);
|
110 |
+
|
111 |
+
float ang = atan(uv.y, uv.x);
|
112 |
+
float len = length(uv);
|
113 |
+
float invLen = len > 0.0 ? 1.0 / len : 0.0;
|
114 |
+
|
115 |
+
float n0 = snoise3(vec3(uv * noiseScale, iTime * 0.5)) * 0.5 + 0.5;
|
116 |
+
float r0 = mix(mix(innerRadius, 1.0, 0.4), mix(innerRadius, 1.0, 0.6), n0);
|
117 |
+
float d0 = distance(uv, (r0 * invLen) * uv);
|
118 |
+
float v0 = light1(1.0, 10.0, d0);
|
119 |
+
v0 *= smoothstep(r0 * 1.05, r0, len);
|
120 |
+
float cl = cos(ang + iTime * 2.0) * 0.5 + 0.5;
|
121 |
+
|
122 |
+
float a = iTime * -1.0;
|
123 |
+
vec2 pos = vec2(cos(a), sin(a)) * r0;
|
124 |
+
float d = distance(uv, pos);
|
125 |
+
float v1 = light2(1.5, 5.0, d);
|
126 |
+
v1 *= light1(1.0, 50.0, d0);
|
127 |
+
|
128 |
+
float v2 = smoothstep(1.0, mix(innerRadius, 1.0, n0 * 0.5), len);
|
129 |
+
float v3 = smoothstep(innerRadius, mix(innerRadius, 1.0, 0.5), len);
|
130 |
+
|
131 |
+
vec3 col = mix(color1, color2, cl);
|
132 |
+
col = mix(color3, col, v0);
|
133 |
+
col = (col + v1) * v2 * v3;
|
134 |
+
col = clamp(col, 0.0, 1.0);
|
135 |
+
|
136 |
+
return extractAlpha(col);
|
137 |
+
}
|
138 |
+
|
139 |
+
vec4 mainImage(vec2 fragCoord) {
|
140 |
+
vec2 center = iResolution.xy * 0.5;
|
141 |
+
float size = min(iResolution.x, iResolution.y);
|
142 |
+
vec2 uv = (fragCoord - center) / size * 2.0;
|
143 |
+
|
144 |
+
float angle = rot;
|
145 |
+
float s = sin(angle);
|
146 |
+
float c = cos(angle);
|
147 |
+
uv = vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y);
|
148 |
+
|
149 |
+
uv.x += hover * hoverIntensity * 0.1 * sin(uv.y * 10.0 + iTime);
|
150 |
+
uv.y += hover * hoverIntensity * 0.1 * sin(uv.x * 10.0 + iTime);
|
151 |
+
|
152 |
+
return draw(uv);
|
153 |
+
}
|
154 |
+
|
155 |
+
void main() {
|
156 |
+
vec2 fragCoord = vUv * iResolution.xy;
|
157 |
+
vec4 col = mainImage(fragCoord);
|
158 |
+
gl_FragColor = vec4(col.rgb * col.a, col.a);
|
159 |
+
}
|
160 |
+
`;
|
161 |
+
|
162 |
+
export default function Orb({
|
163 |
+
hue = 0,
|
164 |
+
hoverIntensity = 0.2,
|
165 |
+
rotateOnHover = true,
|
166 |
+
forceHoverState = false,
|
167 |
+
}) {
|
168 |
+
const ctnDom = useRef(null);
|
169 |
+
|
170 |
+
useEffect(() => {
|
171 |
+
const container = ctnDom.current;
|
172 |
+
if (!container) return;
|
173 |
+
|
174 |
+
const renderer = new Renderer({ alpha: true, premultipliedAlpha: false });
|
175 |
+
const gl = renderer.gl;
|
176 |
+
gl.clearColor(0, 0, 0, 0);
|
177 |
+
container.appendChild(gl.canvas);
|
178 |
+
|
179 |
+
const geometry = new Triangle(gl);
|
180 |
+
const program = new Program(gl, {
|
181 |
+
vertex: vert,
|
182 |
+
fragment: frag,
|
183 |
+
uniforms: {
|
184 |
+
iTime: { value: 0 },
|
185 |
+
iResolution: {
|
186 |
+
value: new Vec3(
|
187 |
+
gl.canvas.width,
|
188 |
+
gl.canvas.height,
|
189 |
+
gl.canvas.width / gl.canvas.height
|
190 |
+
),
|
191 |
+
},
|
192 |
+
hue: { value: hue },
|
193 |
+
hover: { value: 0 },
|
194 |
+
rot: { value: 0 },
|
195 |
+
hoverIntensity: { value: hoverIntensity },
|
196 |
+
},
|
197 |
+
});
|
198 |
+
|
199 |
+
const mesh = new Mesh(gl, { geometry, program });
|
200 |
+
|
201 |
+
function resize() {
|
202 |
+
if (!container) return;
|
203 |
+
const dpr = window.devicePixelRatio || 1;
|
204 |
+
const width = container.clientWidth;
|
205 |
+
const height = container.clientHeight;
|
206 |
+
renderer.setSize(width * dpr, height * dpr);
|
207 |
+
gl.canvas.style.width = width + "px";
|
208 |
+
gl.canvas.style.height = height + "px";
|
209 |
+
program.uniforms.iResolution.value.set(
|
210 |
+
gl.canvas.width,
|
211 |
+
gl.canvas.height,
|
212 |
+
gl.canvas.width / gl.canvas.height
|
213 |
+
);
|
214 |
+
}
|
215 |
+
window.addEventListener("resize", resize);
|
216 |
+
resize();
|
217 |
+
|
218 |
+
let targetHover = 0;
|
219 |
+
let lastTime = 0;
|
220 |
+
let currentRot = 0;
|
221 |
+
const rotationSpeed = 0.3;
|
222 |
+
|
223 |
+
const handleMouseMove = (e) => {
|
224 |
+
const rect = container.getBoundingClientRect();
|
225 |
+
const x = e.clientX - rect.left;
|
226 |
+
const y = e.clientY - rect.top;
|
227 |
+
const width = rect.width;
|
228 |
+
const height = rect.height;
|
229 |
+
const size = Math.min(width, height);
|
230 |
+
const centerX = width / 2;
|
231 |
+
const centerY = height / 2;
|
232 |
+
const uvX = ((x - centerX) / size) * 2.0;
|
233 |
+
const uvY = ((y - centerY) / size) * 2.0;
|
234 |
+
|
235 |
+
if (Math.sqrt(uvX * uvX + uvY * uvY) < 0.8) {
|
236 |
+
targetHover = 1;
|
237 |
+
} else {
|
238 |
+
targetHover = 0;
|
239 |
+
}
|
240 |
+
};
|
241 |
+
|
242 |
+
const handleMouseLeave = () => {
|
243 |
+
targetHover = 0;
|
244 |
+
};
|
245 |
+
|
246 |
+
container.addEventListener("mousemove", handleMouseMove);
|
247 |
+
container.addEventListener("mouseleave", handleMouseLeave);
|
248 |
+
|
249 |
+
let rafId;
|
250 |
+
const update = (t) => {
|
251 |
+
rafId = requestAnimationFrame(update);
|
252 |
+
const dt = (t - lastTime) * 0.001;
|
253 |
+
lastTime = t;
|
254 |
+
program.uniforms.iTime.value = t * 0.001;
|
255 |
+
program.uniforms.hue.value = hue;
|
256 |
+
program.uniforms.hoverIntensity.value = hoverIntensity;
|
257 |
+
|
258 |
+
const effectiveHover = forceHoverState ? 1 : targetHover;
|
259 |
+
program.uniforms.hover.value += (effectiveHover - program.uniforms.hover.value) * 0.1;
|
260 |
+
|
261 |
+
if (rotateOnHover && effectiveHover > 0.5) {
|
262 |
+
currentRot += dt * rotationSpeed;
|
263 |
+
}
|
264 |
+
program.uniforms.rot.value = currentRot;
|
265 |
+
|
266 |
+
renderer.render({ scene: mesh });
|
267 |
+
};
|
268 |
+
rafId = requestAnimationFrame(update);
|
269 |
+
|
270 |
+
return () => {
|
271 |
+
cancelAnimationFrame(rafId);
|
272 |
+
window.removeEventListener("resize", resize);
|
273 |
+
container.removeEventListener("mousemove", handleMouseMove);
|
274 |
+
container.removeEventListener("mouseleave", handleMouseLeave);
|
275 |
+
if (container.contains(gl.canvas)) {
|
276 |
+
container.removeChild(gl.canvas);
|
277 |
+
}
|
278 |
+
gl.getExtension("WEBGL_lose_context")?.loseContext();
|
279 |
+
};
|
280 |
+
}, [hue, hoverIntensity, rotateOnHover, forceHoverState]);
|
281 |
+
|
282 |
+
return <div ref={ctnDom} className="orb-container" />;
|
283 |
+
}
|
284 |
+
|
285 |
+
// CSS for orb container
|
286 |
+
const orbCSS = `
|
287 |
+
.orb-container {
|
288 |
+
position: relative;
|
289 |
+
z-index: 0;
|
290 |
+
width: 100%;
|
291 |
+
height: 100%;
|
292 |
+
}
|
293 |
+
`;
|
294 |
+
|
295 |
+
// Inject CSS
|
296 |
+
if (typeof document !== 'undefined') {
|
297 |
+
const style = document.createElement('style');
|
298 |
+
style.textContent = orbCSS;
|
299 |
+
document.head.appendChild(style);
|
300 |
+
}
|
src/components/PreviewPanel.js
ADDED
@@ -0,0 +1,543 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState, useEffect } from 'react';
|
2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
3 |
+
import { Eye, Zap, Brain, Settings, ExternalLink, FileText } from 'lucide-react';
|
4 |
+
import DocumentViewer from './DocumentViewer';
|
5 |
+
|
6 |
+
const PreviewPanel = ({
|
7 |
+
file,
|
8 |
+
processingMode,
|
9 |
+
onModeChange,
|
10 |
+
onProcess,
|
11 |
+
isProcessing,
|
12 |
+
apiKey
|
13 |
+
}) => {
|
14 |
+
const [previewUrl, setPreviewUrl] = useState(null);
|
15 |
+
const [fileInfo, setFileInfo] = useState(null);
|
16 |
+
const [showDocumentViewer, setShowDocumentViewer] = useState(false);
|
17 |
+
// OCR borders for future use
|
18 |
+
// const [showOcrBorders, setShowOcrBorders] = useState(false);
|
19 |
+
|
20 |
+
useEffect(() => {
|
21 |
+
if (file) {
|
22 |
+
const url = URL.createObjectURL(file);
|
23 |
+
setPreviewUrl(url);
|
24 |
+
|
25 |
+
setFileInfo({
|
26 |
+
name: file.name,
|
27 |
+
size: (file.size / 1024 / 1024).toFixed(2),
|
28 |
+
type: file.type,
|
29 |
+
lastModified: new Date(file.lastModified).toLocaleString()
|
30 |
+
});
|
31 |
+
|
32 |
+
return () => URL.revokeObjectURL(url);
|
33 |
+
}
|
34 |
+
}, [file]);
|
35 |
+
|
36 |
+
const processingModes = [
|
37 |
+
{
|
38 |
+
id: 'standard',
|
39 |
+
name: 'Standard Mode',
|
40 |
+
model: 'Gemini 2.5 Flash',
|
41 |
+
icon: <Zap size={20} />,
|
42 |
+
description: 'Fast processing',
|
43 |
+
features: ['Same features as Pro', 'Faster speed', 'Slightly less accuracy'],
|
44 |
+
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
45 |
+
},
|
46 |
+
{
|
47 |
+
id: 'structured',
|
48 |
+
name: 'Structured Mode',
|
49 |
+
model: 'Gemini 2.5 Pro',
|
50 |
+
icon: <Brain size={20} />,
|
51 |
+
description: 'Maximum accuracy',
|
52 |
+
features: ['Best accuracy', 'Advanced formatting', 'Slower processing'],
|
53 |
+
gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)'
|
54 |
+
}
|
55 |
+
];
|
56 |
+
|
57 |
+
return (
|
58 |
+
<>
|
59 |
+
{showDocumentViewer && (
|
60 |
+
<DocumentViewer
|
61 |
+
file={file}
|
62 |
+
onBack={() => setShowDocumentViewer(false)}
|
63 |
+
/>
|
64 |
+
)}
|
65 |
+
|
66 |
+
<div className="preview-panel">
|
67 |
+
<div className="preview-grid">
|
68 |
+
{/* File Preview */}
|
69 |
+
<motion.div
|
70 |
+
className="preview-section glass-panel"
|
71 |
+
initial={{ opacity: 0, x: -20 }}
|
72 |
+
animate={{ opacity: 1, x: 0 }}
|
73 |
+
transition={{ duration: 0.5 }}
|
74 |
+
>
|
75 |
+
<div className="section-header">
|
76 |
+
<Eye size={20} />
|
77 |
+
<h3>File Preview</h3>
|
78 |
+
</div>
|
79 |
+
|
80 |
+
<div className="preview-container">
|
81 |
+
{previewUrl && (
|
82 |
+
<motion.div
|
83 |
+
className="preview-image-container"
|
84 |
+
initial={{ scale: 0.9, opacity: 0 }}
|
85 |
+
animate={{ scale: 1, opacity: 1 }}
|
86 |
+
transition={{ duration: 0.3 }}
|
87 |
+
>
|
88 |
+
{file.type === 'application/pdf' ? (
|
89 |
+
<div className="pdf-preview glass-preview">
|
90 |
+
<div className="preview-content">
|
91 |
+
<div className="document-icon">
|
92 |
+
<FileText size={48} />
|
93 |
+
</div>
|
94 |
+
<p className="preview-title">PDF Document</p>
|
95 |
+
<span className="pdf-note">Ready for processing</span>
|
96 |
+
<motion.button
|
97 |
+
className="preview-button"
|
98 |
+
onClick={() => setShowDocumentViewer(true)}
|
99 |
+
whileHover={{ scale: 1.05 }}
|
100 |
+
whileTap={{ scale: 0.95 }}
|
101 |
+
>
|
102 |
+
<ExternalLink size={16} />
|
103 |
+
Open Viewer
|
104 |
+
</motion.button>
|
105 |
+
</div>
|
106 |
+
</div>
|
107 |
+
) : file.type === 'text/html' || file.name.toLowerCase().endsWith('.html') ? (
|
108 |
+
<div className="html-preview glass-preview">
|
109 |
+
<div className="preview-content">
|
110 |
+
<div className="document-icon">
|
111 |
+
<FileText size={48} />
|
112 |
+
</div>
|
113 |
+
<p className="preview-title">HTML Document</p>
|
114 |
+
<span className="html-note">Ready for processing</span>
|
115 |
+
<motion.button
|
116 |
+
className="preview-button"
|
117 |
+
onClick={() => setShowDocumentViewer(true)}
|
118 |
+
whileHover={{ scale: 1.05 }}
|
119 |
+
whileTap={{ scale: 0.95 }}
|
120 |
+
>
|
121 |
+
<ExternalLink size={16} />
|
122 |
+
Open Viewer
|
123 |
+
</motion.button>
|
124 |
+
</div>
|
125 |
+
</div>
|
126 |
+
) : (
|
127 |
+
<img
|
128 |
+
src={previewUrl}
|
129 |
+
alt="Preview"
|
130 |
+
className="preview-image"
|
131 |
+
/>
|
132 |
+
)}
|
133 |
+
</motion.div>
|
134 |
+
)}
|
135 |
+
|
136 |
+
{fileInfo && (
|
137 |
+
<div className="file-info nested-panel">
|
138 |
+
<div className="info-grid">
|
139 |
+
<div className="info-item">
|
140 |
+
<span className="info-label">Name:</span>
|
141 |
+
<span className="info-value">{fileInfo.name}</span>
|
142 |
+
</div>
|
143 |
+
<div className="info-item">
|
144 |
+
<span className="info-label">Size:</span>
|
145 |
+
<span className="info-value">{fileInfo.size} MB</span>
|
146 |
+
</div>
|
147 |
+
<div className="info-item">
|
148 |
+
<span className="info-label">Type:</span>
|
149 |
+
<span className="info-value">{fileInfo.type}</span>
|
150 |
+
</div>
|
151 |
+
<div className="info-item">
|
152 |
+
<span className="info-label">Modified:</span>
|
153 |
+
<span className="info-value">{fileInfo.lastModified}</span>
|
154 |
+
</div>
|
155 |
+
</div>
|
156 |
+
</div>
|
157 |
+
)}
|
158 |
+
</div>
|
159 |
+
</motion.div>
|
160 |
+
|
161 |
+
{/* Processing Options */}
|
162 |
+
<motion.div
|
163 |
+
className="options-section glass-panel"
|
164 |
+
initial={{ opacity: 0, x: 20 }}
|
165 |
+
animate={{ opacity: 1, x: 0 }}
|
166 |
+
transition={{ duration: 0.5, delay: 0.1 }}
|
167 |
+
>
|
168 |
+
<div className="section-header">
|
169 |
+
<Settings size={20} />
|
170 |
+
<h3>Processing Options</h3>
|
171 |
+
</div>
|
172 |
+
|
173 |
+
<div className="processing-modes">
|
174 |
+
{processingModes.map((mode, index) => (
|
175 |
+
<motion.div
|
176 |
+
key={mode.id}
|
177 |
+
className={`mode-card nested-panel ${processingMode === mode.id ? 'active' : ''}`}
|
178 |
+
onClick={() => onModeChange(mode.id)}
|
179 |
+
whileHover={{ scale: 1.02, y: -2 }}
|
180 |
+
whileTap={{ scale: 0.98 }}
|
181 |
+
initial={{ opacity: 0, y: 20 }}
|
182 |
+
animate={{ opacity: 1, y: 0 }}
|
183 |
+
transition={{ delay: index * 0.1 }}
|
184 |
+
>
|
185 |
+
<div className="mode-header">
|
186 |
+
<div className="mode-icon" style={{ background: mode.gradient }}>
|
187 |
+
{mode.icon}
|
188 |
+
</div>
|
189 |
+
<div className="mode-info">
|
190 |
+
<h4>{mode.name}</h4>
|
191 |
+
<span className="mode-model">{mode.model}</span>
|
192 |
+
</div>
|
193 |
+
</div>
|
194 |
+
|
195 |
+
<p className="mode-description">{mode.description}</p>
|
196 |
+
|
197 |
+
<div className="mode-features">
|
198 |
+
{mode.features.map((feature, idx) => (
|
199 |
+
<span key={idx} className="feature-tag">
|
200 |
+
{feature}
|
201 |
+
</span>
|
202 |
+
))}
|
203 |
+
</div>
|
204 |
+
|
205 |
+
{processingMode === mode.id && (
|
206 |
+
<motion.div
|
207 |
+
className="mode-indicator"
|
208 |
+
layoutId="modeIndicator"
|
209 |
+
style={{
|
210 |
+
position: 'absolute',
|
211 |
+
top: 0,
|
212 |
+
left: 0,
|
213 |
+
right: 0,
|
214 |
+
bottom: 0,
|
215 |
+
background: 'rgba(99, 102, 241, 0.1)',
|
216 |
+
borderRadius: '12px',
|
217 |
+
zIndex: -1,
|
218 |
+
border: '1px solid rgba(99, 102, 241, 0.3)'
|
219 |
+
}}
|
220 |
+
transition={{
|
221 |
+
type: "spring",
|
222 |
+
stiffness: 500,
|
223 |
+
damping: 30
|
224 |
+
}}
|
225 |
+
/>
|
226 |
+
)}
|
227 |
+
</motion.div>
|
228 |
+
))}
|
229 |
+
</div>
|
230 |
+
|
231 |
+
<motion.button
|
232 |
+
className="process-button flowing-button"
|
233 |
+
onClick={onProcess}
|
234 |
+
disabled={!apiKey || isProcessing}
|
235 |
+
whileHover={{ scale: 1.05 }}
|
236 |
+
whileTap={{ scale: 0.95 }}
|
237 |
+
style={{
|
238 |
+
width: '100%',
|
239 |
+
marginTop: '24px',
|
240 |
+
background: isProcessing
|
241 |
+
? 'linear-gradient(135deg, #6b7280 0%, #4b5563 100%)'
|
242 |
+
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
243 |
+
}}
|
244 |
+
>
|
245 |
+
<AnimatePresence mode="wait">
|
246 |
+
{isProcessing ? (
|
247 |
+
<motion.div
|
248 |
+
key="processing"
|
249 |
+
initial={{ opacity: 0 }}
|
250 |
+
animate={{ opacity: 1 }}
|
251 |
+
exit={{ opacity: 0 }}
|
252 |
+
className="button-content"
|
253 |
+
>
|
254 |
+
<motion.div
|
255 |
+
className="processing-spinner"
|
256 |
+
animate={{ rotate: 360 }}
|
257 |
+
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
258 |
+
>
|
259 |
+
⚡
|
260 |
+
</motion.div>
|
261 |
+
Processing...
|
262 |
+
</motion.div>
|
263 |
+
) : (
|
264 |
+
<motion.div
|
265 |
+
key="ready"
|
266 |
+
initial={{ opacity: 0 }}
|
267 |
+
animate={{ opacity: 1 }}
|
268 |
+
exit={{ opacity: 0 }}
|
269 |
+
className="button-content"
|
270 |
+
>
|
271 |
+
🚀 Extract Text
|
272 |
+
</motion.div>
|
273 |
+
)}
|
274 |
+
</AnimatePresence>
|
275 |
+
</motion.button>
|
276 |
+
|
277 |
+
{!apiKey && (
|
278 |
+
<motion.p
|
279 |
+
className="api-warning"
|
280 |
+
initial={{ opacity: 0 }}
|
281 |
+
animate={{ opacity: 1 }}
|
282 |
+
style={{
|
283 |
+
color: 'rgba(251, 86, 7, 0.8)',
|
284 |
+
fontSize: '0.75rem',
|
285 |
+
textAlign: 'center',
|
286 |
+
marginTop: '8px'
|
287 |
+
}}
|
288 |
+
>
|
289 |
+
Please enter your Google API Key to continue
|
290 |
+
</motion.p>
|
291 |
+
)}
|
292 |
+
</motion.div>
|
293 |
+
</div>
|
294 |
+
|
295 |
+
<style jsx>{`
|
296 |
+
.preview-panel {
|
297 |
+
width: 100%;
|
298 |
+
min-height: auto;
|
299 |
+
padding-bottom: 40px;
|
300 |
+
}
|
301 |
+
|
302 |
+
.preview-grid {
|
303 |
+
display: grid;
|
304 |
+
grid-template-columns: 1fr 1fr;
|
305 |
+
gap: 24px;
|
306 |
+
height: 100%;
|
307 |
+
}
|
308 |
+
|
309 |
+
.section-header {
|
310 |
+
display: flex;
|
311 |
+
align-items: center;
|
312 |
+
gap: 12px;
|
313 |
+
margin-bottom: 24px;
|
314 |
+
color: var(--text-primary);
|
315 |
+
}
|
316 |
+
|
317 |
+
.section-header h3 {
|
318 |
+
font-size: 1.25rem;
|
319 |
+
font-weight: 600;
|
320 |
+
}
|
321 |
+
|
322 |
+
.preview-container {
|
323 |
+
display: flex;
|
324 |
+
flex-direction: column;
|
325 |
+
gap: 16px;
|
326 |
+
}
|
327 |
+
|
328 |
+
.preview-image-container {
|
329 |
+
border-radius: 12px;
|
330 |
+
overflow: hidden;
|
331 |
+
background: rgba(100, 100, 100, 0.1);
|
332 |
+
backdrop-filter: blur(25px);
|
333 |
+
-webkit-backdrop-filter: blur(25px);
|
334 |
+
}
|
335 |
+
|
336 |
+
.preview-image {
|
337 |
+
width: 100%;
|
338 |
+
height: 300px;
|
339 |
+
object-fit: contain;
|
340 |
+
background: rgba(100, 100, 100, 0.1);
|
341 |
+
}
|
342 |
+
|
343 |
+
.pdf-preview {
|
344 |
+
height: 300px;
|
345 |
+
display: flex;
|
346 |
+
flex-direction: column;
|
347 |
+
align-items: center;
|
348 |
+
justify-content: center;
|
349 |
+
gap: 12px;
|
350 |
+
background: rgba(100, 100, 100, 0.1);
|
351 |
+
}
|
352 |
+
|
353 |
+
.pdf-icon {
|
354 |
+
font-size: 4rem;
|
355 |
+
filter: drop-shadow(0 0 20px rgba(99, 102, 241, 0.3));
|
356 |
+
}
|
357 |
+
|
358 |
+
.pdf-note {
|
359 |
+
font-size: 0.75rem;
|
360 |
+
color: var(--text-muted);
|
361 |
+
}
|
362 |
+
|
363 |
+
.file-info {
|
364 |
+
padding: 16px;
|
365 |
+
}
|
366 |
+
|
367 |
+
.info-grid {
|
368 |
+
display: grid;
|
369 |
+
grid-template-columns: 1fr 1fr;
|
370 |
+
gap: 12px;
|
371 |
+
}
|
372 |
+
|
373 |
+
.info-item {
|
374 |
+
display: flex;
|
375 |
+
flex-direction: column;
|
376 |
+
gap: 4px;
|
377 |
+
}
|
378 |
+
|
379 |
+
.info-label {
|
380 |
+
font-size: 0.75rem;
|
381 |
+
color: var(--text-muted);
|
382 |
+
font-weight: 500;
|
383 |
+
}
|
384 |
+
|
385 |
+
.info-value {
|
386 |
+
font-size: 0.875rem;
|
387 |
+
color: var(--text-secondary);
|
388 |
+
word-break: break-all;
|
389 |
+
}
|
390 |
+
|
391 |
+
.processing-modes {
|
392 |
+
display: flex;
|
393 |
+
flex-direction: column;
|
394 |
+
gap: 16px;
|
395 |
+
margin-bottom: 24px;
|
396 |
+
}
|
397 |
+
|
398 |
+
.mode-card {
|
399 |
+
cursor: pointer;
|
400 |
+
transition: all 0.3s ease;
|
401 |
+
position: relative;
|
402 |
+
overflow: hidden;
|
403 |
+
}
|
404 |
+
|
405 |
+
.mode-card.active {
|
406 |
+
border-color: rgba(99, 102, 241, 0.3);
|
407 |
+
}
|
408 |
+
|
409 |
+
.mode-header {
|
410 |
+
display: flex;
|
411 |
+
align-items: center;
|
412 |
+
gap: 12px;
|
413 |
+
margin-bottom: 12px;
|
414 |
+
}
|
415 |
+
|
416 |
+
.mode-icon {
|
417 |
+
width: 40px;
|
418 |
+
height: 40px;
|
419 |
+
border-radius: 10px;
|
420 |
+
display: flex;
|
421 |
+
align-items: center;
|
422 |
+
justify-content: center;
|
423 |
+
color: white;
|
424 |
+
}
|
425 |
+
|
426 |
+
.mode-info h4 {
|
427 |
+
font-size: 1rem;
|
428 |
+
font-weight: 600;
|
429 |
+
color: var(--text-primary);
|
430 |
+
margin-bottom: 2px;
|
431 |
+
}
|
432 |
+
|
433 |
+
.mode-model {
|
434 |
+
font-size: 0.75rem;
|
435 |
+
color: var(--text-muted);
|
436 |
+
}
|
437 |
+
|
438 |
+
.mode-description {
|
439 |
+
font-size: 0.875rem;
|
440 |
+
color: var(--text-secondary);
|
441 |
+
margin-bottom: 12px;
|
442 |
+
}
|
443 |
+
|
444 |
+
.mode-features {
|
445 |
+
display: flex;
|
446 |
+
flex-wrap: wrap;
|
447 |
+
gap: 6px;
|
448 |
+
}
|
449 |
+
|
450 |
+
.feature-tag {
|
451 |
+
background: rgba(100, 100, 100, 0.1);
|
452 |
+
color: rgba(255, 255, 255, 0.8);
|
453 |
+
padding: 4px 8px;
|
454 |
+
border-radius: 6px;
|
455 |
+
font-size: 0.75rem;
|
456 |
+
font-weight: 500;
|
457 |
+
backdrop-filter: blur(10px);
|
458 |
+
}
|
459 |
+
|
460 |
+
.button-content {
|
461 |
+
display: flex;
|
462 |
+
align-items: center;
|
463 |
+
justify-content: center;
|
464 |
+
gap: 8px;
|
465 |
+
}
|
466 |
+
|
467 |
+
.processing-spinner {
|
468 |
+
font-size: 1.2rem;
|
469 |
+
}
|
470 |
+
|
471 |
+
@media (max-width: 768px) {
|
472 |
+
.preview-grid {
|
473 |
+
grid-template-columns: 1fr;
|
474 |
+
}
|
475 |
+
|
476 |
+
.info-grid {
|
477 |
+
grid-template-columns: 1fr;
|
478 |
+
}
|
479 |
+
}
|
480 |
+
|
481 |
+
.glass-preview {
|
482 |
+
background: rgba(0, 0, 0, 0.2);
|
483 |
+
backdrop-filter: blur(25px);
|
484 |
+
-webkit-backdrop-filter: blur(25px);
|
485 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
486 |
+
border-radius: 16px;
|
487 |
+
overflow: hidden;
|
488 |
+
}
|
489 |
+
|
490 |
+
.preview-content {
|
491 |
+
display: flex;
|
492 |
+
flex-direction: column;
|
493 |
+
align-items: center;
|
494 |
+
justify-content: center;
|
495 |
+
gap: 12px;
|
496 |
+
padding: 32px;
|
497 |
+
}
|
498 |
+
|
499 |
+
.document-icon {
|
500 |
+
color: rgba(79, 172, 254, 0.8);
|
501 |
+
filter: drop-shadow(0 0 20px rgba(79, 172, 254, 0.4));
|
502 |
+
}
|
503 |
+
|
504 |
+
.preview-title {
|
505 |
+
font-size: 1.25rem;
|
506 |
+
font-weight: 600;
|
507 |
+
color: var(--text-primary);
|
508 |
+
margin: 0;
|
509 |
+
}
|
510 |
+
|
511 |
+
.pdf-note, .html-note {
|
512 |
+
font-size: 0.875rem;
|
513 |
+
color: var(--text-muted);
|
514 |
+
margin: 0;
|
515 |
+
}
|
516 |
+
|
517 |
+
.preview-button {
|
518 |
+
display: flex;
|
519 |
+
align-items: center;
|
520 |
+
gap: 8px;
|
521 |
+
padding: 12px 20px;
|
522 |
+
background: linear-gradient(135deg, #4facfe, #8b5cf6);
|
523 |
+
border: none;
|
524 |
+
border-radius: 8px;
|
525 |
+
color: white;
|
526 |
+
font-size: 0.875rem;
|
527 |
+
font-weight: 500;
|
528 |
+
cursor: pointer;
|
529 |
+
transition: all 0.3s ease;
|
530 |
+
margin-top: 8px;
|
531 |
+
}
|
532 |
+
|
533 |
+
.preview-button:hover {
|
534 |
+
box-shadow: 0 4px 15px rgba(79, 172, 254, 0.3);
|
535 |
+
transform: translateY(-1px);
|
536 |
+
}
|
537 |
+
`}</style>
|
538 |
+
</div>
|
539 |
+
</>
|
540 |
+
);
|
541 |
+
};
|
542 |
+
|
543 |
+
export default PreviewPanel;
|
src/components/ProcessingProgress.js
ADDED
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react';
|
2 |
+
import { motion } from 'framer-motion';
|
3 |
+
|
4 |
+
const ProcessingProgress = ({ progress, isVisible }) => {
|
5 |
+
if (!isVisible) return null;
|
6 |
+
|
7 |
+
const {
|
8 |
+
current,
|
9 |
+
total,
|
10 |
+
status,
|
11 |
+
fileName,
|
12 |
+
totalPages,
|
13 |
+
currentPage,
|
14 |
+
totalCharacters,
|
15 |
+
pageCharacters,
|
16 |
+
phase,
|
17 |
+
consoleLogs = []
|
18 |
+
} = progress;
|
19 |
+
const percentage = total > 0 ? Math.round((current / total) * 100) : 0;
|
20 |
+
|
21 |
+
return (
|
22 |
+
<motion.div
|
23 |
+
className="processing-progress-overlay"
|
24 |
+
initial={{ opacity: 0 }}
|
25 |
+
animate={{ opacity: 1 }}
|
26 |
+
exit={{ opacity: 0 }}
|
27 |
+
>
|
28 |
+
<div className="processing-progress-container">
|
29 |
+
<div className="progress-content">
|
30 |
+
<div className="progress-header">
|
31 |
+
<h3>🌙 Luna Processing</h3>
|
32 |
+
<div className="progress-file">
|
33 |
+
📄 {fileName || 'Processing file...'}
|
34 |
+
</div>
|
35 |
+
</div>
|
36 |
+
|
37 |
+
<div className="progress-bar-container">
|
38 |
+
<div className="progress-bar">
|
39 |
+
<motion.div
|
40 |
+
className="progress-fill"
|
41 |
+
initial={{ width: 0 }}
|
42 |
+
animate={{ width: `${percentage}%` }}
|
43 |
+
transition={{ duration: 0.5, ease: "easeOut" }}
|
44 |
+
/>
|
45 |
+
</div>
|
46 |
+
<div className="progress-percentage">{percentage}%</div>
|
47 |
+
</div>
|
48 |
+
|
49 |
+
<div className="progress-status">
|
50 |
+
{status && (
|
51 |
+
<motion.div
|
52 |
+
key={status}
|
53 |
+
initial={{ opacity: 0, y: 10 }}
|
54 |
+
animate={{ opacity: 1, y: 0 }}
|
55 |
+
className="status-text"
|
56 |
+
>
|
57 |
+
{status}
|
58 |
+
</motion.div>
|
59 |
+
)}
|
60 |
+
</div>
|
61 |
+
|
62 |
+
{totalPages > 1 && (
|
63 |
+
<div className="progress-details">
|
64 |
+
<div className="progress-counter">
|
65 |
+
<span className="counter-current">{currentPage || 0}</span>
|
66 |
+
<span className="counter-separator">/</span>
|
67 |
+
<span className="counter-total">{totalPages}</span>
|
68 |
+
<span className="counter-label">pages</span>
|
69 |
+
</div>
|
70 |
+
|
71 |
+
{totalCharacters > 0 && (
|
72 |
+
<div className="progress-stats">
|
73 |
+
<div className="stat-item">
|
74 |
+
<span className="stat-label">Total Characters:</span>
|
75 |
+
<span className="stat-value">{totalCharacters.toLocaleString()}</span>
|
76 |
+
</div>
|
77 |
+
{pageCharacters > 0 && (
|
78 |
+
<div className="stat-item">
|
79 |
+
<span className="stat-label">Last Page:</span>
|
80 |
+
<span className="stat-value">{pageCharacters.toLocaleString()} chars</span>
|
81 |
+
</div>
|
82 |
+
)}
|
83 |
+
</div>
|
84 |
+
)}
|
85 |
+
</div>
|
86 |
+
)}
|
87 |
+
|
88 |
+
{consoleLogs.length > 0 && (
|
89 |
+
<div className="console-logs">
|
90 |
+
<div className="console-header">📋 Live Process Log</div>
|
91 |
+
<div className="console-content">
|
92 |
+
{consoleLogs.slice(-6).map((log, index) => (
|
93 |
+
<div key={index} className="console-line">
|
94 |
+
<span className="console-time">
|
95 |
+
{new Date(log.timestamp).toLocaleTimeString('th-TH', {
|
96 |
+
hour12: false,
|
97 |
+
hour: '2-digit',
|
98 |
+
minute: '2-digit',
|
99 |
+
second: '2-digit'
|
100 |
+
})}
|
101 |
+
</span>
|
102 |
+
<span className="console-message">{log.message}</span>
|
103 |
+
</div>
|
104 |
+
))}
|
105 |
+
</div>
|
106 |
+
</div>
|
107 |
+
)}
|
108 |
+
|
109 |
+
<div className="processing-animation">
|
110 |
+
<div className="processing-dots">
|
111 |
+
<div className="dot"></div>
|
112 |
+
<div className="dot"></div>
|
113 |
+
<div className="dot"></div>
|
114 |
+
</div>
|
115 |
+
</div>
|
116 |
+
</div>
|
117 |
+
</div>
|
118 |
+
</motion.div>
|
119 |
+
);
|
120 |
+
};
|
121 |
+
|
122 |
+
export default ProcessingProgress;
|
src/components/ResultsPanel.js
ADDED
@@ -0,0 +1,1340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState } from 'react';
|
2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
3 |
+
import { FileText, Code, Eye, Download, Copy, Check } from 'lucide-react';
|
4 |
+
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
5 |
+
import { atomDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
6 |
+
import { marked } from 'marked';
|
7 |
+
import jsPDF from 'jspdf';
|
8 |
+
|
9 |
+
const ResultsPanel = ({ text, previewMode, onPreviewModeChange, fileName }) => {
|
10 |
+
const [copied, setCopied] = useState(false);
|
11 |
+
const [downloadFormat, setDownloadFormat] = useState('txt');
|
12 |
+
|
13 |
+
const previewModes = [
|
14 |
+
{ id: 'text', label: 'Text', icon: <FileText size={16} /> },
|
15 |
+
{ id: 'markdown', label: 'Markdown', icon: <Code size={16} /> },
|
16 |
+
{ id: 'preview', label: 'HTML', icon: <Eye size={16} /> },
|
17 |
+
{ id: 'json', label: 'JSON', icon: <Code size={16} /> }
|
18 |
+
];
|
19 |
+
|
20 |
+
const downloadFormats = [
|
21 |
+
{ id: 'txt', label: '.txt', icon: '📄' },
|
22 |
+
{ id: 'md', label: '.md', icon: '📝' },
|
23 |
+
{ id: 'html', label: '.html', icon: '🌐' },
|
24 |
+
{ id: 'json', label: '.json', icon: '🔧' },
|
25 |
+
{ id: 'pdf', label: '.pdf', icon: '📋' }
|
26 |
+
];
|
27 |
+
|
28 |
+
const handleCopy = async () => {
|
29 |
+
try {
|
30 |
+
await navigator.clipboard.writeText(text);
|
31 |
+
setCopied(true);
|
32 |
+
setTimeout(() => setCopied(false), 2000);
|
33 |
+
} catch (err) {
|
34 |
+
console.error('Failed to copy text:', err);
|
35 |
+
}
|
36 |
+
};
|
37 |
+
|
38 |
+
|
39 |
+
|
40 |
+
const handleDownload = (format = downloadFormat) => {
|
41 |
+
const baseFileName = fileName ? fileName.split('.')[0] : 'extracted-text';
|
42 |
+
|
43 |
+
switch (format) {
|
44 |
+
case 'txt':
|
45 |
+
downloadFile(text, `${baseFileName}.txt`, 'text/plain');
|
46 |
+
break;
|
47 |
+
case 'md':
|
48 |
+
downloadFile(text, `${baseFileName}.md`, 'text/markdown');
|
49 |
+
break;
|
50 |
+
case 'html':
|
51 |
+
const htmlContent = `
|
52 |
+
<!DOCTYPE html>
|
53 |
+
<html lang="en">
|
54 |
+
<head>
|
55 |
+
<meta charset="UTF-8">
|
56 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
57 |
+
<title>${baseFileName} - Luna OCR</title>
|
58 |
+
<style>
|
59 |
+
* {
|
60 |
+
margin: 0;
|
61 |
+
padding: 0;
|
62 |
+
box-sizing: border-box;
|
63 |
+
}
|
64 |
+
|
65 |
+
body {
|
66 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', sans-serif;
|
67 |
+
background: #0a0a0f;
|
68 |
+
color: #ffffff;
|
69 |
+
min-height: 100vh;
|
70 |
+
overflow-x: hidden;
|
71 |
+
position: relative;
|
72 |
+
}
|
73 |
+
|
74 |
+
/* Modern Animated Background */
|
75 |
+
.animated-background {
|
76 |
+
position: fixed;
|
77 |
+
top: 0;
|
78 |
+
left: 0;
|
79 |
+
width: 100%;
|
80 |
+
height: 100%;
|
81 |
+
pointer-events: none;
|
82 |
+
z-index: -1;
|
83 |
+
overflow: hidden;
|
84 |
+
}
|
85 |
+
|
86 |
+
.gradient-orb {
|
87 |
+
position: absolute;
|
88 |
+
border-radius: 50%;
|
89 |
+
filter: blur(60px);
|
90 |
+
opacity: 0.4;
|
91 |
+
}
|
92 |
+
|
93 |
+
.orb-1 {
|
94 |
+
top: 20%;
|
95 |
+
left: 10%;
|
96 |
+
width: 300px;
|
97 |
+
height: 300px;
|
98 |
+
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
99 |
+
animation: float1 25s ease-in-out infinite;
|
100 |
+
}
|
101 |
+
|
102 |
+
.orb-2 {
|
103 |
+
top: 60%;
|
104 |
+
right: 15%;
|
105 |
+
width: 250px;
|
106 |
+
height: 250px;
|
107 |
+
background: linear-gradient(135deg, #ec4899 0%, #be185d 100%);
|
108 |
+
animation: float2 30s ease-in-out infinite;
|
109 |
+
}
|
110 |
+
|
111 |
+
.orb-3 {
|
112 |
+
bottom: 30%;
|
113 |
+
left: 20%;
|
114 |
+
width: 200px;
|
115 |
+
height: 200px;
|
116 |
+
background: linear-gradient(135deg, #6366f1 0%, #4338ca 100%);
|
117 |
+
animation: float3 35s ease-in-out infinite;
|
118 |
+
}
|
119 |
+
|
120 |
+
.orb-4 {
|
121 |
+
top: 10%;
|
122 |
+
right: 40%;
|
123 |
+
width: 180px;
|
124 |
+
height: 180px;
|
125 |
+
background: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%);
|
126 |
+
animation: float4 20s ease-in-out infinite;
|
127 |
+
}
|
128 |
+
|
129 |
+
.orb-5 {
|
130 |
+
bottom: 10%;
|
131 |
+
right: 30%;
|
132 |
+
width: 220px;
|
133 |
+
height: 220px;
|
134 |
+
background: linear-gradient(135deg, #f472b6 0%, #ec4899 100%);
|
135 |
+
animation: float5 28s ease-in-out infinite;
|
136 |
+
}
|
137 |
+
|
138 |
+
@keyframes float1 {
|
139 |
+
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
140 |
+
25% { transform: translate(-30px, -20px) rotate(90deg); }
|
141 |
+
50% { transform: translate(20px, -30px) rotate(180deg); }
|
142 |
+
75% { transform: translate(-20px, 20px) rotate(270deg); }
|
143 |
+
}
|
144 |
+
|
145 |
+
@keyframes float2 {
|
146 |
+
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
147 |
+
33% { transform: translate(25px, -35px) rotate(120deg); }
|
148 |
+
66% { transform: translate(-35px, -25px) rotate(240deg); }
|
149 |
+
}
|
150 |
+
|
151 |
+
@keyframes float3 {
|
152 |
+
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
153 |
+
20% { transform: translate(-25px, 30px) rotate(72deg); }
|
154 |
+
40% { transform: translate(30px, 25px) rotate(144deg); }
|
155 |
+
60% { transform: translate(25px, -30px) rotate(216deg); }
|
156 |
+
80% { transform: translate(-30px, -25px) rotate(288deg); }
|
157 |
+
}
|
158 |
+
|
159 |
+
@keyframes float4 {
|
160 |
+
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
161 |
+
50% { transform: translate(-20px, 25px) rotate(180deg); }
|
162 |
+
}
|
163 |
+
|
164 |
+
@keyframes float5 {
|
165 |
+
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
166 |
+
25% { transform: translate(20px, -15px) rotate(90deg); }
|
167 |
+
50% { transform: translate(-15px, 20px) rotate(180deg); }
|
168 |
+
75% { transform: translate(-20px, -20px) rotate(270deg); }
|
169 |
+
}
|
170 |
+
left: 50%;
|
171 |
+
width: 60vmin;
|
172 |
+
height: 60vmin;
|
173 |
+
transform: translate(-50%, -50%);
|
174 |
+
opacity: 0.7;
|
175 |
+
}
|
176 |
+
|
177 |
+
.orb-layer {
|
178 |
+
position: absolute;
|
179 |
+
top: 0;
|
180 |
+
left: 0;
|
181 |
+
width: 100%;
|
182 |
+
height: 100%;
|
183 |
+
border-radius: 50%;
|
184 |
+
filter: blur(40px);
|
185 |
+
}
|
186 |
+
|
187 |
+
.orb-layer-1 {
|
188 |
+
background: radial-gradient(circle at 30% 30%,
|
189 |
+
rgba(156, 67, 254, 0.8) 0%,
|
190 |
+
rgba(79, 172, 254, 0.6) 40%,
|
191 |
+
rgba(16, 21, 96, 0.4) 70%,
|
192 |
+
transparent 100%);
|
193 |
+
animation: orbRotate1 20s linear infinite, orbPulse1 8s ease-in-out infinite;
|
194 |
+
}
|
195 |
+
|
196 |
+
.orb-layer-2 {
|
197 |
+
background: radial-gradient(circle at 70% 60%,
|
198 |
+
rgba(76, 194, 233, 0.7) 0%,
|
199 |
+
rgba(139, 92, 246, 0.5) 35%,
|
200 |
+
rgba(79, 172, 254, 0.3) 65%,
|
201 |
+
transparent 100%);
|
202 |
+
animation: orbRotate2 25s linear infinite reverse, orbPulse2 6s ease-in-out infinite;
|
203 |
+
}
|
204 |
+
|
205 |
+
.orb-layer-3 {
|
206 |
+
background: radial-gradient(circle at 50% 80%,
|
207 |
+
rgba(16, 185, 129, 0.6) 0%,
|
208 |
+
rgba(6, 182, 212, 0.4) 30%,
|
209 |
+
rgba(139, 92, 246, 0.2) 60%,
|
210 |
+
transparent 100%);
|
211 |
+
animation: orbRotate3 30s linear infinite, orbPulse3 10s ease-in-out infinite;
|
212 |
+
}
|
213 |
+
|
214 |
+
.orb-core {
|
215 |
+
position: absolute;
|
216 |
+
top: 50%;
|
217 |
+
left: 50%;
|
218 |
+
width: 40%;
|
219 |
+
height: 40%;
|
220 |
+
transform: translate(-50%, -50%);
|
221 |
+
background: radial-gradient(circle,
|
222 |
+
rgba(156, 67, 254, 0.9) 0%,
|
223 |
+
rgba(79, 172, 254, 0.7) 30%,
|
224 |
+
rgba(16, 21, 96, 0.5) 60%,
|
225 |
+
transparent 100%);
|
226 |
+
border-radius: 50%;
|
227 |
+
filter: blur(20px);
|
228 |
+
animation: orbCore 15s ease-in-out infinite;
|
229 |
+
}
|
230 |
+
|
231 |
+
@keyframes orbRotate1 {
|
232 |
+
0% { transform: rotate(0deg) scale(1); }
|
233 |
+
25% { transform: rotate(90deg) scale(1.1); }
|
234 |
+
50% { transform: rotate(180deg) scale(1); }
|
235 |
+
75% { transform: rotate(270deg) scale(0.9); }
|
236 |
+
100% { transform: rotate(360deg) scale(1); }
|
237 |
+
}
|
238 |
+
|
239 |
+
@keyframes orbRotate2 {
|
240 |
+
0% { transform: rotate(0deg) scale(0.9); }
|
241 |
+
33% { transform: rotate(120deg) scale(1.2); }
|
242 |
+
66% { transform: rotate(240deg) scale(0.8); }
|
243 |
+
100% { transform: rotate(360deg) scale(0.9); }
|
244 |
+
}
|
245 |
+
|
246 |
+
@keyframes orbRotate3 {
|
247 |
+
0% { transform: rotate(0deg) scale(1.1); }
|
248 |
+
20% { transform: rotate(72deg) scale(0.9); }
|
249 |
+
40% { transform: rotate(144deg) scale(1.3); }
|
250 |
+
60% { transform: rotate(216deg) scale(0.7); }
|
251 |
+
80% { transform: rotate(288deg) scale(1.1); }
|
252 |
+
100% { transform: rotate(360deg) scale(1.1); }
|
253 |
+
}
|
254 |
+
|
255 |
+
@keyframes orbPulse1 {
|
256 |
+
0%, 100% { opacity: 0.8; filter: blur(40px) hue-rotate(0deg); }
|
257 |
+
50% { opacity: 0.6; filter: blur(60px) hue-rotate(30deg); }
|
258 |
+
}
|
259 |
+
|
260 |
+
@keyframes orbPulse2 {
|
261 |
+
0%, 100% { opacity: 0.7; filter: blur(40px) hue-rotate(0deg); }
|
262 |
+
50% { opacity: 0.9; filter: blur(30px) hue-rotate(-20deg); }
|
263 |
+
}
|
264 |
+
|
265 |
+
@keyframes orbPulse3 {
|
266 |
+
0%, 100% { opacity: 0.6; filter: blur(40px) hue-rotate(0deg); }
|
267 |
+
50% { opacity: 0.4; filter: blur(50px) hue-rotate(15deg); }
|
268 |
+
}
|
269 |
+
|
270 |
+
@keyframes orbCore {
|
271 |
+
0%, 100% {
|
272 |
+
transform: translate(-50%, -50%) scale(1);
|
273 |
+
filter: blur(20px) hue-rotate(0deg);
|
274 |
+
}
|
275 |
+
25% {
|
276 |
+
transform: translate(-50%, -50%) scale(1.2);
|
277 |
+
filter: blur(15px) hue-rotate(45deg);
|
278 |
+
}
|
279 |
+
50% {
|
280 |
+
transform: translate(-50%, -50%) scale(0.8);
|
281 |
+
filter: blur(25px) hue-rotate(90deg);
|
282 |
+
}
|
283 |
+
75% {
|
284 |
+
transform: translate(-50%, -50%) scale(1.1);
|
285 |
+
filter: blur(18px) hue-rotate(135deg);
|
286 |
+
}
|
287 |
+
}
|
288 |
+
|
289 |
+
/* Interactive hover effect */
|
290 |
+
.orb-container:hover .css-orb {
|
291 |
+
animation-duration: 0.5s;
|
292 |
+
}
|
293 |
+
|
294 |
+
.orb-container:hover .orb-layer-1 {
|
295 |
+
filter: blur(30px);
|
296 |
+
transform: scale(1.2);
|
297 |
+
}
|
298 |
+
|
299 |
+
.orb-container:hover .orb-layer-2 {
|
300 |
+
filter: blur(35px);
|
301 |
+
transform: scale(0.9);
|
302 |
+
}
|
303 |
+
|
304 |
+
.orb-container:hover .orb-layer-3 {
|
305 |
+
filter: blur(45px);
|
306 |
+
transform: scale(1.1);
|
307 |
+
}
|
308 |
+
|
309 |
+
/* Modern Glassmorphism Container */
|
310 |
+
.glass-container {
|
311 |
+
backdrop-filter: blur(30px);
|
312 |
+
-webkit-backdrop-filter: blur(30px);
|
313 |
+
background: rgba(255, 255, 255, 0.05);
|
314 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
315 |
+
border-radius: 32px;
|
316 |
+
margin: 24px;
|
317 |
+
min-height: calc(100vh - 48px);
|
318 |
+
overflow: hidden;
|
319 |
+
position: relative;
|
320 |
+
box-shadow:
|
321 |
+
0 8px 32px rgba(0, 0, 0, 0.3),
|
322 |
+
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
323 |
+
}
|
324 |
+
|
325 |
+
.glass-container::before {
|
326 |
+
content: '';
|
327 |
+
position: absolute;
|
328 |
+
top: 0;
|
329 |
+
left: 0;
|
330 |
+
right: 0;
|
331 |
+
bottom: 0;
|
332 |
+
background: linear-gradient(135deg,
|
333 |
+
rgba(16, 185, 129, 0.1) 0%,
|
334 |
+
rgba(236, 72, 153, 0.08) 35%,
|
335 |
+
rgba(99, 102, 241, 0.1) 70%,
|
336 |
+
rgba(20, 184, 166, 0.08) 100%);
|
337 |
+
border-radius: 32px;
|
338 |
+
z-index: -1;
|
339 |
+
}
|
340 |
+
|
341 |
+
/* Modern Header */
|
342 |
+
.document-header {
|
343 |
+
padding: 40px 48px;
|
344 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
345 |
+
background: rgba(255, 255, 255, 0.03);
|
346 |
+
backdrop-filter: blur(25px);
|
347 |
+
position: relative;
|
348 |
+
}
|
349 |
+
|
350 |
+
.document-header::before {
|
351 |
+
content: '';
|
352 |
+
position: absolute;
|
353 |
+
top: 0;
|
354 |
+
left: 0;
|
355 |
+
right: 0;
|
356 |
+
height: 1px;
|
357 |
+
background: linear-gradient(90deg,
|
358 |
+
transparent 0%,
|
359 |
+
rgba(16, 185, 129, 0.5) 25%,
|
360 |
+
rgba(236, 72, 153, 0.5) 75%,
|
361 |
+
transparent 100%);
|
362 |
+
}
|
363 |
+
|
364 |
+
.document-title {
|
365 |
+
font-size: 2.75rem;
|
366 |
+
font-weight: 800;
|
367 |
+
background: linear-gradient(135deg,
|
368 |
+
#10b981 0%,
|
369 |
+
#ec4899 50%,
|
370 |
+
#6366f1 100%);
|
371 |
+
-webkit-background-clip: text;
|
372 |
+
-webkit-text-fill-color: transparent;
|
373 |
+
background-clip: text;
|
374 |
+
margin-bottom: 12px;
|
375 |
+
letter-spacing: -0.02em;
|
376 |
+
}
|
377 |
+
|
378 |
+
.document-subtitle {
|
379 |
+
color: rgba(255, 255, 255, 0.7);
|
380 |
+
font-size: 1.1rem;
|
381 |
+
font-weight: 500;
|
382 |
+
opacity: 0.9;
|
383 |
+
}
|
384 |
+
|
385 |
+
/* Modern Content */
|
386 |
+
.document-content {
|
387 |
+
padding: 48px;
|
388 |
+
line-height: 1.8;
|
389 |
+
position: relative;
|
390 |
+
}
|
391 |
+
|
392 |
+
.document-content::before {
|
393 |
+
content: '';
|
394 |
+
position: absolute;
|
395 |
+
top: 0;
|
396 |
+
left: 48px;
|
397 |
+
right: 48px;
|
398 |
+
height: 1px;
|
399 |
+
background: linear-gradient(90deg,
|
400 |
+
transparent 0%,
|
401 |
+
rgba(255, 255, 255, 0.1) 50%,
|
402 |
+
transparent 100%);
|
403 |
+
}
|
404 |
+
|
405 |
+
/* Clean Typography */
|
406 |
+
h1, h2, h3, h4, h5, h6 {
|
407 |
+
color: rgba(255, 255, 255, 0.95);
|
408 |
+
margin: 2.5rem 0 1.5rem 0;
|
409 |
+
font-weight: 700;
|
410 |
+
letter-spacing: -0.01em;
|
411 |
+
line-height: 1.3;
|
412 |
+
}
|
413 |
+
|
414 |
+
h1 {
|
415 |
+
font-size: 2.5rem;
|
416 |
+
color: rgba(255, 255, 255, 0.98);
|
417 |
+
margin-bottom: 2rem;
|
418 |
+
}
|
419 |
+
|
420 |
+
h2 {
|
421 |
+
font-size: 2rem;
|
422 |
+
color: rgba(255, 255, 255, 0.95);
|
423 |
+
margin-top: 3rem;
|
424 |
+
position: relative;
|
425 |
+
padding-bottom: 0.5rem;
|
426 |
+
}
|
427 |
+
|
428 |
+
h2::after {
|
429 |
+
content: '';
|
430 |
+
position: absolute;
|
431 |
+
bottom: 0;
|
432 |
+
left: 0;
|
433 |
+
width: 60px;
|
434 |
+
height: 2px;
|
435 |
+
background: linear-gradient(90deg,
|
436 |
+
rgba(16, 185, 129, 0.6) 0%,
|
437 |
+
rgba(236, 72, 153, 0.6) 100%);
|
438 |
+
border-radius: 1px;
|
439 |
+
}
|
440 |
+
|
441 |
+
h3 {
|
442 |
+
font-size: 1.5rem;
|
443 |
+
color: rgba(255, 255, 255, 0.92);
|
444 |
+
}
|
445 |
+
|
446 |
+
h4 {
|
447 |
+
font-size: 1.25rem;
|
448 |
+
color: rgba(255, 255, 255, 0.9);
|
449 |
+
}
|
450 |
+
|
451 |
+
p {
|
452 |
+
margin: 1rem 0;
|
453 |
+
color: rgba(255, 255, 255, 0.9);
|
454 |
+
}
|
455 |
+
|
456 |
+
/* Enhanced Glassmorphism Tables */
|
457 |
+
table {
|
458 |
+
width: 100%;
|
459 |
+
border-collapse: separate;
|
460 |
+
border-spacing: 0;
|
461 |
+
margin: 2.5rem 0;
|
462 |
+
background: rgba(255, 255, 255, 0.02);
|
463 |
+
backdrop-filter: blur(40px);
|
464 |
+
-webkit-backdrop-filter: blur(40px);
|
465 |
+
border-radius: 24px;
|
466 |
+
overflow: hidden;
|
467 |
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
468 |
+
box-shadow:
|
469 |
+
0 20px 40px rgba(0, 0, 0, 0.3),
|
470 |
+
0 8px 16px rgba(0, 0, 0, 0.2),
|
471 |
+
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
472 |
+
position: relative;
|
473 |
+
}
|
474 |
+
|
475 |
+
table::before {
|
476 |
+
content: '';
|
477 |
+
position: absolute;
|
478 |
+
top: 0;
|
479 |
+
left: 0;
|
480 |
+
right: 0;
|
481 |
+
bottom: 0;
|
482 |
+
background: linear-gradient(135deg,
|
483 |
+
rgba(16, 185, 129, 0.02) 0%,
|
484 |
+
rgba(236, 72, 153, 0.02) 50%,
|
485 |
+
rgba(99, 102, 241, 0.02) 100%);
|
486 |
+
border-radius: 24px;
|
487 |
+
z-index: -1;
|
488 |
+
}
|
489 |
+
|
490 |
+
th, td {
|
491 |
+
padding: 24px 28px;
|
492 |
+
text-align: left;
|
493 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
494 |
+
position: relative;
|
495 |
+
}
|
496 |
+
|
497 |
+
th {
|
498 |
+
background: rgba(255, 255, 255, 0.04);
|
499 |
+
backdrop-filter: blur(20px);
|
500 |
+
color: rgba(255, 255, 255, 0.95);
|
501 |
+
font-weight: 600;
|
502 |
+
font-size: 0.95rem;
|
503 |
+
letter-spacing: 0.3px;
|
504 |
+
text-transform: uppercase;
|
505 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
506 |
+
}
|
507 |
+
|
508 |
+
th::before {
|
509 |
+
content: '';
|
510 |
+
position: absolute;
|
511 |
+
top: 0;
|
512 |
+
left: 0;
|
513 |
+
right: 0;
|
514 |
+
bottom: 0;
|
515 |
+
background: linear-gradient(135deg,
|
516 |
+
rgba(16, 185, 129, 0.08) 0%,
|
517 |
+
rgba(236, 72, 153, 0.08) 100%);
|
518 |
+
z-index: -1;
|
519 |
+
}
|
520 |
+
|
521 |
+
th::after {
|
522 |
+
content: '';
|
523 |
+
position: absolute;
|
524 |
+
top: 0;
|
525 |
+
left: 0;
|
526 |
+
right: 0;
|
527 |
+
height: 1px;
|
528 |
+
background: linear-gradient(90deg,
|
529 |
+
transparent 0%,
|
530 |
+
rgba(255, 255, 255, 0.1) 50%,
|
531 |
+
transparent 100%);
|
532 |
+
}
|
533 |
+
|
534 |
+
td {
|
535 |
+
color: rgba(255, 255, 255, 0.9);
|
536 |
+
font-weight: 500;
|
537 |
+
background: rgba(255, 255, 255, 0.01);
|
538 |
+
}
|
539 |
+
|
540 |
+
tr:hover td {
|
541 |
+
background: rgba(255, 255, 255, 0.03);
|
542 |
+
color: rgba(255, 255, 255, 0.95);
|
543 |
+
transition: all 0.3s ease;
|
544 |
+
}
|
545 |
+
|
546 |
+
tr:hover {
|
547 |
+
transform: translateY(-2px);
|
548 |
+
transition: all 0.3s ease;
|
549 |
+
}
|
550 |
+
|
551 |
+
tr:hover td::before {
|
552 |
+
content: '';
|
553 |
+
position: absolute;
|
554 |
+
top: 0;
|
555 |
+
left: 0;
|
556 |
+
right: 0;
|
557 |
+
bottom: 0;
|
558 |
+
background: linear-gradient(135deg,
|
559 |
+
rgba(16, 185, 129, 0.03) 0%,
|
560 |
+
rgba(236, 72, 153, 0.03) 100%);
|
561 |
+
z-index: -1;
|
562 |
+
}
|
563 |
+
|
564 |
+
/* Links */
|
565 |
+
a {
|
566 |
+
color: #4facfe;
|
567 |
+
text-decoration: none;
|
568 |
+
transition: all 0.3s ease;
|
569 |
+
}
|
570 |
+
|
571 |
+
a:hover {
|
572 |
+
color: #8b5cf6;
|
573 |
+
text-shadow: 0 0 10px rgba(79, 172, 254, 0.5);
|
574 |
+
}
|
575 |
+
|
576 |
+
/* Enhanced Code blocks */
|
577 |
+
pre, code {
|
578 |
+
background: rgba(0, 0, 0, 0.3);
|
579 |
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
580 |
+
border-radius: 12px;
|
581 |
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
582 |
+
backdrop-filter: blur(20px);
|
583 |
+
}
|
584 |
+
|
585 |
+
pre {
|
586 |
+
padding: 24px;
|
587 |
+
overflow-x: auto;
|
588 |
+
margin: 2rem 0;
|
589 |
+
box-shadow:
|
590 |
+
0 8px 32px rgba(0, 0, 0, 0.2),
|
591 |
+
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
592 |
+
position: relative;
|
593 |
+
}
|
594 |
+
|
595 |
+
pre::before {
|
596 |
+
content: '';
|
597 |
+
position: absolute;
|
598 |
+
top: 0;
|
599 |
+
left: 0;
|
600 |
+
right: 0;
|
601 |
+
bottom: 0;
|
602 |
+
background: linear-gradient(135deg,
|
603 |
+
rgba(16, 185, 129, 0.02) 0%,
|
604 |
+
rgba(99, 102, 241, 0.02) 100%);
|
605 |
+
border-radius: 12px;
|
606 |
+
z-index: -1;
|
607 |
+
}
|
608 |
+
|
609 |
+
code {
|
610 |
+
padding: 6px 10px;
|
611 |
+
font-size: 0.875rem;
|
612 |
+
}
|
613 |
+
|
614 |
+
/* Enhanced Blockquotes */
|
615 |
+
blockquote {
|
616 |
+
border: none;
|
617 |
+
background: rgba(255, 255, 255, 0.03);
|
618 |
+
backdrop-filter: blur(30px);
|
619 |
+
padding: 28px 32px;
|
620 |
+
margin: 2.5rem 0;
|
621 |
+
border-radius: 20px;
|
622 |
+
border-left: 4px solid transparent;
|
623 |
+
background-image: linear-gradient(rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.03)),
|
624 |
+
linear-gradient(135deg, #10b981, #ec4899);
|
625 |
+
background-origin: border-box;
|
626 |
+
background-clip: padding-box, border-box;
|
627 |
+
box-shadow:
|
628 |
+
0 12px 40px rgba(0, 0, 0, 0.2),
|
629 |
+
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
630 |
+
position: relative;
|
631 |
+
}
|
632 |
+
|
633 |
+
blockquote::before {
|
634 |
+
content: '';
|
635 |
+
position: absolute;
|
636 |
+
top: 0;
|
637 |
+
left: 0;
|
638 |
+
right: 0;
|
639 |
+
bottom: 0;
|
640 |
+
background: linear-gradient(135deg,
|
641 |
+
rgba(16, 185, 129, 0.03) 0%,
|
642 |
+
rgba(236, 72, 153, 0.03) 100%);
|
643 |
+
border-radius: 20px;
|
644 |
+
z-index: -1;
|
645 |
+
}
|
646 |
+
|
647 |
+
blockquote p {
|
648 |
+
color: rgba(255, 255, 255, 0.95);
|
649 |
+
font-style: italic;
|
650 |
+
font-weight: 500;
|
651 |
+
margin: 0;
|
652 |
+
}
|
653 |
+
|
654 |
+
/* Lists */
|
655 |
+
ul, ol {
|
656 |
+
margin: 1rem 0;
|
657 |
+
padding-left: 2rem;
|
658 |
+
}
|
659 |
+
|
660 |
+
li {
|
661 |
+
margin: 0.5rem 0;
|
662 |
+
color: rgba(255, 255, 255, 0.9);
|
663 |
+
}
|
664 |
+
|
665 |
+
/* Horizontal rules */
|
666 |
+
hr {
|
667 |
+
border: none;
|
668 |
+
height: 1px;
|
669 |
+
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
670 |
+
margin: 3rem 0;
|
671 |
+
}
|
672 |
+
|
673 |
+
/* Strong and emphasis */
|
674 |
+
strong {
|
675 |
+
color: #ffffff;
|
676 |
+
font-weight: 600;
|
677 |
+
}
|
678 |
+
|
679 |
+
em {
|
680 |
+
color: #4facfe;
|
681 |
+
font-style: italic;
|
682 |
+
}
|
683 |
+
|
684 |
+
/* Modern Footer */
|
685 |
+
.document-footer {
|
686 |
+
padding: 40px 48px;
|
687 |
+
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
688 |
+
background: rgba(255, 255, 255, 0.02);
|
689 |
+
text-align: center;
|
690 |
+
color: rgba(255, 255, 255, 0.6);
|
691 |
+
font-size: 0.9rem;
|
692 |
+
font-weight: 500;
|
693 |
+
position: relative;
|
694 |
+
}
|
695 |
+
|
696 |
+
.document-footer::before {
|
697 |
+
content: '';
|
698 |
+
position: absolute;
|
699 |
+
top: 0;
|
700 |
+
left: 48px;
|
701 |
+
right: 48px;
|
702 |
+
height: 1px;
|
703 |
+
background: linear-gradient(90deg,
|
704 |
+
transparent 0%,
|
705 |
+
rgba(16, 185, 129, 0.3) 25%,
|
706 |
+
rgba(236, 72, 153, 0.3) 75%,
|
707 |
+
transparent 100%);
|
708 |
+
}
|
709 |
+
|
710 |
+
/* Responsive */
|
711 |
+
@media (max-width: 768px) {
|
712 |
+
.glass-container {
|
713 |
+
margin: 10px;
|
714 |
+
border-radius: 16px;
|
715 |
+
}
|
716 |
+
|
717 |
+
.document-header,
|
718 |
+
.document-content,
|
719 |
+
.document-footer {
|
720 |
+
padding: 24px 20px;
|
721 |
+
}
|
722 |
+
|
723 |
+
.document-title {
|
724 |
+
font-size: 2rem;
|
725 |
+
}
|
726 |
+
|
727 |
+
table {
|
728 |
+
font-size: 0.875rem;
|
729 |
+
}
|
730 |
+
|
731 |
+
th, td {
|
732 |
+
padding: 12px 16px;
|
733 |
+
}
|
734 |
+
}
|
735 |
+
|
736 |
+
/* Print styles */
|
737 |
+
@media print {
|
738 |
+
body {
|
739 |
+
background: white;
|
740 |
+
color: black;
|
741 |
+
}
|
742 |
+
|
743 |
+
.orb-background,
|
744 |
+
.glass-container::before {
|
745 |
+
display: none;
|
746 |
+
}
|
747 |
+
|
748 |
+
.glass-container {
|
749 |
+
background: white;
|
750 |
+
border: none;
|
751 |
+
box-shadow: none;
|
752 |
+
margin: 0;
|
753 |
+
}
|
754 |
+
}
|
755 |
+
</style>
|
756 |
+
<script src="https://unpkg.com/[email protected]/dist/ogl.umd.js"></script>
|
757 |
+
</head>
|
758 |
+
<body>
|
759 |
+
<!-- Orb Background -->
|
760 |
+
<div class="animated-background">
|
761 |
+
<div class="gradient-orb orb-1"></div>
|
762 |
+
<div class="gradient-orb orb-2"></div>
|
763 |
+
<div class="gradient-orb orb-3"></div>
|
764 |
+
<div class="gradient-orb orb-4"></div>
|
765 |
+
<div class="gradient-orb orb-5"></div>
|
766 |
+
</div>
|
767 |
+
|
768 |
+
<!-- Main Content -->
|
769 |
+
<div class="glass-container">
|
770 |
+
<header class="document-header">
|
771 |
+
<h1 class="document-title">${baseFileName}</h1>
|
772 |
+
<p class="document-subtitle">Generated by Luna OCR • ${new Date().toLocaleDateString()}</p>
|
773 |
+
</header>
|
774 |
+
|
775 |
+
<main class="document-content">
|
776 |
+
${marked(text)}
|
777 |
+
</main>
|
778 |
+
|
779 |
+
<footer class="document-footer">
|
780 |
+
<p>Processed with Luna OCR • Glassmorphism Theme • ${new Date().toLocaleString()}</p>
|
781 |
+
</footer>
|
782 |
+
</div>
|
783 |
+
|
784 |
+
<script>
|
785 |
+
// Robust Orb.js implementation with fallback
|
786 |
+
function initOrb() {
|
787 |
+
const container = document.getElementById('orbContainer');
|
788 |
+
const fallback = document.getElementById('orbFallback');
|
789 |
+
|
790 |
+
if (!container) {
|
791 |
+
console.error('Orb container not found');
|
792 |
+
return;
|
793 |
+
}
|
794 |
+
|
795 |
+
// Check if OGL loaded
|
796 |
+
if (typeof OGL === 'undefined') {
|
797 |
+
console.log('OGL not loaded, using CSS fallback');
|
798 |
+
if (fallback) fallback.style.display = 'block';
|
799 |
+
return;
|
800 |
+
}
|
801 |
+
|
802 |
+
try {
|
803 |
+
|
804 |
+
const { Renderer, Program, Mesh, Triangle, Vec3 } = OGL;
|
805 |
+
|
806 |
+
const vert = \`
|
807 |
+
precision highp float;
|
808 |
+
attribute vec2 position;
|
809 |
+
attribute vec2 uv;
|
810 |
+
varying vec2 vUv;
|
811 |
+
void main() {
|
812 |
+
vUv = uv;
|
813 |
+
gl_Position = vec4(position, 0.0, 1.0);
|
814 |
+
}
|
815 |
+
\`;
|
816 |
+
|
817 |
+
const frag = \`
|
818 |
+
precision highp float;
|
819 |
+
|
820 |
+
uniform float iTime;
|
821 |
+
uniform vec3 iResolution;
|
822 |
+
uniform float hue;
|
823 |
+
uniform float hover;
|
824 |
+
uniform float rot;
|
825 |
+
uniform float hoverIntensity;
|
826 |
+
varying vec2 vUv;
|
827 |
+
|
828 |
+
vec3 rgb2yiq(vec3 c) {
|
829 |
+
float y = dot(c, vec3(0.299, 0.587, 0.114));
|
830 |
+
float i = dot(c, vec3(0.596, -0.274, -0.322));
|
831 |
+
float q = dot(c, vec3(0.211, -0.523, 0.312));
|
832 |
+
return vec3(y, i, q);
|
833 |
+
}
|
834 |
+
|
835 |
+
vec3 yiq2rgb(vec3 c) {
|
836 |
+
float r = c.x + 0.956 * c.y + 0.621 * c.z;
|
837 |
+
float g = c.x - 0.272 * c.y - 0.647 * c.z;
|
838 |
+
float b = c.x - 1.106 * c.y + 1.703 * c.z;
|
839 |
+
return vec3(r, g, b);
|
840 |
+
}
|
841 |
+
|
842 |
+
vec3 adjustHue(vec3 color, float hueDeg) {
|
843 |
+
float hueRad = hueDeg * 3.14159265 / 180.0;
|
844 |
+
vec3 yiq = rgb2yiq(color);
|
845 |
+
float cosA = cos(hueRad);
|
846 |
+
float sinA = sin(hueRad);
|
847 |
+
float i = yiq.y * cosA - yiq.z * sinA;
|
848 |
+
float q = yiq.y * sinA + yiq.z * cosA;
|
849 |
+
yiq.y = i;
|
850 |
+
yiq.z = q;
|
851 |
+
return yiq2rgb(yiq);
|
852 |
+
}
|
853 |
+
|
854 |
+
vec3 hash33(vec3 p3) {
|
855 |
+
p3 = fract(p3 * vec3(0.1031, 0.11369, 0.13787));
|
856 |
+
p3 += dot(p3, p3.yxz + 19.19);
|
857 |
+
return -1.0 + 2.0 * fract(vec3(
|
858 |
+
p3.x + p3.y,
|
859 |
+
p3.x + p3.z,
|
860 |
+
p3.y + p3.z
|
861 |
+
) * p3.zyx);
|
862 |
+
}
|
863 |
+
|
864 |
+
float snoise3(vec3 p) {
|
865 |
+
const float K1 = 0.333333333;
|
866 |
+
const float K2 = 0.166666667;
|
867 |
+
vec3 i = floor(p + (p.x + p.y + p.z) * K1);
|
868 |
+
vec3 d0 = p - (i - (i.x + i.y + i.z) * K2);
|
869 |
+
vec3 e = step(vec3(0.0), d0 - d0.yzx);
|
870 |
+
vec3 i1 = e * (1.0 - e.zxy);
|
871 |
+
vec3 i2 = 1.0 - e.zxy * (1.0 - e);
|
872 |
+
vec3 d1 = d0 - (i1 - K2);
|
873 |
+
vec3 d2 = d0 - (i2 - K1);
|
874 |
+
vec3 d3 = d0 - 0.5;
|
875 |
+
vec4 h = max(0.6 - vec4(
|
876 |
+
dot(d0, d0),
|
877 |
+
dot(d1, d1),
|
878 |
+
dot(d2, d2),
|
879 |
+
dot(d3, d3)
|
880 |
+
), 0.0);
|
881 |
+
vec4 n = h * h * h * h * vec4(
|
882 |
+
dot(d0, hash33(i)),
|
883 |
+
dot(d1, hash33(i + i1)),
|
884 |
+
dot(d2, hash33(i + i2)),
|
885 |
+
dot(d3, hash33(i + 1.0))
|
886 |
+
);
|
887 |
+
return dot(vec4(31.316), n);
|
888 |
+
}
|
889 |
+
|
890 |
+
vec4 extractAlpha(vec3 colorIn) {
|
891 |
+
float a = max(max(colorIn.r, colorIn.g), colorIn.b);
|
892 |
+
return vec4(colorIn.rgb / (a + 1e-5), a);
|
893 |
+
}
|
894 |
+
|
895 |
+
const vec3 baseColor1 = vec3(0.611765, 0.262745, 0.996078);
|
896 |
+
const vec3 baseColor2 = vec3(0.298039, 0.760784, 0.913725);
|
897 |
+
const vec3 baseColor3 = vec3(0.062745, 0.078431, 0.600000);
|
898 |
+
const float innerRadius = 0.6;
|
899 |
+
const float noiseScale = 0.65;
|
900 |
+
|
901 |
+
float light1(float intensity, float attenuation, float dist) {
|
902 |
+
return intensity / (1.0 + dist * attenuation);
|
903 |
+
}
|
904 |
+
float light2(float intensity, float attenuation, float dist) {
|
905 |
+
return intensity / (1.0 + dist * dist * attenuation);
|
906 |
+
}
|
907 |
+
|
908 |
+
vec4 draw(vec2 uv) {
|
909 |
+
vec3 color1 = adjustHue(baseColor1, hue);
|
910 |
+
vec3 color2 = adjustHue(baseColor2, hue);
|
911 |
+
vec3 color3 = adjustHue(baseColor3, hue);
|
912 |
+
|
913 |
+
float ang = atan(uv.y, uv.x);
|
914 |
+
float len = length(uv);
|
915 |
+
float invLen = len > 0.0 ? 1.0 / len : 0.0;
|
916 |
+
|
917 |
+
float n0 = snoise3(vec3(uv * noiseScale, iTime * 0.5)) * 0.5 + 0.5;
|
918 |
+
float r0 = mix(mix(innerRadius, 1.0, 0.4), mix(innerRadius, 1.0, 0.6), n0);
|
919 |
+
float d0 = distance(uv, (r0 * invLen) * uv);
|
920 |
+
float v0 = light1(1.0, 10.0, d0);
|
921 |
+
v0 *= smoothstep(r0 * 1.05, r0, len);
|
922 |
+
float cl = cos(ang + iTime * 2.0) * 0.5 + 0.5;
|
923 |
+
|
924 |
+
float a = iTime * -1.0;
|
925 |
+
vec2 pos = vec2(cos(a), sin(a)) * r0;
|
926 |
+
float d = distance(uv, pos);
|
927 |
+
float v1 = light2(1.5, 5.0, d);
|
928 |
+
v1 *= light1(1.0, 50.0, d0);
|
929 |
+
|
930 |
+
float v2 = smoothstep(1.0, mix(innerRadius, 1.0, n0 * 0.5), len);
|
931 |
+
float v3 = smoothstep(innerRadius, mix(innerRadius, 1.0, 0.5), len);
|
932 |
+
|
933 |
+
vec3 col = mix(color1, color2, cl);
|
934 |
+
col = mix(color3, col, v0);
|
935 |
+
col = (col + v1) * v2 * v3;
|
936 |
+
col = clamp(col, 0.0, 1.0);
|
937 |
+
|
938 |
+
return extractAlpha(col);
|
939 |
+
}
|
940 |
+
|
941 |
+
vec4 mainImage(vec2 fragCoord) {
|
942 |
+
vec2 center = iResolution.xy * 0.5;
|
943 |
+
float size = min(iResolution.x, iResolution.y);
|
944 |
+
vec2 uv = (fragCoord - center) / size * 2.0;
|
945 |
+
|
946 |
+
float angle = rot;
|
947 |
+
float s = sin(angle);
|
948 |
+
float c = cos(angle);
|
949 |
+
uv = vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y);
|
950 |
+
|
951 |
+
uv.x += hover * hoverIntensity * 0.1 * sin(uv.y * 10.0 + iTime);
|
952 |
+
uv.y += hover * hoverIntensity * 0.1 * sin(uv.x * 10.0 + iTime);
|
953 |
+
|
954 |
+
return draw(uv);
|
955 |
+
}
|
956 |
+
|
957 |
+
void main() {
|
958 |
+
vec2 fragCoord = vUv * iResolution.xy;
|
959 |
+
vec4 col = mainImage(fragCoord);
|
960 |
+
gl_FragColor = vec4(col.rgb * col.a, col.a);
|
961 |
+
}
|
962 |
+
\`;
|
963 |
+
|
964 |
+
// Hide fallback and create WebGL orb
|
965 |
+
if (fallback) fallback.style.display = 'none';
|
966 |
+
|
967 |
+
const renderer = new Renderer({ alpha: true, premultipliedAlpha: false });
|
968 |
+
const gl = renderer.gl;
|
969 |
+
gl.clearColor(0, 0, 0, 0);
|
970 |
+
|
971 |
+
// Style the canvas
|
972 |
+
gl.canvas.style.position = 'absolute';
|
973 |
+
gl.canvas.style.top = '0';
|
974 |
+
gl.canvas.style.left = '0';
|
975 |
+
gl.canvas.style.width = '100%';
|
976 |
+
gl.canvas.style.height = '100%';
|
977 |
+
gl.canvas.style.opacity = '0.6';
|
978 |
+
|
979 |
+
container.appendChild(gl.canvas);
|
980 |
+
|
981 |
+
const geometry = new Triangle(gl);
|
982 |
+
const program = new Program(gl, {
|
983 |
+
vertex: vert,
|
984 |
+
fragment: frag,
|
985 |
+
uniforms: {
|
986 |
+
iTime: { value: 0 },
|
987 |
+
iResolution: {
|
988 |
+
value: new Vec3(
|
989 |
+
gl.canvas.width,
|
990 |
+
gl.canvas.height,
|
991 |
+
gl.canvas.width / gl.canvas.height
|
992 |
+
),
|
993 |
+
},
|
994 |
+
hue: { value: 0 },
|
995 |
+
hover: { value: 0 },
|
996 |
+
rot: { value: 0 },
|
997 |
+
hoverIntensity: { value: 0.2 },
|
998 |
+
},
|
999 |
+
});
|
1000 |
+
|
1001 |
+
const mesh = new Mesh(gl, { geometry, program });
|
1002 |
+
|
1003 |
+
function resize() {
|
1004 |
+
if (!container) return;
|
1005 |
+
const dpr = window.devicePixelRatio || 1;
|
1006 |
+
const width = container.clientWidth;
|
1007 |
+
const height = container.clientHeight;
|
1008 |
+
renderer.setSize(width * dpr, height * dpr);
|
1009 |
+
gl.canvas.style.width = width + "px";
|
1010 |
+
gl.canvas.style.height = height + "px";
|
1011 |
+
program.uniforms.iResolution.value.set(
|
1012 |
+
gl.canvas.width,
|
1013 |
+
gl.canvas.height,
|
1014 |
+
gl.canvas.width / gl.canvas.height
|
1015 |
+
);
|
1016 |
+
}
|
1017 |
+
window.addEventListener("resize", resize);
|
1018 |
+
resize();
|
1019 |
+
|
1020 |
+
let targetHover = 0;
|
1021 |
+
let lastTime = 0;
|
1022 |
+
let currentRot = 0;
|
1023 |
+
const rotationSpeed = 0.3;
|
1024 |
+
|
1025 |
+
const handleMouseMove = (e) => {
|
1026 |
+
const rect = container.getBoundingClientRect();
|
1027 |
+
const x = e.clientX - rect.left;
|
1028 |
+
const y = e.clientY - rect.top;
|
1029 |
+
const width = rect.width;
|
1030 |
+
const height = rect.height;
|
1031 |
+
const size = Math.min(width, height);
|
1032 |
+
const centerX = width / 2;
|
1033 |
+
const centerY = height / 2;
|
1034 |
+
const uvX = ((x - centerX) / size) * 2.0;
|
1035 |
+
const uvY = ((y - centerY) / size) * 2.0;
|
1036 |
+
|
1037 |
+
if (Math.sqrt(uvX * uvX + uvY * uvY) < 0.8) {
|
1038 |
+
targetHover = 1;
|
1039 |
+
} else {
|
1040 |
+
targetHover = 0;
|
1041 |
+
}
|
1042 |
+
};
|
1043 |
+
|
1044 |
+
const handleMouseLeave = () => {
|
1045 |
+
targetHover = 0;
|
1046 |
+
};
|
1047 |
+
|
1048 |
+
container.addEventListener("mousemove", handleMouseMove);
|
1049 |
+
container.addEventListener("mouseleave", handleMouseLeave);
|
1050 |
+
|
1051 |
+
const update = (t) => {
|
1052 |
+
requestAnimationFrame(update);
|
1053 |
+
const dt = (t - lastTime) * 0.001;
|
1054 |
+
lastTime = t;
|
1055 |
+
program.uniforms.iTime.value = t * 0.001;
|
1056 |
+
program.uniforms.hue.value = 0;
|
1057 |
+
program.uniforms.hoverIntensity.value = 0.2;
|
1058 |
+
|
1059 |
+
const effectiveHover = targetHover;
|
1060 |
+
program.uniforms.hover.value += (effectiveHover - program.uniforms.hover.value) * 0.1;
|
1061 |
+
|
1062 |
+
if (effectiveHover > 0.5) {
|
1063 |
+
currentRot += dt * rotationSpeed;
|
1064 |
+
}
|
1065 |
+
program.uniforms.rot.value = currentRot;
|
1066 |
+
|
1067 |
+
renderer.render({ scene: mesh });
|
1068 |
+
};
|
1069 |
+
requestAnimationFrame(update);
|
1070 |
+
|
1071 |
+
console.log('WebGL Orb initialized successfully!');
|
1072 |
+
|
1073 |
+
} catch (error) {
|
1074 |
+
console.error('WebGL Orb failed, using CSS fallback:', error);
|
1075 |
+
if (fallback) fallback.style.display = 'block';
|
1076 |
+
}
|
1077 |
+
}
|
1078 |
+
|
1079 |
+
// Modern animated background - no JavaScript needed!
|
1080 |
+
</script>
|
1081 |
+
</body>
|
1082 |
+
</html>`;
|
1083 |
+
downloadFile(htmlContent, `${baseFileName}.html`, 'text/html');
|
1084 |
+
break;
|
1085 |
+
case 'json':
|
1086 |
+
const jsonData = {
|
1087 |
+
metadata: {
|
1088 |
+
fileName: fileName || 'extracted-text',
|
1089 |
+
extractedAt: new Date().toISOString(),
|
1090 |
+
characterCount: text.length,
|
1091 |
+
lineCount: text.split('\n').length,
|
1092 |
+
wordCount: text.split(/\s+/).filter(word => word.length > 0).length
|
1093 |
+
},
|
1094 |
+
content: {
|
1095 |
+
rawText: text,
|
1096 |
+
lines: text.split('\n'),
|
1097 |
+
paragraphs: text.split('\n\n').filter(p => p.trim().length > 0)
|
1098 |
+
}
|
1099 |
+
};
|
1100 |
+
downloadFile(JSON.stringify(jsonData, null, 2), `${baseFileName}.json`, 'application/json');
|
1101 |
+
break;
|
1102 |
+
case 'pdf':
|
1103 |
+
const pdf = new jsPDF();
|
1104 |
+
|
1105 |
+
// Set up styling
|
1106 |
+
pdf.setFont('helvetica', 'bold');
|
1107 |
+
pdf.setFontSize(20);
|
1108 |
+
pdf.setTextColor(79, 172, 254); // Luna OCR blue
|
1109 |
+
|
1110 |
+
// Add title
|
1111 |
+
pdf.text(baseFileName, 20, 30);
|
1112 |
+
|
1113 |
+
// Add subtitle
|
1114 |
+
pdf.setFont('helvetica', 'normal');
|
1115 |
+
pdf.setFontSize(10);
|
1116 |
+
pdf.setTextColor(128, 128, 128);
|
1117 |
+
pdf.text(`Generated by Luna OCR • ${new Date().toLocaleDateString()}`, 20, 40);
|
1118 |
+
|
1119 |
+
// Add separator line
|
1120 |
+
pdf.setDrawColor(79, 172, 254);
|
1121 |
+
pdf.setLineWidth(0.5);
|
1122 |
+
pdf.line(20, 45, 190, 45);
|
1123 |
+
|
1124 |
+
// Add content
|
1125 |
+
pdf.setFont('helvetica', 'normal');
|
1126 |
+
pdf.setFontSize(11);
|
1127 |
+
pdf.setTextColor(0, 0, 0);
|
1128 |
+
|
1129 |
+
const lines = pdf.splitTextToSize(text, 170);
|
1130 |
+
pdf.text(lines, 20, 55);
|
1131 |
+
|
1132 |
+
// Add footer
|
1133 |
+
const pageCount = pdf.internal.getNumberOfPages();
|
1134 |
+
for (let i = 1; i <= pageCount; i++) {
|
1135 |
+
pdf.setPage(i);
|
1136 |
+
pdf.setFont('helvetica', 'normal');
|
1137 |
+
pdf.setFontSize(8);
|
1138 |
+
pdf.setTextColor(128, 128, 128);
|
1139 |
+
pdf.text(`Luna OCR • Page ${i} of ${pageCount}`, 20, 285);
|
1140 |
+
}
|
1141 |
+
|
1142 |
+
pdf.save(`${baseFileName}.pdf`);
|
1143 |
+
break;
|
1144 |
+
default:
|
1145 |
+
break;
|
1146 |
+
}
|
1147 |
+
};
|
1148 |
+
|
1149 |
+
const downloadFile = (content, filename, mimeType) => {
|
1150 |
+
const blob = new Blob([content], { type: mimeType });
|
1151 |
+
const url = URL.createObjectURL(blob);
|
1152 |
+
const link = document.createElement('a');
|
1153 |
+
link.href = url;
|
1154 |
+
link.download = filename;
|
1155 |
+
document.body.appendChild(link);
|
1156 |
+
link.click();
|
1157 |
+
document.body.removeChild(link);
|
1158 |
+
URL.revokeObjectURL(url);
|
1159 |
+
};
|
1160 |
+
|
1161 |
+
const renderContent = () => {
|
1162 |
+
switch (previewMode) {
|
1163 |
+
case 'text':
|
1164 |
+
// Always show raw text in text mode
|
1165 |
+
return (
|
1166 |
+
<div className="text-content">
|
1167 |
+
<pre className="text-preview">{text}</pre>
|
1168 |
+
</div>
|
1169 |
+
);
|
1170 |
+
case 'markdown':
|
1171 |
+
marked.setOptions({
|
1172 |
+
breaks: true,
|
1173 |
+
gfm: true,
|
1174 |
+
tables: true,
|
1175 |
+
headerIds: false,
|
1176 |
+
mangle: false
|
1177 |
+
});
|
1178 |
+
return (
|
1179 |
+
<div
|
1180 |
+
className="markdown-plain"
|
1181 |
+
dangerouslySetInnerHTML={{ __html: marked(text) }}
|
1182 |
+
/>
|
1183 |
+
);
|
1184 |
+
case 'preview':
|
1185 |
+
marked.setOptions({
|
1186 |
+
breaks: true,
|
1187 |
+
gfm: true,
|
1188 |
+
tables: true,
|
1189 |
+
headerIds: false,
|
1190 |
+
mangle: false
|
1191 |
+
});
|
1192 |
+
return (
|
1193 |
+
<div
|
1194 |
+
className="html-preview"
|
1195 |
+
dangerouslySetInnerHTML={{ __html: marked(text) }}
|
1196 |
+
/>
|
1197 |
+
);
|
1198 |
+
case 'json':
|
1199 |
+
const jsonData = {
|
1200 |
+
metadata: {
|
1201 |
+
fileName: fileName || 'extracted-text',
|
1202 |
+
extractedAt: new Date().toISOString(),
|
1203 |
+
characterCount: text.length,
|
1204 |
+
lineCount: text.split('\n').length,
|
1205 |
+
wordCount: text.split(/\s+/).filter(word => word.length > 0).length
|
1206 |
+
},
|
1207 |
+
content: {
|
1208 |
+
rawText: text,
|
1209 |
+
lines: text.split('\n'),
|
1210 |
+
paragraphs: text.split('\n\n').filter(p => p.trim().length > 0)
|
1211 |
+
}
|
1212 |
+
};
|
1213 |
+
return (
|
1214 |
+
<SyntaxHighlighter
|
1215 |
+
language="json"
|
1216 |
+
style={atomDark}
|
1217 |
+
customStyle={{
|
1218 |
+
background: 'rgba(0, 0, 0, 0.3)',
|
1219 |
+
border: '1px solid rgba(255, 255, 255, 0.1)',
|
1220 |
+
borderRadius: '12px',
|
1221 |
+
fontSize: '0.875rem',
|
1222 |
+
lineHeight: '1.6'
|
1223 |
+
}}
|
1224 |
+
>
|
1225 |
+
{JSON.stringify(jsonData, null, 2)}
|
1226 |
+
</SyntaxHighlighter>
|
1227 |
+
);
|
1228 |
+
default:
|
1229 |
+
return null;
|
1230 |
+
}
|
1231 |
+
};
|
1232 |
+
|
1233 |
+
return (
|
1234 |
+
<div className="results-container">
|
1235 |
+
{/* Header Section */}
|
1236 |
+
<div className="results-header">
|
1237 |
+
<div className="header-content">
|
1238 |
+
<div className="header-left">
|
1239 |
+
<h3>Download & Preview</h3>
|
1240 |
+
<span className="result-info">
|
1241 |
+
{text.length} characters • {text.split('\n').length} lines • Ready to download
|
1242 |
+
</span>
|
1243 |
+
</div>
|
1244 |
+
|
1245 |
+
<div className="header-actions">
|
1246 |
+
<motion.button
|
1247 |
+
className="action-button"
|
1248 |
+
onClick={handleCopy}
|
1249 |
+
whileHover={{ scale: 1.05 }}
|
1250 |
+
whileTap={{ scale: 0.95 }}
|
1251 |
+
>
|
1252 |
+
<AnimatePresence mode="wait">
|
1253 |
+
{copied ? (
|
1254 |
+
<motion.div key="copied" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
1255 |
+
<Check size={16} />
|
1256 |
+
</motion.div>
|
1257 |
+
) : (
|
1258 |
+
<motion.div key="copy" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
1259 |
+
<Copy size={16} />
|
1260 |
+
</motion.div>
|
1261 |
+
)}
|
1262 |
+
</AnimatePresence>
|
1263 |
+
{copied ? 'Copied!' : 'Copy'}
|
1264 |
+
</motion.button>
|
1265 |
+
</div>
|
1266 |
+
</div>
|
1267 |
+
</div>
|
1268 |
+
|
1269 |
+
{/* Unified Preview & Download Section */}
|
1270 |
+
<div className="categories-section">
|
1271 |
+
<div className="category-group">
|
1272 |
+
<div className="category-header">
|
1273 |
+
<Eye size={20} />
|
1274 |
+
<span className="category-label">PREVIEW & DOWNLOAD</span>
|
1275 |
+
</div>
|
1276 |
+
|
1277 |
+
<div className="format-buttons">
|
1278 |
+
{[
|
1279 |
+
{ id: 'text', label: 'Text', icon: <FileText size={24} />, previewId: 'text' },
|
1280 |
+
{ id: 'md', label: 'Markdown', icon: <Code size={24} />, previewId: 'markdown' },
|
1281 |
+
{ id: 'html', label: 'HTML', icon: <Eye size={24} />, previewId: 'preview' },
|
1282 |
+
{ id: 'json', label: 'JSON', icon: <Code size={24} />, previewId: 'json' }
|
1283 |
+
].map((format) => (
|
1284 |
+
<motion.div
|
1285 |
+
key={format.id}
|
1286 |
+
className={`format-button-container ${previewMode === format.previewId ? 'active' : ''}`}
|
1287 |
+
whileHover={{ scale: 1.02 }}
|
1288 |
+
whileTap={{ scale: 0.98 }}
|
1289 |
+
>
|
1290 |
+
<button
|
1291 |
+
className="format-preview-button"
|
1292 |
+
onClick={() => format.previewId && onPreviewModeChange(format.previewId)}
|
1293 |
+
disabled={!format.previewId}
|
1294 |
+
>
|
1295 |
+
<div className="format-icon">{format.icon}</div>
|
1296 |
+
<span className="format-label">{format.label}</span>
|
1297 |
+
</button>
|
1298 |
+
</motion.div>
|
1299 |
+
))}
|
1300 |
+
</div>
|
1301 |
+
</div>
|
1302 |
+
</div>
|
1303 |
+
|
1304 |
+
{/* Content Preview */}
|
1305 |
+
<div className="content-preview">
|
1306 |
+
<motion.button
|
1307 |
+
className="content-preview-download"
|
1308 |
+
onClick={() => {
|
1309 |
+
const formatMap = {
|
1310 |
+
'text': 'txt',
|
1311 |
+
'markdown': 'md',
|
1312 |
+
'preview': 'html',
|
1313 |
+
'json': 'json'
|
1314 |
+
};
|
1315 |
+
handleDownload(formatMap[previewMode] || 'txt');
|
1316 |
+
}}
|
1317 |
+
whileHover={{ scale: 1.05 }}
|
1318 |
+
whileTap={{ scale: 0.95 }}
|
1319 |
+
title={`Download current preview as ${previewMode === 'text' ? 'TXT' : previewMode === 'markdown' ? 'MD' : previewMode === 'preview' ? 'HTML' : 'JSON'}`}
|
1320 |
+
>
|
1321 |
+
<Download size={16} />
|
1322 |
+
</motion.button>
|
1323 |
+
|
1324 |
+
<AnimatePresence mode="wait">
|
1325 |
+
<motion.div
|
1326 |
+
key={previewMode}
|
1327 |
+
initial={{ opacity: 0, y: 20 }}
|
1328 |
+
animate={{ opacity: 1, y: 0 }}
|
1329 |
+
exit={{ opacity: 0, y: -20 }}
|
1330 |
+
transition={{ duration: 0.3 }}
|
1331 |
+
>
|
1332 |
+
{renderContent()}
|
1333 |
+
</motion.div>
|
1334 |
+
</AnimatePresence>
|
1335 |
+
</div>
|
1336 |
+
</div>
|
1337 |
+
);
|
1338 |
+
};
|
1339 |
+
|
1340 |
+
export default ResultsPanel;
|
src/components/TextViewer.js
ADDED
@@ -0,0 +1,809 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState, useEffect } from 'react';
|
2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
3 |
+
import { FileText, Code, Eye, ArrowLeft, Download, Copy, Check, Maximize2, ZoomIn, ZoomOut, Search } from 'lucide-react';
|
4 |
+
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
5 |
+
import { atomDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
6 |
+
import { marked } from 'marked';
|
7 |
+
|
8 |
+
const TextViewer = () => {
|
9 |
+
const [text, setText] = useState('');
|
10 |
+
const [fileName, setFileName] = useState('');
|
11 |
+
const [viewMode, setViewMode] = useState('text');
|
12 |
+
const [copied, setCopied] = useState(false);
|
13 |
+
const [fontSize, setFontSize] = useState(16);
|
14 |
+
const [searchTerm, setSearchTerm] = useState('');
|
15 |
+
const [isSearching, setIsSearching] = useState(false);
|
16 |
+
|
17 |
+
useEffect(() => {
|
18 |
+
// Get text from URL params or localStorage
|
19 |
+
const urlParams = new URLSearchParams(window.location.search);
|
20 |
+
const textParam = urlParams.get('text');
|
21 |
+
const fileParam = urlParams.get('file');
|
22 |
+
|
23 |
+
if (textParam) {
|
24 |
+
setText(decodeURIComponent(textParam));
|
25 |
+
} else {
|
26 |
+
const savedText = localStorage.getItem('extractedText');
|
27 |
+
if (savedText) setText(savedText);
|
28 |
+
}
|
29 |
+
|
30 |
+
if (fileParam) {
|
31 |
+
setFileName(decodeURIComponent(fileParam));
|
32 |
+
} else {
|
33 |
+
setFileName(localStorage.getItem('fileName') || 'extracted-text');
|
34 |
+
}
|
35 |
+
}, []);
|
36 |
+
|
37 |
+
const viewModes = [
|
38 |
+
{ id: 'text', label: 'Text', icon: <FileText size={18} />, ext: '.txt' },
|
39 |
+
{ id: 'markdown', label: 'Markdown', icon: <Code size={18} />, ext: '.md' },
|
40 |
+
{ id: 'html', label: 'Rendered', icon: <Eye size={18} />, ext: '.html' },
|
41 |
+
{ id: 'json', label: 'JSON', icon: <Code size={18} />, ext: '.json' }
|
42 |
+
];
|
43 |
+
|
44 |
+
const handleCopy = async () => {
|
45 |
+
try {
|
46 |
+
await navigator.clipboard.writeText(text);
|
47 |
+
setCopied(true);
|
48 |
+
setTimeout(() => setCopied(false), 2000);
|
49 |
+
} catch (err) {
|
50 |
+
console.error('Failed to copy text:', err);
|
51 |
+
}
|
52 |
+
};
|
53 |
+
|
54 |
+
const handleBack = () => {
|
55 |
+
window.history.back();
|
56 |
+
};
|
57 |
+
|
58 |
+
const adjustFontSize = (delta) => {
|
59 |
+
setFontSize(prev => Math.max(12, Math.min(24, prev + delta)));
|
60 |
+
}; cons
|
61 |
+
t renderContent = () => {
|
62 |
+
const baseStyle = { fontSize: `${fontSize}px`, lineHeight: 1.7 };
|
63 |
+
|
64 |
+
switch (viewMode) {
|
65 |
+
case 'text':
|
66 |
+
return (
|
67 |
+
<div className="reader-content" style={baseStyle}>
|
68 |
+
<pre className="text-reader">{text}</pre>
|
69 |
+
</div>
|
70 |
+
);
|
71 |
+
case 'markdown':
|
72 |
+
return (
|
73 |
+
<div className="reader-content">
|
74 |
+
<SyntaxHighlighter
|
75 |
+
language="markdown"
|
76 |
+
style={atomDark}
|
77 |
+
customStyle={{
|
78 |
+
...baseStyle,
|
79 |
+
background: 'transparent',
|
80 |
+
border: 'none',
|
81 |
+
padding: 0,
|
82 |
+
margin: 0
|
83 |
+
}}
|
84 |
+
>
|
85 |
+
{text}
|
86 |
+
</SyntaxHighlighter>
|
87 |
+
</div>
|
88 |
+
);
|
89 |
+
case 'html':
|
90 |
+
return (
|
91 |
+
<div
|
92 |
+
className="reader-content html-reader"
|
93 |
+
style={baseStyle}
|
94 |
+
dangerouslySetInnerHTML={{ __html: marked(text) }}
|
95 |
+
/>
|
96 |
+
);
|
97 |
+
case 'json':
|
98 |
+
const jsonData = {
|
99 |
+
metadata: {
|
100 |
+
fileName: fileName || 'extracted-text',
|
101 |
+
extractedAt: new Date().toISOString(),
|
102 |
+
characterCount: text.length,
|
103 |
+
lineCount: text.split('\n').length,
|
104 |
+
wordCount: text.split(/\s+/).filter(word => word.length > 0).length
|
105 |
+
},
|
106 |
+
content: {
|
107 |
+
rawText: text,
|
108 |
+
lines: text.split('\n'),
|
109 |
+
paragraphs: text.split('\n\n').filter(p => p.trim().length > 0)
|
110 |
+
}
|
111 |
+
};
|
112 |
+
return (
|
113 |
+
<div className="reader-content">
|
114 |
+
<SyntaxHighlighter
|
115 |
+
language="json"
|
116 |
+
style={atomDark}
|
117 |
+
customStyle={{
|
118 |
+
...baseStyle,
|
119 |
+
background: 'transparent',
|
120 |
+
border: 'none',
|
121 |
+
padding: 0,
|
122 |
+
margin: 0
|
123 |
+
}}
|
124 |
+
>
|
125 |
+
{JSON.stringify(jsonData, null, 2)}
|
126 |
+
</SyntaxHighlighter>
|
127 |
+
</div>
|
128 |
+
);
|
129 |
+
default:
|
130 |
+
return null;
|
131 |
+
}
|
132 |
+
}; return
|
133 |
+
(
|
134 |
+
<div className="text-viewer-app">
|
135 |
+
{/* Same background as main app */}
|
136 |
+
<div className="viewer-background">
|
137 |
+
<div className="giant-orb-background">
|
138 |
+
<div className="giant-orb-container">
|
139 |
+
<div className="gradient-orb orb-1"></div>
|
140 |
+
<div className="gradient-orb orb-2"></div>
|
141 |
+
<div className="gradient-orb orb-3"></div>
|
142 |
+
</div>
|
143 |
+
</div>
|
144 |
+
</div>
|
145 |
+
|
146 |
+
<div className="viewer-glass-container">
|
147 |
+
{/* Professional Header */}
|
148 |
+
<motion.header
|
149 |
+
className="viewer-header"
|
150 |
+
initial={{ opacity: 0, y: -20 }}
|
151 |
+
animate={{ opacity: 1, y: 0 }}
|
152 |
+
transition={{ duration: 0.6 }}
|
153 |
+
>
|
154 |
+
<div className="header-left">
|
155 |
+
<motion.button
|
156 |
+
className="back-button"
|
157 |
+
onClick={handleBack}
|
158 |
+
whileHover={{ scale: 1.05 }}
|
159 |
+
whileTap={{ scale: 0.95 }}
|
160 |
+
>
|
161 |
+
<ArrowLeft size={18} />
|
162 |
+
Back
|
163 |
+
</motion.button>
|
164 |
+
|
165 |
+
<div className="document-info">
|
166 |
+
<h1 className="document-title">{fileName}</h1>
|
167 |
+
<span className="document-stats">
|
168 |
+
{text.length} chars • {text.split('\n').length} lines • {text.split(/\s+/).filter(w => w.length > 0).length} words
|
169 |
+
</span>
|
170 |
+
</div>
|
171 |
+
</div>
|
172 |
+
|
173 |
+
<div className="header-actions">
|
174 |
+
<div className="search-container">
|
175 |
+
<Search size={16} />
|
176 |
+
<input
|
177 |
+
type="text"
|
178 |
+
placeholder="Search in document..."
|
179 |
+
value={searchTerm}
|
180 |
+
onChange={(e) => setSearchTerm(e.target.value)}
|
181 |
+
className="search-input"
|
182 |
+
/>
|
183 |
+
</div>
|
184 |
+
|
185 |
+
<div className="font-controls">
|
186 |
+
<button onClick={() => adjustFontSize(-2)} className="font-btn">
|
187 |
+
<ZoomOut size={16} />
|
188 |
+
</button>
|
189 |
+
<span className="font-size">{fontSize}px</span>
|
190 |
+
<button onClick={() => adjustFontSize(2)} className="font-btn">
|
191 |
+
<ZoomIn size={16} />
|
192 |
+
</button>
|
193 |
+
</div>
|
194 |
+
|
195 |
+
<motion.button
|
196 |
+
className="action-btn"
|
197 |
+
onClick={handleCopy}
|
198 |
+
whileHover={{ scale: 1.05 }}
|
199 |
+
whileTap={{ scale: 0.95 }}
|
200 |
+
>
|
201 |
+
<AnimatePresence mode="wait">
|
202 |
+
{copied ? (
|
203 |
+
<motion.div key="copied" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
204 |
+
<Check size={16} />
|
205 |
+
</motion.div>
|
206 |
+
) : (
|
207 |
+
<motion.div key="copy" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
208 |
+
<Copy size={16} />
|
209 |
+
</motion.div>
|
210 |
+
)}
|
211 |
+
</AnimatePresence>
|
212 |
+
{copied ? 'Copied!' : 'Copy'}
|
213 |
+
</motion.button>
|
214 |
+
</div>
|
215 |
+
</motion.header>
|
216 |
+
{/* Format Selector */}
|
217 |
+
<motion.div
|
218 |
+
className="format-selector"
|
219 |
+
initial={{ opacity: 0, y: 20 }}
|
220 |
+
animate={{ opacity: 1, y: 0 }}
|
221 |
+
transition={{ duration: 0.6, delay: 0.1 }}
|
222 |
+
>
|
223 |
+
<div className="format-tabs">
|
224 |
+
{viewModes.map((mode, index) => (
|
225 |
+
<motion.button
|
226 |
+
key={mode.id}
|
227 |
+
className={`format-tab ${viewMode === mode.id ? 'active' : ''}`}
|
228 |
+
onClick={() => setViewMode(mode.id)}
|
229 |
+
whileHover={{ scale: 1.02 }}
|
230 |
+
whileTap={{ scale: 0.98 }}
|
231 |
+
initial={{ opacity: 0, x: -20 }}
|
232 |
+
animate={{ opacity: 1, x: 0 }}
|
233 |
+
transition={{ delay: index * 0.1 }}
|
234 |
+
>
|
235 |
+
{mode.icon}
|
236 |
+
<span className="tab-label">{mode.label}</span>
|
237 |
+
<span className="tab-ext">{mode.ext}</span>
|
238 |
+
|
239 |
+
{viewMode === mode.id && (
|
240 |
+
<motion.div
|
241 |
+
className="tab-indicator"
|
242 |
+
layoutId="viewModeIndicator"
|
243 |
+
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
244 |
+
/>
|
245 |
+
)}
|
246 |
+
</motion.button>
|
247 |
+
))}
|
248 |
+
</div>
|
249 |
+
</motion.div>
|
250 |
+
|
251 |
+
{/* Professional Reader */}
|
252 |
+
<motion.main
|
253 |
+
className="reader-main"
|
254 |
+
initial={{ opacity: 0, y: 20 }}
|
255 |
+
animate={{ opacity: 1, y: 0 }}
|
256 |
+
transition={{ duration: 0.6, delay: 0.2 }}
|
257 |
+
>
|
258 |
+
<div className="reader-panel">
|
259 |
+
<AnimatePresence mode="wait">
|
260 |
+
<motion.div
|
261 |
+
key={viewMode}
|
262 |
+
initial={{ opacity: 0, x: 20 }}
|
263 |
+
animate={{ opacity: 1, x: 0 }}
|
264 |
+
exit={{ opacity: 0, x: -20 }}
|
265 |
+
transition={{ duration: 0.3 }}
|
266 |
+
className="content-wrapper"
|
267 |
+
>
|
268 |
+
{renderContent()}
|
269 |
+
</motion.div>
|
270 |
+
</AnimatePresence>
|
271 |
+
</div>
|
272 |
+
</motion.main>
|
273 |
+
</div>
|
274 |
+
<style jsx>{`
|
275 |
+
.text-viewer-app {
|
276 |
+
min-height: 100vh;
|
277 |
+
background: #0f0f23;
|
278 |
+
color: #ffffff;
|
279 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif;
|
280 |
+
position: relative;
|
281 |
+
overflow-x: hidden;
|
282 |
+
}
|
283 |
+
|
284 |
+
.viewer-background {
|
285 |
+
position: fixed;
|
286 |
+
top: 0;
|
287 |
+
left: 0;
|
288 |
+
width: 100%;
|
289 |
+
height: 100%;
|
290 |
+
pointer-events: none;
|
291 |
+
z-index: -1;
|
292 |
+
}
|
293 |
+
|
294 |
+
.giant-orb-background {
|
295 |
+
position: fixed;
|
296 |
+
top: 0;
|
297 |
+
left: 0;
|
298 |
+
width: 100%;
|
299 |
+
height: 100%;
|
300 |
+
pointer-events: none;
|
301 |
+
z-index: -10;
|
302 |
+
display: flex;
|
303 |
+
align-items: center;
|
304 |
+
justify-content: center;
|
305 |
+
}
|
306 |
+
|
307 |
+
.giant-orb-container {
|
308 |
+
width: 100vmin;
|
309 |
+
height: 100vmin;
|
310 |
+
position: relative;
|
311 |
+
pointer-events: none;
|
312 |
+
transform: translateY(-5vh);
|
313 |
+
}
|
314 |
+
|
315 |
+
.gradient-orb {
|
316 |
+
position: absolute;
|
317 |
+
border-radius: 50%;
|
318 |
+
filter: blur(100px);
|
319 |
+
opacity: 0.3;
|
320 |
+
}
|
321 |
+
|
322 |
+
.orb-1 {
|
323 |
+
top: 10%;
|
324 |
+
right: 20%;
|
325 |
+
width: 300px;
|
326 |
+
height: 300px;
|
327 |
+
background: linear-gradient(45deg, #4facfe, #8b5cf6);
|
328 |
+
animation: float1 20s ease-in-out infinite;
|
329 |
+
}
|
330 |
+
|
331 |
+
.orb-2 {
|
332 |
+
bottom: 20%;
|
333 |
+
left: 15%;
|
334 |
+
width: 250px;
|
335 |
+
height: 250px;
|
336 |
+
background: linear-gradient(45deg, #f093fb, #f5576c);
|
337 |
+
animation: float2 25s ease-in-out infinite;
|
338 |
+
}
|
339 |
+
|
340 |
+
.orb-3 {
|
341 |
+
top: 50%;
|
342 |
+
left: 50%;
|
343 |
+
width: 200px;
|
344 |
+
height: 200px;
|
345 |
+
background: linear-gradient(45deg, #06b6d4, #10b981);
|
346 |
+
animation: float3 30s ease-in-out infinite;
|
347 |
+
}
|
348 |
+
|
349 |
+
@keyframes float1 {
|
350 |
+
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
351 |
+
25% { transform: translate(-20px, -30px) rotate(90deg); }
|
352 |
+
50% { transform: translate(10px, -20px) rotate(180deg); }
|
353 |
+
75% { transform: translate(-10px, 10px) rotate(270deg); }
|
354 |
+
}
|
355 |
+
|
356 |
+
@keyframes float2 {
|
357 |
+
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
358 |
+
33% { transform: translate(30px, -20px) rotate(120deg); }
|
359 |
+
66% { transform: translate(-20px, -40px) rotate(240deg); }
|
360 |
+
}
|
361 |
+
|
362 |
+
@keyframes float3 {
|
363 |
+
0%, 100% { transform: translate(-50%, -50%) rotate(0deg); }
|
364 |
+
20% { transform: translate(-65%, -35%) rotate(72deg); }
|
365 |
+
40% { transform: translate(-35%, -35%) rotate(144deg); }
|
366 |
+
60% { transform: translate(-35%, -65%) rotate(216deg); }
|
367 |
+
80% { transform: translate(-65%, -65%) rotate(288deg); }
|
368 |
+
}
|
369 |
+
.viewer-glass-container {
|
370 |
+
backdrop-filter: blur(25px);
|
371 |
+
-webkit-backdrop-filter: blur(25px);
|
372 |
+
background: rgba(0, 0, 0, 0.1);
|
373 |
+
border: none;
|
374 |
+
border-radius: 16px;
|
375 |
+
margin: 12px;
|
376 |
+
min-height: calc(100vh - 24px);
|
377 |
+
overflow: hidden;
|
378 |
+
position: relative;
|
379 |
+
z-index: 10;
|
380 |
+
display: flex;
|
381 |
+
flex-direction: column;
|
382 |
+
}
|
383 |
+
|
384 |
+
.viewer-header {
|
385 |
+
display: flex;
|
386 |
+
justify-content: space-between;
|
387 |
+
align-items: center;
|
388 |
+
padding: 20px 24px;
|
389 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
390 |
+
flex-shrink: 0;
|
391 |
+
}
|
392 |
+
|
393 |
+
.header-left {
|
394 |
+
display: flex;
|
395 |
+
align-items: center;
|
396 |
+
gap: 20px;
|
397 |
+
}
|
398 |
+
|
399 |
+
.back-button {
|
400 |
+
background: rgba(255, 255, 255, 0.05);
|
401 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
402 |
+
border-radius: 8px;
|
403 |
+
padding: 8px 16px;
|
404 |
+
color: #ffffff;
|
405 |
+
cursor: pointer;
|
406 |
+
display: flex;
|
407 |
+
align-items: center;
|
408 |
+
gap: 6px;
|
409 |
+
font-size: 0.85rem;
|
410 |
+
transition: all 0.3s ease;
|
411 |
+
backdrop-filter: blur(10px);
|
412 |
+
}
|
413 |
+
|
414 |
+
.back-button:hover {
|
415 |
+
background: rgba(255, 255, 255, 0.08);
|
416 |
+
border-color: rgba(79, 172, 254, 0.3);
|
417 |
+
}
|
418 |
+
|
419 |
+
.document-info {
|
420 |
+
display: flex;
|
421 |
+
flex-direction: column;
|
422 |
+
gap: 4px;
|
423 |
+
}
|
424 |
+
|
425 |
+
.document-title {
|
426 |
+
font-size: 1.25rem;
|
427 |
+
font-weight: 600;
|
428 |
+
color: #ffffff;
|
429 |
+
margin: 0;
|
430 |
+
}
|
431 |
+
|
432 |
+
.document-stats {
|
433 |
+
font-size: 0.75rem;
|
434 |
+
color: rgba(255, 255, 255, 0.6);
|
435 |
+
}
|
436 |
+
|
437 |
+
.header-actions {
|
438 |
+
display: flex;
|
439 |
+
align-items: center;
|
440 |
+
gap: 16px;
|
441 |
+
}
|
442 |
+
|
443 |
+
.search-container {
|
444 |
+
display: flex;
|
445 |
+
align-items: center;
|
446 |
+
gap: 8px;
|
447 |
+
background: rgba(255, 255, 255, 0.05);
|
448 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
449 |
+
border-radius: 8px;
|
450 |
+
padding: 6px 12px;
|
451 |
+
backdrop-filter: blur(10px);
|
452 |
+
}
|
453 |
+
|
454 |
+
.search-input {
|
455 |
+
background: none;
|
456 |
+
border: none;
|
457 |
+
color: #ffffff;
|
458 |
+
font-size: 0.85rem;
|
459 |
+
outline: none;
|
460 |
+
width: 200px;
|
461 |
+
}
|
462 |
+
|
463 |
+
.search-input::placeholder {
|
464 |
+
color: rgba(255, 255, 255, 0.5);
|
465 |
+
}
|
466 |
+
|
467 |
+
.font-controls {
|
468 |
+
display: flex;
|
469 |
+
align-items: center;
|
470 |
+
gap: 8px;
|
471 |
+
background: rgba(255, 255, 255, 0.05);
|
472 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
473 |
+
border-radius: 8px;
|
474 |
+
padding: 4px 8px;
|
475 |
+
backdrop-filter: blur(10px);
|
476 |
+
}
|
477 |
+
|
478 |
+
.font-btn {
|
479 |
+
background: none;
|
480 |
+
border: none;
|
481 |
+
color: rgba(255, 255, 255, 0.7);
|
482 |
+
cursor: pointer;
|
483 |
+
padding: 4px;
|
484 |
+
border-radius: 4px;
|
485 |
+
transition: all 0.3s ease;
|
486 |
+
}
|
487 |
+
|
488 |
+
.font-btn:hover {
|
489 |
+
color: #ffffff;
|
490 |
+
background: rgba(255, 255, 255, 0.1);
|
491 |
+
}
|
492 |
+
|
493 |
+
.font-size {
|
494 |
+
font-size: 0.75rem;
|
495 |
+
color: rgba(255, 255, 255, 0.8);
|
496 |
+
min-width: 32px;
|
497 |
+
text-align: center;
|
498 |
+
}
|
499 |
+
|
500 |
+
.action-btn {
|
501 |
+
background: rgba(255, 255, 255, 0.05);
|
502 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
503 |
+
border-radius: 8px;
|
504 |
+
padding: 8px 16px;
|
505 |
+
color: #ffffff;
|
506 |
+
cursor: pointer;
|
507 |
+
display: flex;
|
508 |
+
align-items: center;
|
509 |
+
gap: 6px;
|
510 |
+
font-size: 0.85rem;
|
511 |
+
transition: all 0.3s ease;
|
512 |
+
backdrop-filter: blur(10px);
|
513 |
+
}
|
514 |
+
|
515 |
+
.action-btn:hover {
|
516 |
+
background: rgba(255, 255, 255, 0.08);
|
517 |
+
border-color: rgba(79, 172, 254, 0.3);
|
518 |
+
}
|
519 |
+
.format-selector {
|
520 |
+
display: flex;
|
521 |
+
justify-content: center;
|
522 |
+
padding: 16px 24px;
|
523 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
524 |
+
flex-shrink: 0;
|
525 |
+
}
|
526 |
+
|
527 |
+
.format-tabs {
|
528 |
+
display: flex;
|
529 |
+
background: rgba(0, 0, 0, 0.3);
|
530 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
531 |
+
border-radius: 12px;
|
532 |
+
padding: 4px;
|
533 |
+
backdrop-filter: blur(20px);
|
534 |
+
}
|
535 |
+
|
536 |
+
.format-tab {
|
537 |
+
background: none;
|
538 |
+
border: none;
|
539 |
+
padding: 12px 20px;
|
540 |
+
border-radius: 8px;
|
541 |
+
cursor: pointer;
|
542 |
+
display: flex;
|
543 |
+
align-items: center;
|
544 |
+
gap: 8px;
|
545 |
+
font-size: 0.85rem;
|
546 |
+
color: rgba(255, 255, 255, 0.7);
|
547 |
+
transition: all 0.3s ease;
|
548 |
+
position: relative;
|
549 |
+
min-width: 120px;
|
550 |
+
justify-content: center;
|
551 |
+
}
|
552 |
+
|
553 |
+
.format-tab:hover {
|
554 |
+
color: #ffffff;
|
555 |
+
background: rgba(255, 255, 255, 0.05);
|
556 |
+
}
|
557 |
+
|
558 |
+
.format-tab.active {
|
559 |
+
color: #ffffff;
|
560 |
+
background: rgba(255, 255, 255, 0.08);
|
561 |
+
}
|
562 |
+
|
563 |
+
.tab-label {
|
564 |
+
font-weight: 500;
|
565 |
+
}
|
566 |
+
|
567 |
+
.tab-ext {
|
568 |
+
font-size: 0.75rem;
|
569 |
+
color: rgba(255, 255, 255, 0.5);
|
570 |
+
font-weight: 400;
|
571 |
+
}
|
572 |
+
|
573 |
+
.tab-indicator {
|
574 |
+
position: absolute;
|
575 |
+
bottom: 0;
|
576 |
+
left: 0;
|
577 |
+
right: 0;
|
578 |
+
height: 2px;
|
579 |
+
background: linear-gradient(90deg, #4facfe, #8b5cf6);
|
580 |
+
border-radius: 1px;
|
581 |
+
}
|
582 |
+
|
583 |
+
.reader-main {
|
584 |
+
flex: 1;
|
585 |
+
overflow: hidden;
|
586 |
+
display: flex;
|
587 |
+
flex-direction: column;
|
588 |
+
}
|
589 |
+
|
590 |
+
.reader-panel {
|
591 |
+
flex: 1;
|
592 |
+
overflow: auto;
|
593 |
+
padding: 0;
|
594 |
+
background: rgba(255, 255, 255, 0.02);
|
595 |
+
margin: 16px 24px 24px;
|
596 |
+
border-radius: 12px;
|
597 |
+
backdrop-filter: blur(10px);
|
598 |
+
}
|
599 |
+
|
600 |
+
.content-wrapper {
|
601 |
+
height: 100%;
|
602 |
+
overflow: auto;
|
603 |
+
}
|
604 |
+
|
605 |
+
.reader-content {
|
606 |
+
padding: 32px;
|
607 |
+
max-width: 900px;
|
608 |
+
margin: 0 auto;
|
609 |
+
line-height: 1.8;
|
610 |
+
}
|
611 |
+
|
612 |
+
.text-reader {
|
613 |
+
background: none;
|
614 |
+
border: none;
|
615 |
+
color: #ffffff;
|
616 |
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
617 |
+
white-space: pre-wrap;
|
618 |
+
word-wrap: break-word;
|
619 |
+
width: 100%;
|
620 |
+
margin: 0;
|
621 |
+
padding: 0;
|
622 |
+
line-height: inherit;
|
623 |
+
} .ht
|
624 |
+
ml-reader {
|
625 |
+
color: #ffffff;
|
626 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif;
|
627 |
+
}
|
628 |
+
|
629 |
+
.html-reader h1,
|
630 |
+
.html-reader h2,
|
631 |
+
.html-reader h3,
|
632 |
+
.html-reader h4,
|
633 |
+
.html-reader h5,
|
634 |
+
.html-reader h6 {
|
635 |
+
color: #ffffff;
|
636 |
+
margin-bottom: 24px;
|
637 |
+
margin-top: 40px;
|
638 |
+
font-weight: 600;
|
639 |
+
line-height: 1.3;
|
640 |
+
}
|
641 |
+
|
642 |
+
.html-reader h1 {
|
643 |
+
font-size: 2.5rem;
|
644 |
+
background: linear-gradient(45deg, #4facfe, #8b5cf6);
|
645 |
+
-webkit-background-clip: text;
|
646 |
+
-webkit-text-fill-color: transparent;
|
647 |
+
background-clip: text;
|
648 |
+
border-bottom: 2px solid rgba(79, 172, 254, 0.3);
|
649 |
+
padding-bottom: 16px;
|
650 |
+
}
|
651 |
+
|
652 |
+
.html-reader h2 {
|
653 |
+
font-size: 2rem;
|
654 |
+
color: #4facfe;
|
655 |
+
border-left: 4px solid #4facfe;
|
656 |
+
padding-left: 16px;
|
657 |
+
}
|
658 |
+
|
659 |
+
.html-reader h3 {
|
660 |
+
font-size: 1.5rem;
|
661 |
+
color: #8b5cf6;
|
662 |
+
}
|
663 |
+
|
664 |
+
.html-reader p {
|
665 |
+
margin-bottom: 20px;
|
666 |
+
color: rgba(255, 255, 255, 0.9);
|
667 |
+
text-align: justify;
|
668 |
+
}
|
669 |
+
|
670 |
+
.html-reader strong,
|
671 |
+
.html-reader b {
|
672 |
+
color: #ffffff;
|
673 |
+
font-weight: 700;
|
674 |
+
text-shadow: 0 0 10px rgba(255, 255, 255, 0.3);
|
675 |
+
}
|
676 |
+
|
677 |
+
.html-reader em,
|
678 |
+
.html-reader i {
|
679 |
+
color: rgba(255, 255, 255, 0.95);
|
680 |
+
font-style: italic;
|
681 |
+
}
|
682 |
+
|
683 |
+
.html-reader ul,
|
684 |
+
.html-reader ol {
|
685 |
+
margin-bottom: 20px;
|
686 |
+
padding-left: 32px;
|
687 |
+
}
|
688 |
+
|
689 |
+
.html-reader li {
|
690 |
+
margin-bottom: 12px;
|
691 |
+
color: rgba(255, 255, 255, 0.9);
|
692 |
+
position: relative;
|
693 |
+
}
|
694 |
+
|
695 |
+
.html-reader ul li::marker {
|
696 |
+
color: #4facfe;
|
697 |
+
}
|
698 |
+
|
699 |
+
.html-reader ol li::marker {
|
700 |
+
color: #8b5cf6;
|
701 |
+
font-weight: 600;
|
702 |
+
}
|
703 |
+
|
704 |
+
.html-reader table {
|
705 |
+
width: 100%;
|
706 |
+
border-collapse: collapse;
|
707 |
+
margin: 32px 0;
|
708 |
+
background: rgba(0, 0, 0, 0.2);
|
709 |
+
border-radius: 12px;
|
710 |
+
overflow: hidden;
|
711 |
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
712 |
+
}
|
713 |
+
|
714 |
+
.html-reader th,
|
715 |
+
.html-reader td {
|
716 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
717 |
+
padding: 16px 20px;
|
718 |
+
text-align: left;
|
719 |
+
vertical-align: top;
|
720 |
+
}
|
721 |
+
|
722 |
+
.html-reader th {
|
723 |
+
background: linear-gradient(135deg, rgba(79, 172, 254, 0.2), rgba(139, 92, 246, 0.2));
|
724 |
+
font-weight: 700;
|
725 |
+
color: #ffffff;
|
726 |
+
text-transform: uppercase;
|
727 |
+
font-size: 0.85rem;
|
728 |
+
letter-spacing: 0.5px;
|
729 |
+
}
|
730 |
+
|
731 |
+
.html-reader td {
|
732 |
+
background: rgba(255, 255, 255, 0.02);
|
733 |
+
}
|
734 |
+
|
735 |
+
.html-reader tr:nth-child(even) td {
|
736 |
+
background: rgba(255, 255, 255, 0.04);
|
737 |
+
}
|
738 |
+
|
739 |
+
.html-reader tr:hover td {
|
740 |
+
background: rgba(79, 172, 254, 0.05);
|
741 |
+
}
|
742 |
+
|
743 |
+
.html-reader blockquote {
|
744 |
+
border-left: 4px solid #4facfe;
|
745 |
+
padding: 20px 24px;
|
746 |
+
margin: 24px 0;
|
747 |
+
background: rgba(79, 172, 254, 0.05);
|
748 |
+
border-radius: 0 8px 8px 0;
|
749 |
+
font-style: italic;
|
750 |
+
color: rgba(255, 255, 255, 0.9);
|
751 |
+
position: relative;
|
752 |
+
}
|
753 |
+
|
754 |
+
.html-reader blockquote::before {
|
755 |
+
content: '"';
|
756 |
+
font-size: 4rem;
|
757 |
+
color: rgba(79, 172, 254, 0.3);
|
758 |
+
position: absolute;
|
759 |
+
top: -10px;
|
760 |
+
left: 10px;
|
761 |
+
font-family: serif;
|
762 |
+
}
|
763 |
+
|
764 |
+
.html-reader code {
|
765 |
+
background: rgba(0, 0, 0, 0.4);
|
766 |
+
padding: 4px 8px;
|
767 |
+
border-radius: 6px;
|
768 |
+
font-family: 'SF Mono', Monaco, monospace;
|
769 |
+
font-size: 0.9em;
|
770 |
+
color: #4facfe;
|
771 |
+
border: 1px solid rgba(79, 172, 254, 0.2);
|
772 |
+
}
|
773 |
+
|
774 |
+
.html-reader pre {
|
775 |
+
background: rgba(0, 0, 0, 0.4);
|
776 |
+
padding: 24px;
|
777 |
+
border-radius: 12px;
|
778 |
+
overflow-x: auto;
|
779 |
+
margin: 24px 0;
|
780 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
781 |
+
position: relative;
|
782 |
+
}
|
783 |
+
|
784 |
+
.html-reader pre code {
|
785 |
+
background: none;
|
786 |
+
padding: 0;
|
787 |
+
border: none;
|
788 |
+
color: rgba(255, 255, 255, 0.9);
|
789 |
+
}
|
790 |
+
|
791 |
+
.html-reader a {
|
792 |
+
color: #4facfe;
|
793 |
+
text-decoration: none;
|
794 |
+
border-bottom: 1px solid rgba(79, 172, 254, 0.3);
|
795 |
+
transition: all 0.3s ease;
|
796 |
+
}
|
797 |
+
|
798 |
+
.html-reader a:hover {
|
799 |
+
color: #8b5cf6;
|
800 |
+
border-bottom-color: rgba(139, 92, 246, 0.5);
|
801 |
+
text-shadow: 0 0 8px rgba(139, 92, 246, 0.3);
|
802 |
+
}
|
803 |
+
|
804 |
+
.html-reader hr {
|
805 |
+
border: none;
|
806 |
+
height: 2px;
|
807 |
+
background: linear-gradient(90deg, transparent, rgba(79, 172, 254, 0.5), transparent);
|
808 |
+
margin: 40px 0;
|
809 |
+
}
|
src/components/TrueFocus.js
ADDED
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useEffect, useRef, useState } from "react";
|
2 |
+
import { motion } from "framer-motion";
|
3 |
+
|
4 |
+
const TrueFocus = ({
|
5 |
+
sentence = "Luna OCR",
|
6 |
+
manualMode = false,
|
7 |
+
blurAmount = 0,
|
8 |
+
borderColor = "#8b5cf6",
|
9 |
+
glowColor = "rgba(139, 92, 246, 0.6)",
|
10 |
+
animationDuration = 0.8,
|
11 |
+
pauseBetweenAnimations = 1.5,
|
12 |
+
}) => {
|
13 |
+
const words = sentence.split(" ");
|
14 |
+
const [currentIndex, setCurrentIndex] = useState(0);
|
15 |
+
const [lastActiveIndex, setLastActiveIndex] = useState(null);
|
16 |
+
const containerRef = useRef(null);
|
17 |
+
const wordRefs = useRef([]);
|
18 |
+
const [focusRect, setFocusRect] = useState({ x: 0, y: 0, width: 0, height: 0 });
|
19 |
+
|
20 |
+
useEffect(() => {
|
21 |
+
if (!manualMode) {
|
22 |
+
const interval = setInterval(() => {
|
23 |
+
setCurrentIndex((prev) => (prev + 1) % words.length);
|
24 |
+
}, (animationDuration + pauseBetweenAnimations) * 1000);
|
25 |
+
|
26 |
+
return () => clearInterval(interval);
|
27 |
+
}
|
28 |
+
}, [manualMode, animationDuration, pauseBetweenAnimations, words.length]);
|
29 |
+
|
30 |
+
useEffect(() => {
|
31 |
+
if (currentIndex === null || currentIndex === -1) return;
|
32 |
+
|
33 |
+
if (!wordRefs.current[currentIndex] || !containerRef.current) return;
|
34 |
+
|
35 |
+
const parentRect = containerRef.current.getBoundingClientRect();
|
36 |
+
const activeRect = wordRefs.current[currentIndex].getBoundingClientRect();
|
37 |
+
|
38 |
+
setFocusRect({
|
39 |
+
x: activeRect.left - parentRect.left,
|
40 |
+
y: activeRect.top - parentRect.top,
|
41 |
+
width: activeRect.width,
|
42 |
+
height: activeRect.height,
|
43 |
+
});
|
44 |
+
}, [currentIndex, words.length]);
|
45 |
+
|
46 |
+
const handleMouseEnter = (index) => {
|
47 |
+
if (manualMode) {
|
48 |
+
setLastActiveIndex(index);
|
49 |
+
setCurrentIndex(index);
|
50 |
+
}
|
51 |
+
};
|
52 |
+
|
53 |
+
const handleMouseLeave = () => {
|
54 |
+
if (manualMode) {
|
55 |
+
setCurrentIndex(lastActiveIndex);
|
56 |
+
}
|
57 |
+
};
|
58 |
+
|
59 |
+
return (
|
60 |
+
<div className="focus-container" ref={containerRef}>
|
61 |
+
{words.map((word, index) => {
|
62 |
+
const isActive = index === currentIndex;
|
63 |
+
return (
|
64 |
+
<span
|
65 |
+
key={index}
|
66 |
+
ref={(el) => (wordRefs.current[index] = el)}
|
67 |
+
className={`focus-word ${manualMode ? "manual" : ""} ${isActive && !manualMode ? "active" : ""}`}
|
68 |
+
style={{
|
69 |
+
filter: manualMode
|
70 |
+
? isActive
|
71 |
+
? `blur(0px)`
|
72 |
+
: `blur(${blurAmount}px)`
|
73 |
+
: isActive
|
74 |
+
? `blur(0px)`
|
75 |
+
: `blur(${blurAmount}px)`,
|
76 |
+
"--border-color": borderColor,
|
77 |
+
"--glow-color": glowColor,
|
78 |
+
transition: `filter ${animationDuration}s ease`,
|
79 |
+
}}
|
80 |
+
onMouseEnter={() => handleMouseEnter(index)}
|
81 |
+
onMouseLeave={handleMouseLeave}
|
82 |
+
>
|
83 |
+
{word}
|
84 |
+
</span>
|
85 |
+
);
|
86 |
+
})}
|
87 |
+
|
88 |
+
<motion.div
|
89 |
+
className="focus-frame"
|
90 |
+
animate={{
|
91 |
+
x: focusRect.x,
|
92 |
+
y: focusRect.y,
|
93 |
+
width: focusRect.width,
|
94 |
+
height: focusRect.height,
|
95 |
+
opacity: currentIndex >= 0 ? 1 : 0,
|
96 |
+
}}
|
97 |
+
transition={{
|
98 |
+
duration: animationDuration,
|
99 |
+
}}
|
100 |
+
style={{
|
101 |
+
"--border-color": borderColor,
|
102 |
+
"--glow-color": glowColor,
|
103 |
+
}}
|
104 |
+
>
|
105 |
+
<span className="corner top-left"></span>
|
106 |
+
<span className="corner top-right"></span>
|
107 |
+
<span className="corner bottom-left"></span>
|
108 |
+
<span className="corner bottom-right"></span>
|
109 |
+
</motion.div>
|
110 |
+
</div>
|
111 |
+
);
|
112 |
+
};
|
113 |
+
|
114 |
+
export default TrueFocus;
|
src/index.css
ADDED
@@ -0,0 +1,2241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
2 |
+
|
3 |
+
* {
|
4 |
+
margin: 0;
|
5 |
+
padding: 0;
|
6 |
+
box-sizing: border-box;
|
7 |
+
}
|
8 |
+
|
9 |
+
:root {
|
10 |
+
--bg-primary: #000000;
|
11 |
+
--bg-secondary: #111111;
|
12 |
+
--glass-bg: rgba(255, 255, 255, 0.08);
|
13 |
+
--glass-border: transparent;
|
14 |
+
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
15 |
+
--text-primary: #ffffff;
|
16 |
+
--text-secondary: rgba(255, 255, 255, 0.8);
|
17 |
+
--text-muted: rgba(255, 255, 255, 0.6);
|
18 |
+
--accent-primary: #4facfe;
|
19 |
+
--accent-secondary: #8b5cf6;
|
20 |
+
--accent-tertiary: #06b6d4;
|
21 |
+
--gradient-primary: linear-gradient(135deg, #4facfe 0%, #8b5cf6 100%);
|
22 |
+
--gradient-secondary: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
23 |
+
--gradient-tertiary: linear-gradient(135deg, #06b6d4 0%, #4facfe 100%);
|
24 |
+
--gradient-iridescent: linear-gradient(45deg, #4facfe, #8b5cf6, #06b6d4, #10b981, #f59e0b, #ef4444);
|
25 |
+
}
|
26 |
+
|
27 |
+
body {
|
28 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif;
|
29 |
+
background: var(--bg-primary);
|
30 |
+
color: var(--text-primary);
|
31 |
+
overflow-x: hidden;
|
32 |
+
overflow-y: auto;
|
33 |
+
min-height: 100vh;
|
34 |
+
}
|
35 |
+
|
36 |
+
/* Universal fix - force same colors in all browsers */
|
37 |
+
body {
|
38 |
+
background: #0f0f23 !important;
|
39 |
+
}
|
40 |
+
|
41 |
+
.app::before {
|
42 |
+
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%) !important;
|
43 |
+
}
|
44 |
+
|
45 |
+
.glass-container {
|
46 |
+
background: rgba(0, 0, 0, 0.3) !important;
|
47 |
+
backdrop-filter: none !important;
|
48 |
+
-webkit-backdrop-filter: none !important;
|
49 |
+
}
|
50 |
+
|
51 |
+
.glass-panel {
|
52 |
+
background: rgba(100, 100, 100, 0.1) !important;
|
53 |
+
backdrop-filter: blur(25px) !important;
|
54 |
+
-webkit-backdrop-filter: blur(25px) !important;
|
55 |
+
}
|
56 |
+
|
57 |
+
.app {
|
58 |
+
min-height: 100vh;
|
59 |
+
display: flex;
|
60 |
+
flex-direction: column;
|
61 |
+
position: relative;
|
62 |
+
overflow-y: auto;
|
63 |
+
}
|
64 |
+
|
65 |
+
.app::before {
|
66 |
+
content: '';
|
67 |
+
position: fixed;
|
68 |
+
top: 0;
|
69 |
+
left: 0;
|
70 |
+
right: 0;
|
71 |
+
bottom: 0;
|
72 |
+
background: var(--gradient-iridescent);
|
73 |
+
opacity: 0.03;
|
74 |
+
pointer-events: none;
|
75 |
+
z-index: -1;
|
76 |
+
}
|
77 |
+
|
78 |
+
.glass-container {
|
79 |
+
backdrop-filter: blur(25px);
|
80 |
+
-webkit-backdrop-filter: blur(25px);
|
81 |
+
background: rgba(0, 0, 0, 0.1);
|
82 |
+
border: none;
|
83 |
+
border-radius: 16px;
|
84 |
+
box-shadow: none;
|
85 |
+
margin: 12px;
|
86 |
+
min-height: auto;
|
87 |
+
overflow: visible;
|
88 |
+
position: relative;
|
89 |
+
z-index: 10;
|
90 |
+
flex: 1;
|
91 |
+
}
|
92 |
+
|
93 |
+
.glass-container::before {
|
94 |
+
content: '';
|
95 |
+
position: absolute;
|
96 |
+
top: -1px;
|
97 |
+
left: -1px;
|
98 |
+
right: -1px;
|
99 |
+
bottom: -1px;
|
100 |
+
background: linear-gradient(45deg,
|
101 |
+
rgba(138, 43, 226, 0.2) 0%,
|
102 |
+
rgba(59, 130, 246, 0.15) 50%,
|
103 |
+
rgba(138, 43, 226, 0.2) 100%);
|
104 |
+
border-radius: 24px;
|
105 |
+
z-index: -1;
|
106 |
+
}
|
107 |
+
|
108 |
+
.app-header {
|
109 |
+
padding: 20px 24px;
|
110 |
+
display: flex;
|
111 |
+
justify-content: space-between;
|
112 |
+
align-items: center;
|
113 |
+
border-bottom: 1px solid var(--glass-border);
|
114 |
+
}
|
115 |
+
|
116 |
+
.logo {
|
117 |
+
display: flex;
|
118 |
+
align-items: center;
|
119 |
+
gap: 16px;
|
120 |
+
}
|
121 |
+
|
122 |
+
.logo-icon {
|
123 |
+
font-size: 2.5rem;
|
124 |
+
filter: drop-shadow(0 0 20px rgba(255, 193, 7, 0.5));
|
125 |
+
}
|
126 |
+
|
127 |
+
.logo h1 {
|
128 |
+
font-size: 2rem;
|
129 |
+
font-weight: 700;
|
130 |
+
filter: drop-shadow(0 0 10px rgba(255, 107, 53, 0.3));
|
131 |
+
}
|
132 |
+
|
133 |
+
.logo-subtitle {
|
134 |
+
font-size: 0.875rem;
|
135 |
+
color: var(--text-muted);
|
136 |
+
font-weight: 400;
|
137 |
+
}
|
138 |
+
|
139 |
+
@keyframes gradient-shift {
|
140 |
+
|
141 |
+
0%,
|
142 |
+
100% {
|
143 |
+
background-position: 0% 50%;
|
144 |
+
}
|
145 |
+
|
146 |
+
50% {
|
147 |
+
background-position: 100% 50%;
|
148 |
+
}
|
149 |
+
}
|
150 |
+
|
151 |
+
/* Loading Screen Styles */
|
152 |
+
.loading-container {
|
153 |
+
position: fixed !important;
|
154 |
+
top: 0 !important;
|
155 |
+
left: 0 !important;
|
156 |
+
width: 100vw !important;
|
157 |
+
height: 100vh !important;
|
158 |
+
background: #141414 !important;
|
159 |
+
z-index: 99999 !important;
|
160 |
+
display: flex !important;
|
161 |
+
align-items: center;
|
162 |
+
justify-content: center;
|
163 |
+
opacity: 1 !important;
|
164 |
+
}
|
165 |
+
|
166 |
+
@keyframes loading-fade-in {
|
167 |
+
0% {
|
168 |
+
opacity: 0;
|
169 |
+
}
|
170 |
+
100% {
|
171 |
+
opacity: 1;
|
172 |
+
}
|
173 |
+
}
|
174 |
+
|
175 |
+
.a-hole {
|
176 |
+
position: absolute;
|
177 |
+
top: 0;
|
178 |
+
left: 0;
|
179 |
+
margin: 0;
|
180 |
+
padding: 0;
|
181 |
+
width: 100%;
|
182 |
+
height: 100%;
|
183 |
+
overflow: hidden;
|
184 |
+
opacity: 0;
|
185 |
+
animation: hole-appear 0.2s ease-out 0.5s forwards;
|
186 |
+
}
|
187 |
+
|
188 |
+
@keyframes hole-appear {
|
189 |
+
0% {
|
190 |
+
opacity: 0;
|
191 |
+
}
|
192 |
+
100% {
|
193 |
+
opacity: 1;
|
194 |
+
}
|
195 |
+
}
|
196 |
+
|
197 |
+
.a-hole::before {
|
198 |
+
position: absolute;
|
199 |
+
top: 50%;
|
200 |
+
left: 50%;
|
201 |
+
z-index: 2;
|
202 |
+
display: block;
|
203 |
+
width: 150%;
|
204 |
+
height: 140%;
|
205 |
+
background: radial-gradient(ellipse at 50% 55%, transparent 10%, black 50%);
|
206 |
+
transform: translate3d(-50%, -50%, 0);
|
207 |
+
content: "";
|
208 |
+
}
|
209 |
+
|
210 |
+
.a-hole::after {
|
211 |
+
position: absolute;
|
212 |
+
top: 50%;
|
213 |
+
left: 50%;
|
214 |
+
z-index: 5;
|
215 |
+
display: block;
|
216 |
+
width: 100%;
|
217 |
+
height: 100%;
|
218 |
+
background: radial-gradient(ellipse at 50% 75%, #a900ff 20%, transparent 75%);
|
219 |
+
mix-blend-mode: overlay;
|
220 |
+
transform: translate3d(-50%, -50%, 0);
|
221 |
+
content: "";
|
222 |
+
}
|
223 |
+
|
224 |
+
|
225 |
+
|
226 |
+
@keyframes aura-glow {
|
227 |
+
0% {
|
228 |
+
background-position: 0 100%;
|
229 |
+
}
|
230 |
+
100% {
|
231 |
+
background-position: 0 300%;
|
232 |
+
}
|
233 |
+
}
|
234 |
+
|
235 |
+
.aura {
|
236 |
+
position: absolute;
|
237 |
+
top: -71.5%;
|
238 |
+
left: 50%;
|
239 |
+
z-index: 3;
|
240 |
+
width: 30%;
|
241 |
+
height: 140%;
|
242 |
+
background: linear-gradient(
|
243 |
+
20deg,
|
244 |
+
#00f8f1,
|
245 |
+
#ffbd1e20 16.5%,
|
246 |
+
#fe848f 33%,
|
247 |
+
#fe848f20 49.5%,
|
248 |
+
#00f8f1 66%,
|
249 |
+
#00f8f160 85.5%,
|
250 |
+
#ffbd1e 100%
|
251 |
+
)
|
252 |
+
0 100% / 100% 200%;
|
253 |
+
border-radius: 0 0 100% 100%;
|
254 |
+
filter: blur(50px);
|
255 |
+
mix-blend-mode: plus-lighter;
|
256 |
+
opacity: 0;
|
257 |
+
transform: translate3d(-50%, 0, 0);
|
258 |
+
animation: aura-appear 0.2s ease-out 0.5s forwards, aura-glow 5s infinite linear 0.7s;
|
259 |
+
}
|
260 |
+
|
261 |
+
@keyframes aura-appear {
|
262 |
+
0% {
|
263 |
+
opacity: 0;
|
264 |
+
}
|
265 |
+
100% {
|
266 |
+
opacity: 0.75;
|
267 |
+
}
|
268 |
+
}
|
269 |
+
|
270 |
+
.overlay {
|
271 |
+
position: absolute;
|
272 |
+
top: 0;
|
273 |
+
left: 0;
|
274 |
+
z-index: 10;
|
275 |
+
width: 100%;
|
276 |
+
height: 100%;
|
277 |
+
background: transparent;
|
278 |
+
opacity: 0;
|
279 |
+
}
|
280 |
+
|
281 |
+
.a-hole canvas {
|
282 |
+
display: block;
|
283 |
+
width: 100%;
|
284 |
+
height: 100%;
|
285 |
+
}
|
286 |
+
|
287 |
+
/* TrueFocus Animation Styles */
|
288 |
+
.focus-container {
|
289 |
+
position: relative;
|
290 |
+
display: flex;
|
291 |
+
gap: 0.5em;
|
292 |
+
justify-content: center;
|
293 |
+
align-items: center;
|
294 |
+
flex-wrap: wrap;
|
295 |
+
}
|
296 |
+
|
297 |
+
.focus-word {
|
298 |
+
position: relative;
|
299 |
+
font-size: 1.75rem;
|
300 |
+
font-weight: 300;
|
301 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', 'SF Pro Display', system-ui, sans-serif;
|
302 |
+
cursor: pointer;
|
303 |
+
color: #ffffff;
|
304 |
+
letter-spacing: 0.05em;
|
305 |
+
text-shadow: 0 0 20px rgba(255, 255, 255, 0.3);
|
306 |
+
transition: filter 0.3s ease, color 0.3s ease;
|
307 |
+
}
|
308 |
+
|
309 |
+
.focus-word.active {
|
310 |
+
filter: blur(0);
|
311 |
+
}
|
312 |
+
|
313 |
+
.focus-frame {
|
314 |
+
position: absolute;
|
315 |
+
top: 0;
|
316 |
+
left: 0;
|
317 |
+
pointer-events: none;
|
318 |
+
box-sizing: content-box;
|
319 |
+
border: none;
|
320 |
+
}
|
321 |
+
|
322 |
+
.corner {
|
323 |
+
position: absolute;
|
324 |
+
width: 0.75rem;
|
325 |
+
height: 0.75rem;
|
326 |
+
border: 2.5px solid var(--border-color, #ffffff);
|
327 |
+
filter: drop-shadow(0px 0px 8px var(--glow-color, rgba(255, 255, 255, 0.8)));
|
328 |
+
border-radius: 2px;
|
329 |
+
transition: none;
|
330 |
+
}
|
331 |
+
|
332 |
+
.top-left {
|
333 |
+
top: -8px;
|
334 |
+
left: -8px;
|
335 |
+
border-right: none;
|
336 |
+
border-bottom: none;
|
337 |
+
}
|
338 |
+
|
339 |
+
.top-right {
|
340 |
+
top: -8px;
|
341 |
+
right: -8px;
|
342 |
+
border-left: none;
|
343 |
+
border-bottom: none;
|
344 |
+
}
|
345 |
+
|
346 |
+
.bottom-left {
|
347 |
+
bottom: -8px;
|
348 |
+
left: -8px;
|
349 |
+
border-right: none;
|
350 |
+
border-top: none;
|
351 |
+
}
|
352 |
+
|
353 |
+
.bottom-right {
|
354 |
+
bottom: -8px;
|
355 |
+
right: -8px;
|
356 |
+
border-left: none;
|
357 |
+
border-top: none;
|
358 |
+
}
|
359 |
+
|
360 |
+
|
361 |
+
|
362 |
+
|
363 |
+
|
364 |
+
.api-key-input {
|
365 |
+
position: relative;
|
366 |
+
}
|
367 |
+
|
368 |
+
/* API Key Container */
|
369 |
+
.api-key-container {
|
370 |
+
position: relative;
|
371 |
+
width: 300px;
|
372 |
+
}
|
373 |
+
|
374 |
+
.glass-input {
|
375 |
+
background: rgba(255, 255, 255, 0.05);
|
376 |
+
border: 1px solid var(--glass-border);
|
377 |
+
border-radius: 12px;
|
378 |
+
padding: 12px 16px;
|
379 |
+
padding-right: 45px; /* Make room for clear button */
|
380 |
+
color: var(--text-primary);
|
381 |
+
font-size: 0.875rem;
|
382 |
+
width: 100%;
|
383 |
+
backdrop-filter: blur(10px);
|
384 |
+
transition: all 0.3s ease;
|
385 |
+
}
|
386 |
+
|
387 |
+
.glass-input:focus {
|
388 |
+
outline: none;
|
389 |
+
border-color: var(--accent-primary);
|
390 |
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
391 |
+
background: rgba(255, 255, 255, 0.08);
|
392 |
+
}
|
393 |
+
|
394 |
+
.glass-input::placeholder {
|
395 |
+
color: var(--text-muted);
|
396 |
+
}
|
397 |
+
|
398 |
+
/* Clear API Key Button */
|
399 |
+
.clear-api-key-btn {
|
400 |
+
position: absolute;
|
401 |
+
right: 8px;
|
402 |
+
top: 50%;
|
403 |
+
transform: translateY(-50%);
|
404 |
+
background: rgba(255, 255, 255, 0.1);
|
405 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
406 |
+
border-radius: 50%;
|
407 |
+
width: 24px;
|
408 |
+
height: 24px;
|
409 |
+
color: rgba(255, 255, 255, 0.7);
|
410 |
+
font-size: 16px;
|
411 |
+
font-weight: bold;
|
412 |
+
cursor: pointer;
|
413 |
+
display: flex;
|
414 |
+
align-items: center;
|
415 |
+
justify-content: center;
|
416 |
+
transition: all 0.3s ease;
|
417 |
+
backdrop-filter: blur(10px);
|
418 |
+
}
|
419 |
+
|
420 |
+
.clear-api-key-btn:hover {
|
421 |
+
background: rgba(255, 255, 255, 0.2);
|
422 |
+
color: #ff6b6b;
|
423 |
+
border-color: rgba(255, 107, 107, 0.3);
|
424 |
+
transform: translateY(-50%) scale(1.1);
|
425 |
+
}
|
426 |
+
|
427 |
+
/* Server API Key Notice */
|
428 |
+
.server-api-notice {
|
429 |
+
background: rgba(34, 197, 94, 0.1);
|
430 |
+
border: 1px solid rgba(34, 197, 94, 0.3);
|
431 |
+
border-radius: 12px;
|
432 |
+
padding: 12px 16px;
|
433 |
+
margin-bottom: 20px;
|
434 |
+
text-align: center;
|
435 |
+
}
|
436 |
+
|
437 |
+
.server-api-notice p {
|
438 |
+
color: rgba(34, 197, 94, 0.9);
|
439 |
+
margin: 0;
|
440 |
+
font-size: 0.875rem;
|
441 |
+
font-weight: 500;
|
442 |
+
}
|
443 |
+
|
444 |
+
/* Processing Progress Overlay */
|
445 |
+
.processing-progress-overlay {
|
446 |
+
position: fixed;
|
447 |
+
top: 0;
|
448 |
+
left: 0;
|
449 |
+
right: 0;
|
450 |
+
bottom: 0;
|
451 |
+
background: rgba(0, 0, 0, 0.8);
|
452 |
+
backdrop-filter: blur(10px);
|
453 |
+
display: flex;
|
454 |
+
align-items: center;
|
455 |
+
justify-content: center;
|
456 |
+
z-index: 9999;
|
457 |
+
}
|
458 |
+
|
459 |
+
.processing-progress-container {
|
460 |
+
background: rgba(255, 255, 255, 0.1);
|
461 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
462 |
+
border-radius: 20px;
|
463 |
+
padding: 40px;
|
464 |
+
min-width: 400px;
|
465 |
+
max-width: 500px;
|
466 |
+
backdrop-filter: blur(20px);
|
467 |
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
468 |
+
}
|
469 |
+
|
470 |
+
.progress-content {
|
471 |
+
text-align: center;
|
472 |
+
}
|
473 |
+
|
474 |
+
.progress-header h3 {
|
475 |
+
color: var(--text-primary);
|
476 |
+
font-size: 1.5rem;
|
477 |
+
margin-bottom: 10px;
|
478 |
+
font-weight: 600;
|
479 |
+
}
|
480 |
+
|
481 |
+
.progress-file {
|
482 |
+
color: var(--text-secondary);
|
483 |
+
font-size: 0.9rem;
|
484 |
+
margin-bottom: 30px;
|
485 |
+
padding: 8px 16px;
|
486 |
+
background: rgba(255, 255, 255, 0.05);
|
487 |
+
border-radius: 8px;
|
488 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
489 |
+
}
|
490 |
+
|
491 |
+
.progress-bar-container {
|
492 |
+
display: flex;
|
493 |
+
align-items: center;
|
494 |
+
gap: 15px;
|
495 |
+
margin-bottom: 20px;
|
496 |
+
}
|
497 |
+
|
498 |
+
.progress-bar {
|
499 |
+
flex: 1;
|
500 |
+
height: 8px;
|
501 |
+
background: rgba(255, 255, 255, 0.1);
|
502 |
+
border-radius: 4px;
|
503 |
+
overflow: hidden;
|
504 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
505 |
+
}
|
506 |
+
|
507 |
+
.progress-fill {
|
508 |
+
height: 100%;
|
509 |
+
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
|
510 |
+
border-radius: 4px;
|
511 |
+
box-shadow: 0 0 10px rgba(79, 172, 254, 0.5);
|
512 |
+
}
|
513 |
+
|
514 |
+
.progress-percentage {
|
515 |
+
color: var(--text-primary);
|
516 |
+
font-weight: 600;
|
517 |
+
font-size: 1.1rem;
|
518 |
+
min-width: 45px;
|
519 |
+
}
|
520 |
+
|
521 |
+
.progress-status {
|
522 |
+
min-height: 24px;
|
523 |
+
margin-bottom: 20px;
|
524 |
+
}
|
525 |
+
|
526 |
+
.status-text {
|
527 |
+
color: var(--text-secondary);
|
528 |
+
font-size: 0.9rem;
|
529 |
+
padding: 6px 12px;
|
530 |
+
background: rgba(255, 255, 255, 0.05);
|
531 |
+
border-radius: 6px;
|
532 |
+
display: inline-block;
|
533 |
+
}
|
534 |
+
|
535 |
+
.progress-details {
|
536 |
+
margin-bottom: 25px;
|
537 |
+
}
|
538 |
+
|
539 |
+
.progress-counter {
|
540 |
+
display: flex;
|
541 |
+
align-items: center;
|
542 |
+
justify-content: center;
|
543 |
+
gap: 5px;
|
544 |
+
margin-bottom: 15px;
|
545 |
+
font-size: 1.2rem;
|
546 |
+
}
|
547 |
+
|
548 |
+
.counter-current {
|
549 |
+
color: var(--accent-primary);
|
550 |
+
font-weight: 700;
|
551 |
+
font-size: 1.4rem;
|
552 |
+
}
|
553 |
+
|
554 |
+
.counter-separator {
|
555 |
+
color: var(--text-muted);
|
556 |
+
font-weight: 300;
|
557 |
+
}
|
558 |
+
|
559 |
+
.counter-total {
|
560 |
+
color: var(--text-secondary);
|
561 |
+
font-weight: 600;
|
562 |
+
}
|
563 |
+
|
564 |
+
.counter-label {
|
565 |
+
color: var(--text-muted);
|
566 |
+
font-size: 0.9rem;
|
567 |
+
margin-left: 5px;
|
568 |
+
}
|
569 |
+
|
570 |
+
.progress-stats {
|
571 |
+
display: flex;
|
572 |
+
flex-direction: column;
|
573 |
+
gap: 8px;
|
574 |
+
padding: 12px;
|
575 |
+
background: rgba(255, 255, 255, 0.05);
|
576 |
+
border-radius: 8px;
|
577 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
578 |
+
}
|
579 |
+
|
580 |
+
.stat-item {
|
581 |
+
display: flex;
|
582 |
+
justify-content: space-between;
|
583 |
+
align-items: center;
|
584 |
+
font-size: 0.85rem;
|
585 |
+
}
|
586 |
+
|
587 |
+
.stat-label {
|
588 |
+
color: var(--text-secondary);
|
589 |
+
}
|
590 |
+
|
591 |
+
.stat-value {
|
592 |
+
color: var(--accent-primary);
|
593 |
+
font-weight: 600;
|
594 |
+
font-family: 'Courier New', monospace;
|
595 |
+
}
|
596 |
+
|
597 |
+
/* Console Logs */
|
598 |
+
.console-logs {
|
599 |
+
margin: 20px 0;
|
600 |
+
background: rgba(0, 0, 0, 0.3);
|
601 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
602 |
+
border-radius: 8px;
|
603 |
+
overflow: hidden;
|
604 |
+
}
|
605 |
+
|
606 |
+
.console-header {
|
607 |
+
background: rgba(255, 255, 255, 0.1);
|
608 |
+
padding: 8px 12px;
|
609 |
+
font-size: 0.8rem;
|
610 |
+
font-weight: 600;
|
611 |
+
color: var(--text-secondary);
|
612 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
613 |
+
}
|
614 |
+
|
615 |
+
.console-content {
|
616 |
+
max-height: 120px;
|
617 |
+
overflow-y: auto;
|
618 |
+
padding: 8px;
|
619 |
+
}
|
620 |
+
|
621 |
+
.console-line {
|
622 |
+
display: flex;
|
623 |
+
gap: 8px;
|
624 |
+
margin-bottom: 4px;
|
625 |
+
font-size: 0.75rem;
|
626 |
+
font-family: 'Courier New', monospace;
|
627 |
+
}
|
628 |
+
|
629 |
+
.console-time {
|
630 |
+
color: var(--text-muted);
|
631 |
+
min-width: 60px;
|
632 |
+
flex-shrink: 0;
|
633 |
+
}
|
634 |
+
|
635 |
+
.console-message {
|
636 |
+
color: var(--text-primary);
|
637 |
+
flex: 1;
|
638 |
+
word-break: break-word;
|
639 |
+
}
|
640 |
+
|
641 |
+
.processing-animation {
|
642 |
+
display: flex;
|
643 |
+
justify-content: center;
|
644 |
+
}
|
645 |
+
|
646 |
+
.processing-dots {
|
647 |
+
display: flex;
|
648 |
+
gap: 8px;
|
649 |
+
}
|
650 |
+
|
651 |
+
.processing-dots .dot {
|
652 |
+
width: 8px;
|
653 |
+
height: 8px;
|
654 |
+
background: var(--accent-primary);
|
655 |
+
border-radius: 50%;
|
656 |
+
animation: processingPulse 1.5s ease-in-out infinite;
|
657 |
+
}
|
658 |
+
|
659 |
+
.processing-dots .dot:nth-child(2) {
|
660 |
+
animation-delay: 0.2s;
|
661 |
+
}
|
662 |
+
|
663 |
+
.processing-dots .dot:nth-child(3) {
|
664 |
+
animation-delay: 0.4s;
|
665 |
+
}
|
666 |
+
|
667 |
+
@keyframes processingPulse {
|
668 |
+
0%, 80%, 100% {
|
669 |
+
transform: scale(0.8);
|
670 |
+
opacity: 0.5;
|
671 |
+
}
|
672 |
+
40% {
|
673 |
+
transform: scale(1.2);
|
674 |
+
opacity: 1;
|
675 |
+
}
|
676 |
+
}
|
677 |
+
|
678 |
+
|
679 |
+
|
680 |
+
.menu-container {
|
681 |
+
display: flex;
|
682 |
+
background: rgba(0, 0, 0, 0.4) !important;
|
683 |
+
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
684 |
+
border-radius: 16px;
|
685 |
+
padding: 8px;
|
686 |
+
backdrop-filter: blur(20px) !important;
|
687 |
+
-webkit-backdrop-filter: blur(20px) !important;
|
688 |
+
position: relative;
|
689 |
+
overflow: hidden;
|
690 |
+
}
|
691 |
+
|
692 |
+
.menu-container::before {
|
693 |
+
content: '';
|
694 |
+
position: absolute;
|
695 |
+
top: 0;
|
696 |
+
left: 0;
|
697 |
+
right: 0;
|
698 |
+
bottom: 0;
|
699 |
+
background: var(--gradient-iridescent);
|
700 |
+
opacity: 0.05;
|
701 |
+
border-radius: 16px;
|
702 |
+
}
|
703 |
+
|
704 |
+
.menu-item {
|
705 |
+
position: relative;
|
706 |
+
padding: 12px 24px;
|
707 |
+
border-radius: 12px;
|
708 |
+
cursor: pointer;
|
709 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
710 |
+
display: flex;
|
711 |
+
align-items: center;
|
712 |
+
gap: 8px;
|
713 |
+
font-weight: 500;
|
714 |
+
font-size: 0.875rem;
|
715 |
+
color: var(--text-secondary);
|
716 |
+
z-index: 2;
|
717 |
+
}
|
718 |
+
|
719 |
+
.menu-item:hover {
|
720 |
+
color: var(--text-primary);
|
721 |
+
transform: translateY(-1px);
|
722 |
+
}
|
723 |
+
|
724 |
+
.menu-item.active {
|
725 |
+
color: var(--text-primary);
|
726 |
+
background: rgba(255, 255, 255, 0.1);
|
727 |
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
728 |
+
}
|
729 |
+
|
730 |
+
.menu-item-icon {
|
731 |
+
font-size: 1rem;
|
732 |
+
}
|
733 |
+
|
734 |
+
.app-main {
|
735 |
+
flex: 1;
|
736 |
+
padding: 0 24px 24px;
|
737 |
+
position: relative;
|
738 |
+
overflow-y: auto;
|
739 |
+
min-height: 0;
|
740 |
+
}
|
741 |
+
|
742 |
+
.tab-content {
|
743 |
+
min-height: auto;
|
744 |
+
display: flex;
|
745 |
+
flex-direction: column;
|
746 |
+
padding-bottom: 40px;
|
747 |
+
}
|
748 |
+
|
749 |
+
.glass-panel {
|
750 |
+
background: rgba(100, 100, 100, 0.1);
|
751 |
+
border-radius: 12px;
|
752 |
+
padding: 20px;
|
753 |
+
backdrop-filter: blur(25px);
|
754 |
+
-webkit-backdrop-filter: blur(25px);
|
755 |
+
position: relative;
|
756 |
+
overflow: hidden;
|
757 |
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
758 |
+
}
|
759 |
+
|
760 |
+
|
761 |
+
.glass-panel::before {
|
762 |
+
content: '';
|
763 |
+
position: absolute;
|
764 |
+
top: 0;
|
765 |
+
left: 0;
|
766 |
+
right: 0;
|
767 |
+
height: 1px;
|
768 |
+
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
769 |
+
}
|
770 |
+
|
771 |
+
.nested-panel {
|
772 |
+
background: transparent;
|
773 |
+
border-radius: 8px;
|
774 |
+
padding: 16px;
|
775 |
+
margin: 12px 0;
|
776 |
+
backdrop-filter: blur(15px);
|
777 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
778 |
+
}
|
779 |
+
|
780 |
+
.flowing-button {
|
781 |
+
background: var(--gradient-primary);
|
782 |
+
border: none;
|
783 |
+
border-radius: 12px;
|
784 |
+
padding: 12px 24px;
|
785 |
+
color: white;
|
786 |
+
font-weight: 600;
|
787 |
+
cursor: pointer;
|
788 |
+
transition: all 0.3s ease;
|
789 |
+
position: relative;
|
790 |
+
overflow: hidden;
|
791 |
+
}
|
792 |
+
|
793 |
+
.flowing-button::before {
|
794 |
+
content: '';
|
795 |
+
position: absolute;
|
796 |
+
top: 0;
|
797 |
+
left: -100%;
|
798 |
+
width: 100%;
|
799 |
+
height: 100%;
|
800 |
+
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
801 |
+
transition: left 0.5s ease;
|
802 |
+
}
|
803 |
+
|
804 |
+
.flowing-button:hover::before {
|
805 |
+
left: 100%;
|
806 |
+
}
|
807 |
+
|
808 |
+
.flowing-button:hover {
|
809 |
+
transform: translateY(-2px);
|
810 |
+
box-shadow: 0 8px 25px rgba(99, 102, 241, 0.3);
|
811 |
+
}
|
812 |
+
|
813 |
+
.flowing-button:disabled {
|
814 |
+
opacity: 0.5;
|
815 |
+
cursor: not-allowed;
|
816 |
+
transform: none;
|
817 |
+
}
|
818 |
+
|
819 |
+
@media (max-width: 768px) {
|
820 |
+
.glass-container {
|
821 |
+
margin: 10px;
|
822 |
+
min-height: calc(100vh - 20px);
|
823 |
+
border-radius: 16px;
|
824 |
+
}
|
825 |
+
|
826 |
+
.app-header {
|
827 |
+
padding: 24px 20px;
|
828 |
+
flex-direction: column;
|
829 |
+
gap: 16px;
|
830 |
+
}
|
831 |
+
|
832 |
+
.glass-input {
|
833 |
+
width: 100%;
|
834 |
+
}
|
835 |
+
|
836 |
+
.app-main {
|
837 |
+
padding: 0 20px 20px;
|
838 |
+
}
|
839 |
+
|
840 |
+
.flowing-menu {
|
841 |
+
padding: 16px 20px;
|
842 |
+
}
|
843 |
+
|
844 |
+
.menu-container {
|
845 |
+
width: 100%;
|
846 |
+
justify-content: space-around;
|
847 |
+
}
|
848 |
+
|
849 |
+
.menu-item {
|
850 |
+
flex: 1;
|
851 |
+
justify-content: center;
|
852 |
+
padding: 12px 8px;
|
853 |
+
}
|
854 |
+
}
|
855 |
+
|
856 |
+
/* Iridescence Container */
|
857 |
+
.iridescence-container {
|
858 |
+
width: 100%;
|
859 |
+
height: 100%;
|
860 |
+
position: absolute;
|
861 |
+
top: 0;
|
862 |
+
left: 0;
|
863 |
+
pointer-events: none;
|
864 |
+
}
|
865 |
+
|
866 |
+
.iridescence-container canvas {
|
867 |
+
width: 100% !important;
|
868 |
+
height: 100% !important;
|
869 |
+
}
|
870 |
+
|
871 |
+
|
872 |
+
.flowing-menu-container {
|
873 |
+
width: 100%;
|
874 |
+
height: 200px;
|
875 |
+
overflow: hidden;
|
876 |
+
padding: 24px 40px;
|
877 |
+
}
|
878 |
+
|
879 |
+
.flowing-menu-nav {
|
880 |
+
display: flex;
|
881 |
+
flex-direction: column;
|
882 |
+
height: 100%;
|
883 |
+
margin: 0;
|
884 |
+
padding: 0;
|
885 |
+
}
|
886 |
+
|
887 |
+
.flowing-menu-item {
|
888 |
+
flex: 1;
|
889 |
+
position: relative;
|
890 |
+
overflow: hidden;
|
891 |
+
text-align: center;
|
892 |
+
box-shadow: 0 -1px 0 0 #fff;
|
893 |
+
}
|
894 |
+
|
895 |
+
.flowing-menu-item.active {
|
896 |
+
background: rgba(255, 255, 255, 0.1);
|
897 |
+
}
|
898 |
+
|
899 |
+
.menu-item-link {
|
900 |
+
display: flex;
|
901 |
+
align-items: center;
|
902 |
+
justify-content: center;
|
903 |
+
height: 100%;
|
904 |
+
position: relative;
|
905 |
+
cursor: pointer;
|
906 |
+
text-transform: uppercase;
|
907 |
+
text-decoration: none;
|
908 |
+
font-weight: 600;
|
909 |
+
color: white;
|
910 |
+
font-size: 2rem;
|
911 |
+
transition: color 0.3s ease;
|
912 |
+
}
|
913 |
+
|
914 |
+
.menu-item-link:hover {
|
915 |
+
color: #ffffff;
|
916 |
+
}
|
917 |
+
|
918 |
+
.menu-item-link:focus {
|
919 |
+
color: white;
|
920 |
+
}
|
921 |
+
|
922 |
+
.menu-icon {
|
923 |
+
margin-right: 0.5rem;
|
924 |
+
font-size: 1.5rem;
|
925 |
+
}
|
926 |
+
|
927 |
+
.menu-text {
|
928 |
+
font-size: 1.2rem;
|
929 |
+
}
|
930 |
+
|
931 |
+
|
932 |
+
|
933 |
+
/* Lanyard Styles */
|
934 |
+
.lanyard-wrapper {
|
935 |
+
position: absolute;
|
936 |
+
top: 20px;
|
937 |
+
right: 20px;
|
938 |
+
width: 200px;
|
939 |
+
height: 300px;
|
940 |
+
z-index: 10;
|
941 |
+
pointer-events: auto;
|
942 |
+
}
|
943 |
+
|
944 |
+
/* Professional Logo Styles */
|
945 |
+
.logo-content h1 {
|
946 |
+
font-size: 1.75rem;
|
947 |
+
font-weight: 700;
|
948 |
+
color: white;
|
949 |
+
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
950 |
+
margin-bottom: 4px;
|
951 |
+
}
|
952 |
+
|
953 |
+
.logo-subtitle {
|
954 |
+
font-size: 0.8rem;
|
955 |
+
color: rgba(255, 255, 255, 0.7);
|
956 |
+
font-weight: 400;
|
957 |
+
}
|
958 |
+
|
959 |
+
/* More Professional Upload Styles */
|
960 |
+
.upload-zone {
|
961 |
+
border: none;
|
962 |
+
border-radius: 16px;
|
963 |
+
transition: all 0.3s ease;
|
964 |
+
}
|
965 |
+
|
966 |
+
.upload-zone.drag-active {
|
967 |
+
background: rgba(79, 172, 254, 0.05);
|
968 |
+
}
|
969 |
+
|
970 |
+
.upload-svg {
|
971 |
+
color: rgba(255, 255, 255, 0.6);
|
972 |
+
filter: drop-shadow(0 0 10px rgba(79, 172, 254, 0.2));
|
973 |
+
}
|
974 |
+
|
975 |
+
.upload-methods {
|
976 |
+
margin-top: 32px;
|
977 |
+
}
|
978 |
+
|
979 |
+
.upload-method {
|
980 |
+
background: rgba(255, 255, 255, 0.02);
|
981 |
+
border: none;
|
982 |
+
transition: all 0.3s ease;
|
983 |
+
}
|
984 |
+
|
985 |
+
.upload-method:hover {
|
986 |
+
background: rgba(255, 255, 255, 0.04);
|
987 |
+
border-color: rgba(79, 172, 254, 0.3);
|
988 |
+
transform: translateY(-1px);
|
989 |
+
}
|
990 |
+
|
991 |
+
.method-icon {
|
992 |
+
color: rgba(79, 172, 254, 0.8);
|
993 |
+
}
|
994 |
+
|
995 |
+
/*
|
996 |
+
Simple Lanyard Styles */
|
997 |
+
.lanyard-wrapper {
|
998 |
+
position: fixed;
|
999 |
+
top: 60px;
|
1000 |
+
right: 40px;
|
1001 |
+
width: 120px;
|
1002 |
+
height: 200px;
|
1003 |
+
z-index: 10;
|
1004 |
+
pointer-events: none;
|
1005 |
+
}
|
1006 |
+
|
1007 |
+
.lanyard-card {
|
1008 |
+
position: relative;
|
1009 |
+
width: 100%;
|
1010 |
+
height: 100%;
|
1011 |
+
}
|
1012 |
+
|
1013 |
+
.lanyard-string {
|
1014 |
+
width: 2px;
|
1015 |
+
height: 80px;
|
1016 |
+
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.3));
|
1017 |
+
margin: 0 auto;
|
1018 |
+
border-radius: 1px;
|
1019 |
+
}
|
1020 |
+
|
1021 |
+
.card {
|
1022 |
+
width: 80px;
|
1023 |
+
height: 100px;
|
1024 |
+
background: rgba(0, 0, 0, 0.8);
|
1025 |
+
border: none;
|
1026 |
+
border-radius: 8px;
|
1027 |
+
margin: 0 auto;
|
1028 |
+
backdrop-filter: blur(10px);
|
1029 |
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
1030 |
+
}
|
1031 |
+
|
1032 |
+
.card-content {
|
1033 |
+
padding: 12px;
|
1034 |
+
text-align: center;
|
1035 |
+
height: 100%;
|
1036 |
+
display: flex;
|
1037 |
+
flex-direction: column;
|
1038 |
+
justify-content: center;
|
1039 |
+
align-items: center;
|
1040 |
+
gap: 8px;
|
1041 |
+
}
|
1042 |
+
|
1043 |
+
.card-logo {
|
1044 |
+
font-size: 1.2rem;
|
1045 |
+
font-weight: bold;
|
1046 |
+
color: #4facfe;
|
1047 |
+
text-shadow: 0 0 10px rgba(79, 172, 254, 0.5);
|
1048 |
+
}
|
1049 |
+
|
1050 |
+
.card-text {
|
1051 |
+
font-size: 0.6rem;
|
1052 |
+
color: rgba(255, 255, 255, 0.8);
|
1053 |
+
font-weight: 500;
|
1054 |
+
}
|
1055 |
+
|
1056 |
+
/* Interactive Pullable Lanyard Styles */
|
1057 |
+
.lanyard-wrapper {
|
1058 |
+
position: fixed;
|
1059 |
+
top: 40px;
|
1060 |
+
right: 40px;
|
1061 |
+
width: 120px;
|
1062 |
+
height: 250px;
|
1063 |
+
z-index: 10;
|
1064 |
+
pointer-events: auto;
|
1065 |
+
}
|
1066 |
+
|
1067 |
+
.lanyard-string-container {
|
1068 |
+
position: relative;
|
1069 |
+
width: 100%;
|
1070 |
+
height: 100%;
|
1071 |
+
display: flex;
|
1072 |
+
flex-direction: column;
|
1073 |
+
align-items: center;
|
1074 |
+
}
|
1075 |
+
|
1076 |
+
.lanyard-string {
|
1077 |
+
width: 2px;
|
1078 |
+
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.4));
|
1079 |
+
border-radius: 1px;
|
1080 |
+
box-shadow: 0 0 4px rgba(255, 255, 255, 0.3);
|
1081 |
+
}
|
1082 |
+
|
1083 |
+
.lanyard-card {
|
1084 |
+
position: relative;
|
1085 |
+
margin-top: 4px;
|
1086 |
+
}
|
1087 |
+
|
1088 |
+
.card {
|
1089 |
+
width: 85px;
|
1090 |
+
height: 110px;
|
1091 |
+
background: rgba(0, 0, 0, 0.85);
|
1092 |
+
border: 1px solid rgba(79, 172, 254, 0.3);
|
1093 |
+
border-radius: 10px;
|
1094 |
+
backdrop-filter: blur(15px);
|
1095 |
+
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4), 0 0 20px rgba(79, 172, 254, 0.1);
|
1096 |
+
transition: all 0.3s ease;
|
1097 |
+
}
|
1098 |
+
|
1099 |
+
.card:hover {
|
1100 |
+
border-color: rgba(79, 172, 254, 0.5);
|
1101 |
+
box-shadow: 0 12px 35px rgba(0, 0, 0, 0.5), 0 0 30px rgba(79, 172, 254, 0.2);
|
1102 |
+
}
|
1103 |
+
|
1104 |
+
.card-content {
|
1105 |
+
padding: 16px 12px;
|
1106 |
+
text-align: center;
|
1107 |
+
height: 100%;
|
1108 |
+
display: flex;
|
1109 |
+
flex-direction: column;
|
1110 |
+
justify-content: center;
|
1111 |
+
align-items: center;
|
1112 |
+
gap: 10px;
|
1113 |
+
}
|
1114 |
+
|
1115 |
+
.card-logo {
|
1116 |
+
font-size: 1.4rem;
|
1117 |
+
font-weight: bold;
|
1118 |
+
color: #4facfe;
|
1119 |
+
text-shadow: 0 0 15px rgba(79, 172, 254, 0.6);
|
1120 |
+
letter-spacing: 1px;
|
1121 |
+
}
|
1122 |
+
|
1123 |
+
.card-text {
|
1124 |
+
font-size: 0.65rem;
|
1125 |
+
color: rgba(255, 255, 255, 0.9);
|
1126 |
+
font-weight: 500;
|
1127 |
+
letter-spacing: 0.5px;
|
1128 |
+
}
|
1129 |
+
|
1130 |
+
/* ReactBits Physics Lanyard Override */
|
1131 |
+
.lanyard-wrapper {
|
1132 |
+
position: fixed !important;
|
1133 |
+
top: 40px !important;
|
1134 |
+
right: 40px !important;
|
1135 |
+
width: 150px !important;
|
1136 |
+
height: 300px !important;
|
1137 |
+
z-index: 10 !important;
|
1138 |
+
pointer-events: auto !important;
|
1139 |
+
}
|
1140 |
+
|
1141 |
+
|
1142 |
+
.flowing-menu-container {
|
1143 |
+
width: 100% !important;
|
1144 |
+
height: 50px !important;
|
1145 |
+
overflow: hidden !important;
|
1146 |
+
padding: 8px 24px !important;
|
1147 |
+
display: flex !important;
|
1148 |
+
justify-content: center !important;
|
1149 |
+
}
|
1150 |
+
|
1151 |
+
.flowing-menu-nav {
|
1152 |
+
display: flex !important;
|
1153 |
+
flex-direction: row !important;
|
1154 |
+
height: 100% !important;
|
1155 |
+
background: rgba(100, 100, 100, 0.1) !important;
|
1156 |
+
border: none !important;
|
1157 |
+
border-radius: 16px !important;
|
1158 |
+
backdrop-filter: blur(20px) !important;
|
1159 |
+
overflow: hidden !important;
|
1160 |
+
position: relative !important;
|
1161 |
+
min-width: 400px !important;
|
1162 |
+
}
|
1163 |
+
|
1164 |
+
.flowing-menu-item {
|
1165 |
+
flex: 1 !important;
|
1166 |
+
position: relative !important;
|
1167 |
+
overflow: hidden !important;
|
1168 |
+
text-align: center !important;
|
1169 |
+
border-right: none !important;
|
1170 |
+
}
|
1171 |
+
|
1172 |
+
.flowing-menu-item:last-child {
|
1173 |
+
border-right: none !important;
|
1174 |
+
}
|
1175 |
+
|
1176 |
+
.flowing-menu-item.active {
|
1177 |
+
background: transparent !important;
|
1178 |
+
border: none !important;
|
1179 |
+
box-shadow: none !important;
|
1180 |
+
}
|
1181 |
+
|
1182 |
+
.menu-item-link {
|
1183 |
+
display: flex !important;
|
1184 |
+
align-items: center !important;
|
1185 |
+
justify-content: center !important;
|
1186 |
+
height: 100% !important;
|
1187 |
+
position: relative !important;
|
1188 |
+
cursor: pointer !important;
|
1189 |
+
text-decoration: none !important;
|
1190 |
+
font-weight: 500 !important;
|
1191 |
+
color: rgba(255, 255, 255, 0.7) !important;
|
1192 |
+
font-size: 0.75rem !important;
|
1193 |
+
transition: all 0.3s ease !important;
|
1194 |
+
gap: 4px !important;
|
1195 |
+
padding: 8px 12px !important;
|
1196 |
+
}
|
1197 |
+
|
1198 |
+
.menu-item-link:hover {
|
1199 |
+
color: #ffffff !important;
|
1200 |
+
background: linear-gradient(135deg,
|
1201 |
+
rgba(138, 43, 226, 0.1) 0%,
|
1202 |
+
rgba(59, 130, 246, 0.08) 50%,
|
1203 |
+
rgba(138, 43, 226, 0.05) 100%) !important;
|
1204 |
+
box-shadow: 0 0 15px rgba(138, 43, 226, 0.05), inset 0 1px 0 rgba(255, 255, 255, 0.05) !important;
|
1205 |
+
}
|
1206 |
+
|
1207 |
+
.menu-icon {
|
1208 |
+
font-size: 1.2rem !important;
|
1209 |
+
background: linear-gradient(45deg, #8b5cf6, #3b82f6);
|
1210 |
+
-webkit-background-clip: text !important;
|
1211 |
+
-webkit-text-fill-color: transparent !important;
|
1212 |
+
background-clip: text !important;
|
1213 |
+
opacity: 0.9 !important;
|
1214 |
+
}
|
1215 |
+
|
1216 |
+
.menu-text {
|
1217 |
+
font-size: 0.875rem !important;
|
1218 |
+
font-weight: 500 !important;
|
1219 |
+
}
|
1220 |
+
|
1221 |
+
/* Smooth Physics Lanyard Styles */
|
1222 |
+
.lanyard-string-container {
|
1223 |
+
position: relative;
|
1224 |
+
width: 100%;
|
1225 |
+
height: 100%;
|
1226 |
+
display: flex;
|
1227 |
+
flex-direction: column;
|
1228 |
+
align-items: center;
|
1229 |
+
}
|
1230 |
+
|
1231 |
+
.lanyard-string {
|
1232 |
+
width: 2px;
|
1233 |
+
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.4));
|
1234 |
+
border-radius: 1px;
|
1235 |
+
box-shadow: 0 0 4px rgba(255, 255, 255, 0.3);
|
1236 |
+
}
|
1237 |
+
|
1238 |
+
.lanyard-card {
|
1239 |
+
position: relative;
|
1240 |
+
margin-top: 4px;
|
1241 |
+
}
|
1242 |
+
|
1243 |
+
.card {
|
1244 |
+
width: 90px;
|
1245 |
+
height: 120px;
|
1246 |
+
background: rgba(0, 0, 0, 0.9);
|
1247 |
+
border: 1px solid rgba(79, 172, 254, 0.4);
|
1248 |
+
border-radius: 12px;
|
1249 |
+
backdrop-filter: blur(20px);
|
1250 |
+
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4), 0 0 20px rgba(79, 172, 254, 0.15);
|
1251 |
+
transition: all 0.3s ease;
|
1252 |
+
}
|
1253 |
+
|
1254 |
+
.card:hover {
|
1255 |
+
border-color: rgba(79, 172, 254, 0.6);
|
1256 |
+
box-shadow: 0 12px 35px rgba(0, 0, 0, 0.5), 0 0 30px rgba(79, 172, 254, 0.25);
|
1257 |
+
}
|
1258 |
+
|
1259 |
+
.card-content {
|
1260 |
+
padding: 16px 12px;
|
1261 |
+
text-align: center;
|
1262 |
+
height: 100%;
|
1263 |
+
display: flex;
|
1264 |
+
flex-direction: column;
|
1265 |
+
justify-content: center;
|
1266 |
+
align-items: center;
|
1267 |
+
gap: 8px;
|
1268 |
+
}
|
1269 |
+
|
1270 |
+
.card-logo {
|
1271 |
+
font-size: 1.5rem;
|
1272 |
+
font-weight: bold;
|
1273 |
+
color: #4facfe;
|
1274 |
+
text-shadow: 0 0 15px rgba(79, 172, 254, 0.6);
|
1275 |
+
letter-spacing: 1px;
|
1276 |
+
}
|
1277 |
+
|
1278 |
+
.card-text {
|
1279 |
+
font-size: 0.7rem;
|
1280 |
+
color: rgba(255, 255, 255, 0.9);
|
1281 |
+
font-weight: 600;
|
1282 |
+
letter-spacing: 0.5px;
|
1283 |
+
}
|
1284 |
+
|
1285 |
+
.card-subtitle {
|
1286 |
+
font-size: 0.6rem;
|
1287 |
+
color: rgba(255, 255, 255, 0.6);
|
1288 |
+
font-weight: 400;
|
1289 |
+
letter-spacing: 0.5px;
|
1290 |
+
}
|
1291 |
+
|
1292 |
+
/* Simple Visible Lanyard Styles */
|
1293 |
+
.simple-lanyard-wrapper {
|
1294 |
+
position: fixed !important;
|
1295 |
+
top: 60px !important;
|
1296 |
+
right: 40px !important;
|
1297 |
+
width: 120px !important;
|
1298 |
+
height: 250px !important;
|
1299 |
+
z-index: 1000 !important;
|
1300 |
+
pointer-events: auto !important;
|
1301 |
+
}
|
1302 |
+
|
1303 |
+
.lanyard-container {
|
1304 |
+
position: relative;
|
1305 |
+
width: 100%;
|
1306 |
+
height: 100%;
|
1307 |
+
display: flex;
|
1308 |
+
flex-direction: column;
|
1309 |
+
align-items: center;
|
1310 |
+
}
|
1311 |
+
|
1312 |
+
.lanyard-string {
|
1313 |
+
width: 3px;
|
1314 |
+
background: linear-gradient(to bottom,
|
1315 |
+
rgba(255, 255, 255, 0.9) 0%,
|
1316 |
+
rgba(255, 255, 255, 0.7) 50%,
|
1317 |
+
rgba(255, 255, 255, 0.5) 100%);
|
1318 |
+
border-radius: 2px;
|
1319 |
+
box-shadow: 0 0 8px rgba(255, 255, 255, 0.3);
|
1320 |
+
margin-bottom: 8px;
|
1321 |
+
}
|
1322 |
+
|
1323 |
+
.lanyard-card {
|
1324 |
+
position: relative;
|
1325 |
+
width: 90px;
|
1326 |
+
height: 120px;
|
1327 |
+
}
|
1328 |
+
|
1329 |
+
.card-face {
|
1330 |
+
width: 100%;
|
1331 |
+
height: 100%;
|
1332 |
+
background: linear-gradient(135deg,
|
1333 |
+
rgba(0, 0, 0, 0.9) 0%,
|
1334 |
+
rgba(20, 20, 40, 0.9) 100%);
|
1335 |
+
border: 2px solid rgba(79, 172, 254, 0.4);
|
1336 |
+
border-radius: 12px;
|
1337 |
+
backdrop-filter: blur(20px);
|
1338 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), 0 0 20px rgba(79, 172, 254, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
1339 |
+
display: flex;
|
1340 |
+
flex-direction: column;
|
1341 |
+
padding: 12px;
|
1342 |
+
transition: all 0.3s ease;
|
1343 |
+
}
|
1344 |
+
|
1345 |
+
.card-face:hover {
|
1346 |
+
border-color: rgba(79, 172, 254, 0.6);
|
1347 |
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.7), 0 0 30px rgba(79, 172, 254, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
1348 |
+
}
|
1349 |
+
|
1350 |
+
.card-header {
|
1351 |
+
display: flex;
|
1352 |
+
justify-content: center;
|
1353 |
+
margin-bottom: 8px;
|
1354 |
+
}
|
1355 |
+
|
1356 |
+
.card-logo {
|
1357 |
+
font-size: 1.5rem;
|
1358 |
+
font-weight: bold;
|
1359 |
+
color: #4facfe;
|
1360 |
+
text-shadow: 0 0 15px rgba(79, 172, 254, 0.8);
|
1361 |
+
letter-spacing: 2px;
|
1362 |
+
}
|
1363 |
+
|
1364 |
+
.card-body {
|
1365 |
+
flex: 1;
|
1366 |
+
display: flex;
|
1367 |
+
flex-direction: column;
|
1368 |
+
justify-content: center;
|
1369 |
+
align-items: center;
|
1370 |
+
text-align: center;
|
1371 |
+
gap: 4px;
|
1372 |
+
}
|
1373 |
+
|
1374 |
+
.card-title {
|
1375 |
+
font-size: 0.75rem;
|
1376 |
+
font-weight: 600;
|
1377 |
+
color: rgba(255, 255, 255, 0.95);
|
1378 |
+
letter-spacing: 0.5px;
|
1379 |
+
}
|
1380 |
+
|
1381 |
+
.card-subtitle {
|
1382 |
+
font-size: 0.6rem;
|
1383 |
+
color: rgba(255, 255, 255, 0.6);
|
1384 |
+
font-weight: 400;
|
1385 |
+
}
|
1386 |
+
|
1387 |
+
.card-footer {
|
1388 |
+
display: flex;
|
1389 |
+
justify-content: center;
|
1390 |
+
gap: 4px;
|
1391 |
+
margin-top: 8px;
|
1392 |
+
}
|
1393 |
+
|
1394 |
+
.card-dot {
|
1395 |
+
width: 4px;
|
1396 |
+
height: 4px;
|
1397 |
+
background: rgba(79, 172, 254, 0.6);
|
1398 |
+
border-radius: 50%;
|
1399 |
+
box-shadow: 0 0 4px rgba(79, 172, 254, 0.4);
|
1400 |
+
}
|
1401 |
+
|
1402 |
+
/* Giant Central Orb - Hidden until loading complete */
|
1403 |
+
.giant-orb-background {
|
1404 |
+
position: fixed;
|
1405 |
+
top: 0;
|
1406 |
+
left: 0;
|
1407 |
+
width: 100%;
|
1408 |
+
height: 100%;
|
1409 |
+
pointer-events: none;
|
1410 |
+
z-index: -10;
|
1411 |
+
display: flex;
|
1412 |
+
align-items: center;
|
1413 |
+
justify-content: center;
|
1414 |
+
opacity: 0;
|
1415 |
+
transition: opacity 0.8s ease-out;
|
1416 |
+
}
|
1417 |
+
|
1418 |
+
.giant-orb-background.loaded {
|
1419 |
+
opacity: 1;
|
1420 |
+
}
|
1421 |
+
|
1422 |
+
.giant-orb-container {
|
1423 |
+
width: 100vmin;
|
1424 |
+
height: 100vmin;
|
1425 |
+
position: relative;
|
1426 |
+
pointer-events: none;
|
1427 |
+
transform: translateY(-5vh);
|
1428 |
+
}
|
1429 |
+
|
1430 |
+
.orb {
|
1431 |
+
position: absolute;
|
1432 |
+
pointer-events: auto;
|
1433 |
+
}
|
1434 |
+
|
1435 |
+
.orb-1 {
|
1436 |
+
top: 10%;
|
1437 |
+
right: 15%;
|
1438 |
+
width: 300px;
|
1439 |
+
height: 300px;
|
1440 |
+
animation: float1 20s ease-in-out infinite;
|
1441 |
+
}
|
1442 |
+
|
1443 |
+
.orb-2 {
|
1444 |
+
bottom: 20%;
|
1445 |
+
left: 10%;
|
1446 |
+
width: 250px;
|
1447 |
+
height: 250px;
|
1448 |
+
animation: float2 25s ease-in-out infinite;
|
1449 |
+
}
|
1450 |
+
|
1451 |
+
.orb-3 {
|
1452 |
+
top: 60%;
|
1453 |
+
right: 60%;
|
1454 |
+
width: 200px;
|
1455 |
+
height: 200px;
|
1456 |
+
animation: float3 30s ease-in-out infinite;
|
1457 |
+
}
|
1458 |
+
|
1459 |
+
@keyframes float1 {
|
1460 |
+
|
1461 |
+
0%,
|
1462 |
+
100% {
|
1463 |
+
transform: translate(0, 0) rotate(0deg);
|
1464 |
+
}
|
1465 |
+
|
1466 |
+
25% {
|
1467 |
+
transform: translate(-20px, -30px) rotate(90deg);
|
1468 |
+
}
|
1469 |
+
|
1470 |
+
50% {
|
1471 |
+
transform: translate(10px, -20px) rotate(180deg);
|
1472 |
+
}
|
1473 |
+
|
1474 |
+
75% {
|
1475 |
+
transform: translate(-10px, 10px) rotate(270deg);
|
1476 |
+
}
|
1477 |
+
}
|
1478 |
+
|
1479 |
+
@keyframes float2 {
|
1480 |
+
|
1481 |
+
0%,
|
1482 |
+
100% {
|
1483 |
+
transform: translate(0, 0) rotate(0deg);
|
1484 |
+
}
|
1485 |
+
|
1486 |
+
33% {
|
1487 |
+
transform: translate(30px, -20px) rotate(120deg);
|
1488 |
+
}
|
1489 |
+
|
1490 |
+
66% {
|
1491 |
+
transform: translate(-20px, -40px) rotate(240deg);
|
1492 |
+
}
|
1493 |
+
}
|
1494 |
+
|
1495 |
+
@keyframes float3 {
|
1496 |
+
|
1497 |
+
0%,
|
1498 |
+
100% {
|
1499 |
+
transform: translate(0, 0) rotate(0deg);
|
1500 |
+
}
|
1501 |
+
|
1502 |
+
20% {
|
1503 |
+
transform: translate(-15px, 25px) rotate(72deg);
|
1504 |
+
}
|
1505 |
+
|
1506 |
+
40% {
|
1507 |
+
transform: translate(25px, 15px) rotate(144deg);
|
1508 |
+
}
|
1509 |
+
|
1510 |
+
60% {
|
1511 |
+
transform: translate(20px, -25px) rotate(216deg);
|
1512 |
+
}
|
1513 |
+
|
1514 |
+
80% {
|
1515 |
+
transform: translate(-25px, -15px) rotate(288deg);
|
1516 |
+
}
|
1517 |
+
}
|
1518 |
+
|
1519 |
+
.orb-container {
|
1520 |
+
position: relative;
|
1521 |
+
z-index: 0;
|
1522 |
+
width: 100%;
|
1523 |
+
height: 100%;
|
1524 |
+
}
|
1525 |
+
|
1526 |
+
/* Apple-Style Glass Container */
|
1527 |
+
.glass-container {
|
1528 |
+
backdrop-filter: blur(40px);
|
1529 |
+
-webkit-backdrop-filter: blur(40px);
|
1530 |
+
background: rgba(0, 0, 0, 0.1);
|
1531 |
+
border: none;
|
1532 |
+
border-radius: 24px;
|
1533 |
+
box-shadow: none;
|
1534 |
+
margin: 20px;
|
1535 |
+
min-height: calc(100vh - 40px);
|
1536 |
+
overflow: hidden;
|
1537 |
+
position: relative;
|
1538 |
+
}
|
1539 |
+
|
1540 |
+
.glass-panel {
|
1541 |
+
background: rgba(255, 255, 255, 0.7);
|
1542 |
+
border: 1px solid rgba(0, 0, 0, 0.06);
|
1543 |
+
border-radius: 16px;
|
1544 |
+
padding: 32px;
|
1545 |
+
backdrop-filter: blur(20px);
|
1546 |
+
position: relative;
|
1547 |
+
overflow: hidden;
|
1548 |
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
1549 |
+
}
|
1550 |
+
|
1551 |
+
.nested-panel {
|
1552 |
+
background: transparent;
|
1553 |
+
border: 1px solid rgba(0, 0, 0, 0.04);
|
1554 |
+
border-radius: 12px;
|
1555 |
+
padding: 24px;
|
1556 |
+
margin: 16px 0;
|
1557 |
+
backdrop-filter: blur(10px);
|
1558 |
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.03);
|
1559 |
+
}
|
1560 |
+
|
1561 |
+
/* Fixed Lanyard - Make it VERY visible */
|
1562 |
+
.simple-lanyard-wrapper {
|
1563 |
+
position: fixed !important;
|
1564 |
+
top: 80px !important;
|
1565 |
+
right: 50px !important;
|
1566 |
+
width: 100px !important;
|
1567 |
+
height: 200px !important;
|
1568 |
+
z-index: 9999 !important;
|
1569 |
+
pointer-events: auto !important;
|
1570 |
+
}
|
1571 |
+
|
1572 |
+
.lanyard-string {
|
1573 |
+
width: 4px !important;
|
1574 |
+
background: linear-gradient(to bottom,
|
1575 |
+
rgba(59, 130, 246, 0.9) 0%,
|
1576 |
+
rgba(139, 92, 246, 0.7) 100%) !important;
|
1577 |
+
border-radius: 2px !important;
|
1578 |
+
box-shadow: 0 0 10px rgba(59, 130, 246, 0.5) !important;
|
1579 |
+
margin-bottom: 8px !important;
|
1580 |
+
}
|
1581 |
+
|
1582 |
+
.card-face {
|
1583 |
+
background: rgba(255, 255, 255, 0.95) !important;
|
1584 |
+
border: 2px solid rgba(59, 130, 246, 0.3) !important;
|
1585 |
+
color: var(--text-primary) !important;
|
1586 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15) !important, 0 0 20px rgba(59, 130, 246, 0.2) !important;
|
1587 |
+
}
|
1588 |
+
|
1589 |
+
.card-logo {
|
1590 |
+
color: #3b82f6 !important;
|
1591 |
+
text-shadow: 0 0 15px rgba(59, 130, 246, 0.6) !important;
|
1592 |
+
}
|
1593 |
+
|
1594 |
+
.card-title {
|
1595 |
+
color: var(--text-primary) !important;
|
1596 |
+
}
|
1597 |
+
|
1598 |
+
.card-subtitle {
|
1599 |
+
color: var(--text-secondary) !important;
|
1600 |
+
}
|
1601 |
+
|
1602 |
+
.card-dot {
|
1603 |
+
background: rgba(59, 130, 246, 0.8) !important;
|
1604 |
+
box-shadow: 0 0 6px rgba(59, 130, 246, 0.6) !important;
|
1605 |
+
}
|
1606 |
+
/*
|
1607 |
+
Results Panel Styles */
|
1608 |
+
.results-container {
|
1609 |
+
display: flex;
|
1610 |
+
flex-direction: column;
|
1611 |
+
gap: 24px;
|
1612 |
+
height: 100%;
|
1613 |
+
}
|
1614 |
+
|
1615 |
+
.results-header {
|
1616 |
+
background: rgba(255, 255, 255, 0.05);
|
1617 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
1618 |
+
border-radius: 16px;
|
1619 |
+
padding: 24px;
|
1620 |
+
backdrop-filter: blur(20px);
|
1621 |
+
}
|
1622 |
+
|
1623 |
+
.header-content {
|
1624 |
+
display: flex;
|
1625 |
+
justify-content: space-between;
|
1626 |
+
align-items: flex-start;
|
1627 |
+
gap: 24px;
|
1628 |
+
}
|
1629 |
+
|
1630 |
+
.header-left h3 {
|
1631 |
+
font-size: 1.5rem;
|
1632 |
+
font-weight: 600;
|
1633 |
+
color: var(--text-primary);
|
1634 |
+
margin: 0 0 8px 0;
|
1635 |
+
background: var(--gradient-primary);
|
1636 |
+
-webkit-background-clip: text;
|
1637 |
+
-webkit-text-fill-color: transparent;
|
1638 |
+
background-clip: text;
|
1639 |
+
}
|
1640 |
+
|
1641 |
+
.result-info {
|
1642 |
+
font-size: 0.875rem;
|
1643 |
+
color: var(--text-muted);
|
1644 |
+
}
|
1645 |
+
|
1646 |
+
.header-actions {
|
1647 |
+
display: flex;
|
1648 |
+
gap: 12px;
|
1649 |
+
}
|
1650 |
+
|
1651 |
+
.action-button {
|
1652 |
+
display: flex;
|
1653 |
+
align-items: center;
|
1654 |
+
gap: 8px;
|
1655 |
+
padding: 12px 20px;
|
1656 |
+
background: rgba(255, 255, 255, 0.05);
|
1657 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
1658 |
+
border-radius: 12px;
|
1659 |
+
color: var(--text-primary);
|
1660 |
+
font-size: 0.875rem;
|
1661 |
+
font-weight: 500;
|
1662 |
+
cursor: pointer;
|
1663 |
+
transition: all 0.3s ease;
|
1664 |
+
backdrop-filter: blur(10px);
|
1665 |
+
}
|
1666 |
+
|
1667 |
+
.action-button:hover {
|
1668 |
+
background: rgba(255, 255, 255, 0.08);
|
1669 |
+
border-color: var(--accent-primary);
|
1670 |
+
transform: translateY(-1px);
|
1671 |
+
}
|
1672 |
+
|
1673 |
+
|
1674 |
+
|
1675 |
+
/* Categories Section */
|
1676 |
+
.categories-section {
|
1677 |
+
display: flex;
|
1678 |
+
gap: 24px;
|
1679 |
+
}
|
1680 |
+
|
1681 |
+
.category-group {
|
1682 |
+
flex: 1;
|
1683 |
+
background: rgba(255, 255, 255, 0.05);
|
1684 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
1685 |
+
border-radius: 16px;
|
1686 |
+
padding: 24px;
|
1687 |
+
backdrop-filter: blur(20px);
|
1688 |
+
}
|
1689 |
+
|
1690 |
+
.category-header {
|
1691 |
+
display: flex;
|
1692 |
+
align-items: center;
|
1693 |
+
gap: 12px;
|
1694 |
+
margin-bottom: 20px;
|
1695 |
+
color: var(--text-primary);
|
1696 |
+
}
|
1697 |
+
|
1698 |
+
.category-label {
|
1699 |
+
font-size: 0.75rem;
|
1700 |
+
font-weight: 600;
|
1701 |
+
letter-spacing: 1px;
|
1702 |
+
text-transform: uppercase;
|
1703 |
+
color: var(--text-muted);
|
1704 |
+
}
|
1705 |
+
|
1706 |
+
/* Mode Buttons */
|
1707 |
+
.mode-buttons {
|
1708 |
+
display: flex;
|
1709 |
+
flex-direction: column;
|
1710 |
+
gap: 8px;
|
1711 |
+
}
|
1712 |
+
|
1713 |
+
.mode-button {
|
1714 |
+
display: flex;
|
1715 |
+
align-items: center;
|
1716 |
+
gap: 12px;
|
1717 |
+
padding: 12px 16px;
|
1718 |
+
background: rgba(255, 255, 255, 0.02);
|
1719 |
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
1720 |
+
border-radius: 12px;
|
1721 |
+
color: var(--text-secondary);
|
1722 |
+
font-size: 0.875rem;
|
1723 |
+
font-weight: 500;
|
1724 |
+
cursor: pointer;
|
1725 |
+
transition: all 0.3s ease;
|
1726 |
+
text-align: left;
|
1727 |
+
}
|
1728 |
+
|
1729 |
+
.mode-button:hover {
|
1730 |
+
background: rgba(255, 255, 255, 0.05);
|
1731 |
+
border-color: rgba(255, 255, 255, 0.1);
|
1732 |
+
color: var(--text-primary);
|
1733 |
+
}
|
1734 |
+
|
1735 |
+
.mode-button.active {
|
1736 |
+
background: var(--gradient-primary);
|
1737 |
+
border-color: var(--accent-primary);
|
1738 |
+
color: white;
|
1739 |
+
box-shadow: 0 4px 15px rgba(79, 172, 254, 0.3);
|
1740 |
+
}
|
1741 |
+
|
1742 |
+
/* Download Buttons */
|
1743 |
+
.download-buttons {
|
1744 |
+
display: grid;
|
1745 |
+
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
1746 |
+
gap: 12px;
|
1747 |
+
}
|
1748 |
+
|
1749 |
+
.download-button {
|
1750 |
+
display: flex;
|
1751 |
+
flex-direction: column;
|
1752 |
+
align-items: center;
|
1753 |
+
gap: 8px;
|
1754 |
+
padding: 16px 12px;
|
1755 |
+
background: rgba(255, 255, 255, 0.02);
|
1756 |
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
1757 |
+
border-radius: 12px;
|
1758 |
+
color: var(--text-secondary);
|
1759 |
+
font-size: 0.75rem;
|
1760 |
+
font-weight: 500;
|
1761 |
+
cursor: pointer;
|
1762 |
+
transition: all 0.3s ease;
|
1763 |
+
text-align: center;
|
1764 |
+
min-height: 80px;
|
1765 |
+
}
|
1766 |
+
|
1767 |
+
.download-button:hover {
|
1768 |
+
background: rgba(255, 255, 255, 0.05);
|
1769 |
+
border-color: rgba(255, 255, 255, 0.1);
|
1770 |
+
color: var(--text-primary);
|
1771 |
+
transform: translateY(-2px);
|
1772 |
+
}
|
1773 |
+
|
1774 |
+
.download-button.active {
|
1775 |
+
background: var(--gradient-secondary);
|
1776 |
+
border-color: var(--accent-secondary);
|
1777 |
+
color: white;
|
1778 |
+
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3);
|
1779 |
+
}
|
1780 |
+
|
1781 |
+
.format-icon {
|
1782 |
+
display: flex;
|
1783 |
+
align-items: center;
|
1784 |
+
justify-content: center;
|
1785 |
+
color: rgba(255, 255, 255, 0.8);
|
1786 |
+
}
|
1787 |
+
|
1788 |
+
.format-button-container.active .format-icon {
|
1789 |
+
color: white;
|
1790 |
+
}
|
1791 |
+
|
1792 |
+
/* Unified Format Buttons */
|
1793 |
+
.format-buttons {
|
1794 |
+
display: grid;
|
1795 |
+
grid-template-columns: repeat(4, 1fr);
|
1796 |
+
gap: 16px;
|
1797 |
+
margin: 0;
|
1798 |
+
}
|
1799 |
+
|
1800 |
+
.format-button-container {
|
1801 |
+
display: flex;
|
1802 |
+
align-items: stretch;
|
1803 |
+
background: rgba(100, 100, 100, 0.1);
|
1804 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
1805 |
+
border-radius: 12px;
|
1806 |
+
overflow: hidden;
|
1807 |
+
transition: all 0.3s ease;
|
1808 |
+
backdrop-filter: blur(15px);
|
1809 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
1810 |
+
}
|
1811 |
+
|
1812 |
+
.format-button-container:hover {
|
1813 |
+
background: rgba(255, 255, 255, 0.05);
|
1814 |
+
border-color: rgba(255, 255, 255, 0.15);
|
1815 |
+
transform: translateY(-1px);
|
1816 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
1817 |
+
}
|
1818 |
+
|
1819 |
+
.format-button-container.active {
|
1820 |
+
background: var(--gradient-primary);
|
1821 |
+
border-color: var(--accent-primary);
|
1822 |
+
box-shadow: 0 4px 15px rgba(79, 172, 254, 0.3);
|
1823 |
+
}
|
1824 |
+
|
1825 |
+
.format-preview-button {
|
1826 |
+
width: 100%;
|
1827 |
+
display: flex;
|
1828 |
+
flex-direction: column;
|
1829 |
+
align-items: center;
|
1830 |
+
justify-content: center;
|
1831 |
+
gap: 8px;
|
1832 |
+
padding: 20px 16px;
|
1833 |
+
background: none;
|
1834 |
+
border: none;
|
1835 |
+
color: var(--text-secondary);
|
1836 |
+
cursor: pointer;
|
1837 |
+
transition: all 0.3s ease;
|
1838 |
+
min-height: 100px;
|
1839 |
+
}
|
1840 |
+
|
1841 |
+
.format-preview-button:disabled {
|
1842 |
+
opacity: 0.5;
|
1843 |
+
cursor: not-allowed;
|
1844 |
+
}
|
1845 |
+
|
1846 |
+
.format-button-container.active .format-preview-button {
|
1847 |
+
color: white;
|
1848 |
+
}
|
1849 |
+
|
1850 |
+
.format-label {
|
1851 |
+
font-size: 0.875rem;
|
1852 |
+
font-weight: 500;
|
1853 |
+
text-align: center;
|
1854 |
+
}
|
1855 |
+
|
1856 |
+
.format-download-button {
|
1857 |
+
display: none;
|
1858 |
+
}
|
1859 |
+
|
1860 |
+
.format-download-button:hover {
|
1861 |
+
background: rgba(255, 255, 255, 0.15);
|
1862 |
+
color: #ffffff;
|
1863 |
+
transform: scale(1.1);
|
1864 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
1865 |
+
}
|
1866 |
+
|
1867 |
+
.format-button-container.active .format-download-button {
|
1868 |
+
background: rgba(255, 255, 255, 0.2);
|
1869 |
+
color: white;
|
1870 |
+
}
|
1871 |
+
|
1872 |
+
/* Content Preview */
|
1873 |
+
.content-preview {
|
1874 |
+
position: relative;
|
1875 |
+
flex: 1;
|
1876 |
+
background: rgba(255, 255, 255, 0.05);
|
1877 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
1878 |
+
border-radius: 16px;
|
1879 |
+
padding: 24px 60px 24px 24px;
|
1880 |
+
backdrop-filter: blur(20px);
|
1881 |
+
overflow: hidden;
|
1882 |
+
}
|
1883 |
+
|
1884 |
+
.content-preview-download {
|
1885 |
+
position: absolute;
|
1886 |
+
top: 16px;
|
1887 |
+
right: 16px;
|
1888 |
+
display: flex;
|
1889 |
+
align-items: center;
|
1890 |
+
justify-content: center;
|
1891 |
+
width: 36px;
|
1892 |
+
height: 36px;
|
1893 |
+
background: rgba(255, 255, 255, 0.1);
|
1894 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
1895 |
+
border-radius: 8px;
|
1896 |
+
color: rgba(255, 255, 255, 0.8);
|
1897 |
+
cursor: pointer;
|
1898 |
+
transition: all 0.3s ease;
|
1899 |
+
z-index: 10;
|
1900 |
+
backdrop-filter: blur(10px);
|
1901 |
+
}
|
1902 |
+
|
1903 |
+
.content-preview-download:hover {
|
1904 |
+
background: rgba(255, 255, 255, 0.15);
|
1905 |
+
border-color: rgba(79, 172, 254, 0.3);
|
1906 |
+
color: #4facfe;
|
1907 |
+
transform: scale(1.05);
|
1908 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
1909 |
+
}
|
1910 |
+
|
1911 |
+
.text-content {
|
1912 |
+
background: rgba(0, 0, 0, 0.2);
|
1913 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
1914 |
+
border-radius: 12px;
|
1915 |
+
padding: 20px;
|
1916 |
+
overflow: auto;
|
1917 |
+
max-height: 500px;
|
1918 |
+
}
|
1919 |
+
|
1920 |
+
.text-preview {
|
1921 |
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
1922 |
+
font-size: 0.875rem;
|
1923 |
+
line-height: 1.6;
|
1924 |
+
color: var(--text-primary);
|
1925 |
+
white-space: pre-wrap;
|
1926 |
+
margin: 0;
|
1927 |
+
}
|
1928 |
+
|
1929 |
+
/* HTML Preview Styles */
|
1930 |
+
.html-preview {
|
1931 |
+
background: rgba(0, 0, 0, 0.2);
|
1932 |
+
color: var(--text-primary);
|
1933 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
1934 |
+
border-radius: 12px;
|
1935 |
+
padding: 24px;
|
1936 |
+
overflow: auto;
|
1937 |
+
max-height: 500px;
|
1938 |
+
line-height: 1.6;
|
1939 |
+
}
|
1940 |
+
|
1941 |
+
/* Headers are now styled in the emoji fix section below */
|
1942 |
+
|
1943 |
+
.html-preview p {
|
1944 |
+
margin: 16px 0;
|
1945 |
+
color: var(--text-secondary);
|
1946 |
+
}
|
1947 |
+
|
1948 |
+
.html-preview ul,
|
1949 |
+
.html-preview ol {
|
1950 |
+
margin: 16px 0;
|
1951 |
+
padding-left: 24px;
|
1952 |
+
}
|
1953 |
+
|
1954 |
+
.html-preview li {
|
1955 |
+
margin: 8px 0;
|
1956 |
+
color: var(--text-secondary);
|
1957 |
+
}
|
1958 |
+
|
1959 |
+
.html-preview table {
|
1960 |
+
width: 100%;
|
1961 |
+
border-collapse: collapse;
|
1962 |
+
background: rgba(0, 0, 0, 0.3);
|
1963 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
1964 |
+
border-radius: 8px;
|
1965 |
+
overflow: hidden;
|
1966 |
+
margin: 24px 0;
|
1967 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
1968 |
+
}
|
1969 |
+
|
1970 |
+
.html-preview th,
|
1971 |
+
.html-preview td {
|
1972 |
+
padding: 12px 16px;
|
1973 |
+
text-align: left;
|
1974 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
1975 |
+
}
|
1976 |
+
|
1977 |
+
.html-preview th {
|
1978 |
+
background: linear-gradient(135deg, #4facfe, #8b5cf6);
|
1979 |
+
color: white;
|
1980 |
+
font-weight: 600;
|
1981 |
+
font-size: 0.875rem;
|
1982 |
+
text-transform: uppercase;
|
1983 |
+
letter-spacing: 0.5px;
|
1984 |
+
}
|
1985 |
+
|
1986 |
+
.html-preview td {
|
1987 |
+
color: var(--text-primary);
|
1988 |
+
}
|
1989 |
+
|
1990 |
+
.html-preview tr:hover {
|
1991 |
+
background: rgba(79, 172, 254, 0.1);
|
1992 |
+
}
|
1993 |
+
|
1994 |
+
.html-preview code {
|
1995 |
+
background: rgba(79, 172, 254, 0.1);
|
1996 |
+
color: #4facfe;
|
1997 |
+
padding: 2px 6px;
|
1998 |
+
border-radius: 4px;
|
1999 |
+
font-family: 'SF Mono', Monaco, monospace;
|
2000 |
+
font-size: 0.875em;
|
2001 |
+
}
|
2002 |
+
|
2003 |
+
.html-preview pre {
|
2004 |
+
background: rgba(0, 0, 0, 0.4);
|
2005 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
2006 |
+
border-radius: 8px;
|
2007 |
+
padding: 16px;
|
2008 |
+
overflow-x: auto;
|
2009 |
+
margin: 16px 0;
|
2010 |
+
}
|
2011 |
+
|
2012 |
+
.html-preview pre code {
|
2013 |
+
background: none;
|
2014 |
+
color: var(--text-primary);
|
2015 |
+
padding: 0;
|
2016 |
+
}
|
2017 |
+
|
2018 |
+
.html-preview blockquote {
|
2019 |
+
border-left: 4px solid var(--accent-primary);
|
2020 |
+
background: rgba(79, 172, 254, 0.1);
|
2021 |
+
padding: 16px 20px;
|
2022 |
+
margin: 16px 0;
|
2023 |
+
border-radius: 0 8px 8px 0;
|
2024 |
+
font-style: italic;
|
2025 |
+
color: var(--text-secondary);
|
2026 |
+
}
|
2027 |
+
|
2028 |
+
.html-preview strong {
|
2029 |
+
color: var(--accent-primary);
|
2030 |
+
font-weight: 600;
|
2031 |
+
}
|
2032 |
+
|
2033 |
+
.html-preview em {
|
2034 |
+
color: var(--accent-secondary);
|
2035 |
+
font-style: italic;
|
2036 |
+
}
|
2037 |
+
|
2038 |
+
.html-preview a {
|
2039 |
+
color: var(--accent-primary);
|
2040 |
+
text-decoration: none;
|
2041 |
+
border-bottom: 1px solid rgba(79, 172, 254, 0.3);
|
2042 |
+
transition: all 0.3s ease;
|
2043 |
+
}
|
2044 |
+
|
2045 |
+
.html-preview a:hover {
|
2046 |
+
border-bottom-color: var(--accent-primary);
|
2047 |
+
color: var(--accent-secondary);
|
2048 |
+
}
|
2049 |
+
|
2050 |
+
/* Fix emoji rendering in HTML preview */
|
2051 |
+
.html-preview {
|
2052 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
2053 |
+
}
|
2054 |
+
|
2055 |
+
/* Force proper emoji rendering - override all text styling */
|
2056 |
+
.html-preview * {
|
2057 |
+
font-variant-emoji: emoji !important;
|
2058 |
+
}
|
2059 |
+
|
2060 |
+
/* Completely reset styling for any element that might contain emojis */
|
2061 |
+
.html-preview h1,
|
2062 |
+
.html-preview h2,
|
2063 |
+
.html-preview h3,
|
2064 |
+
.html-preview h4,
|
2065 |
+
.html-preview h5,
|
2066 |
+
.html-preview h6,
|
2067 |
+
.html-preview p,
|
2068 |
+
.html-preview td,
|
2069 |
+
.html-preview th,
|
2070 |
+
.html-preview li,
|
2071 |
+
.html-preview span,
|
2072 |
+
.html-preview div {
|
2073 |
+
background: none !important;
|
2074 |
+
-webkit-background-clip: initial !important;
|
2075 |
+
-webkit-text-fill-color: initial !important;
|
2076 |
+
background-clip: initial !important;
|
2077 |
+
color: var(--text-primary) !important;
|
2078 |
+
}
|
2079 |
+
|
2080 |
+
/* Specific fix for headers to maintain their styling but not affect emojis */
|
2081 |
+
.html-preview h1 {
|
2082 |
+
font-size: 2rem;
|
2083 |
+
font-weight: 600;
|
2084 |
+
color: var(--text-primary) !important;
|
2085 |
+
}
|
2086 |
+
.html-preview h2 {
|
2087 |
+
font-size: 1.5rem;
|
2088 |
+
font-weight: 600;
|
2089 |
+
color: var(--text-primary) !important;
|
2090 |
+
}
|
2091 |
+
.html-preview h3 {
|
2092 |
+
font-size: 1.25rem;
|
2093 |
+
font-weight: 600;
|
2094 |
+
color: var(--text-primary) !important;
|
2095 |
+
}
|
2096 |
+
|
2097 |
+
/* Responsive Design */
|
2098 |
+
@media (max-width: 768px) {
|
2099 |
+
.categories-section {
|
2100 |
+
flex-direction: column;
|
2101 |
+
}
|
2102 |
+
|
2103 |
+
.header-content {
|
2104 |
+
flex-direction: column;
|
2105 |
+
gap: 16px;
|
2106 |
+
}
|
2107 |
+
|
2108 |
+
.download-buttons {
|
2109 |
+
grid-template-columns: repeat(2, 1fr);
|
2110 |
+
}
|
2111 |
+
|
2112 |
+
.format-buttons {
|
2113 |
+
grid-template-columns: repeat(2, 1fr);
|
2114 |
+
gap: 12px;
|
2115 |
+
}
|
2116 |
+
|
2117 |
+
.mode-buttons {
|
2118 |
+
display: grid;
|
2119 |
+
grid-template-columns: repeat(2, 1fr);
|
2120 |
+
gap: 8px;
|
2121 |
+
}
|
2122 |
+
}
|
2123 |
+
|
2124 |
+
@media (max-width: 480px) {
|
2125 |
+
.results-container {
|
2126 |
+
gap: 16px;
|
2127 |
+
}
|
2128 |
+
|
2129 |
+
.results-header,
|
2130 |
+
.category-group {
|
2131 |
+
padding: 16px;
|
2132 |
+
}
|
2133 |
+
|
2134 |
+
.content-preview {
|
2135 |
+
padding: 16px 50px 16px 16px;
|
2136 |
+
}
|
2137 |
+
|
2138 |
+
.download-buttons {
|
2139 |
+
grid-template-columns: 1fr;
|
2140 |
+
}
|
2141 |
+
|
2142 |
+
.format-buttons {
|
2143 |
+
grid-template-columns: repeat(2, 1fr);
|
2144 |
+
}
|
2145 |
+
|
2146 |
+
.mode-buttons {
|
2147 |
+
grid-template-columns: 1fr;
|
2148 |
+
}
|
2149 |
+
}/* Plain
|
2150 |
+
Markdown Preview - No Styling */
|
2151 |
+
.markdown-plain {
|
2152 |
+
background: rgba(0, 0, 0, 0.2);
|
2153 |
+
color: var(--text-primary);
|
2154 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
2155 |
+
border-radius: 12px;
|
2156 |
+
padding: 24px;
|
2157 |
+
overflow: auto;
|
2158 |
+
max-height: 500px;
|
2159 |
+
line-height: 1.6;
|
2160 |
+
font-family: monospace;
|
2161 |
+
}
|
2162 |
+
|
2163 |
+
.markdown-plain h1,
|
2164 |
+
.markdown-plain h2,
|
2165 |
+
.markdown-plain h3,
|
2166 |
+
.markdown-plain h4,
|
2167 |
+
.markdown-plain h5,
|
2168 |
+
.markdown-plain h6 {
|
2169 |
+
color: var(--text-primary);
|
2170 |
+
margin: 16px 0 8px 0;
|
2171 |
+
font-weight: normal;
|
2172 |
+
font-size: 1rem;
|
2173 |
+
}
|
2174 |
+
|
2175 |
+
.markdown-plain p {
|
2176 |
+
margin: 8px 0;
|
2177 |
+
color: var(--text-primary);
|
2178 |
+
}
|
2179 |
+
|
2180 |
+
.markdown-plain table {
|
2181 |
+
border-collapse: collapse;
|
2182 |
+
margin: 16px 0;
|
2183 |
+
font-family: monospace;
|
2184 |
+
}
|
2185 |
+
|
2186 |
+
.markdown-plain th,
|
2187 |
+
.markdown-plain td {
|
2188 |
+
border: 1px solid var(--text-muted);
|
2189 |
+
padding: 8px 12px;
|
2190 |
+
text-align: left;
|
2191 |
+
color: var(--text-primary);
|
2192 |
+
background: transparent;
|
2193 |
+
}
|
2194 |
+
|
2195 |
+
.markdown-plain th {
|
2196 |
+
font-weight: normal;
|
2197 |
+
background: transparent;
|
2198 |
+
}
|
2199 |
+
|
2200 |
+
.markdown-plain tr:hover {
|
2201 |
+
background: transparent;
|
2202 |
+
}
|
2203 |
+
|
2204 |
+
.markdown-plain strong {
|
2205 |
+
font-weight: bold;
|
2206 |
+
color: var(--text-primary);
|
2207 |
+
}
|
2208 |
+
|
2209 |
+
.markdown-plain em {
|
2210 |
+
font-style: italic;
|
2211 |
+
color: var(--text-primary);
|
2212 |
+
}
|
2213 |
+
|
2214 |
+
.markdown-plain ul,
|
2215 |
+
.markdown-plain ol {
|
2216 |
+
margin: 8px 0;
|
2217 |
+
padding-left: 20px;
|
2218 |
+
color: var(--text-primary);
|
2219 |
+
}
|
2220 |
+
|
2221 |
+
.markdown-plain li {
|
2222 |
+
margin: 4px 0;
|
2223 |
+
color: var(--text-primary);
|
2224 |
+
}.mark
|
2225 |
+
div.markdown-plain a,
|
2226 |
+
div.markdown-plain a:link,
|
2227 |
+
div.markdown-plain a:visited {
|
2228 |
+
color: #ff9500 !important;
|
2229 |
+
text-decoration: none !important;
|
2230 |
+
border-bottom: none !important;
|
2231 |
+
border: none !important;
|
2232 |
+
}
|
2233 |
+
|
2234 |
+
div.markdown-plain a:hover {
|
2235 |
+
color: #ffb347 !important;
|
2236 |
+
text-decoration: underline !important;
|
2237 |
+
}
|
2238 |
+
|
2239 |
+
div.markdown-plain a:active {
|
2240 |
+
color: #cc7700 !important;
|
2241 |
+
}
|
src/index.js
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react';
|
2 |
+
import ReactDOM from 'react-dom/client';
|
3 |
+
import App from './App';
|
4 |
+
|
5 |
+
const root = ReactDOM.createRoot(document.getElementById('root'));
|
6 |
+
root.render(
|
7 |
+
<React.StrictMode>
|
8 |
+
<App />
|
9 |
+
</React.StrictMode>
|
10 |
+
);
|
src/utils/encryption.js
ADDED
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Secure encryption utility for API key storage
|
2 |
+
// Uses device-specific salt and multiple encryption layers
|
3 |
+
|
4 |
+
class ApiKeyEncryption {
|
5 |
+
constructor() {
|
6 |
+
// Generate a device-specific salt based on browser fingerprint
|
7 |
+
this.salt = this.generateDeviceSalt();
|
8 |
+
this.additionalSalt = 'Luna-OCR-2025-Security-Salt';
|
9 |
+
}
|
10 |
+
|
11 |
+
// Generate a consistent salt based on device characteristics
|
12 |
+
generateDeviceSalt() {
|
13 |
+
const canvas = document.createElement('canvas');
|
14 |
+
const ctx = canvas.getContext('2d');
|
15 |
+
ctx.textBaseline = 'top';
|
16 |
+
ctx.font = '14px Arial';
|
17 |
+
ctx.fillText('Luna OCR Device Salt', 2, 2);
|
18 |
+
|
19 |
+
const fingerprint = [
|
20 |
+
navigator.userAgent,
|
21 |
+
navigator.language,
|
22 |
+
window.screen.width + 'x' + window.screen.height,
|
23 |
+
new Date().getTimezoneOffset(),
|
24 |
+
canvas.toDataURL()
|
25 |
+
].join('|');
|
26 |
+
|
27 |
+
return this.simpleHash(fingerprint);
|
28 |
+
}
|
29 |
+
|
30 |
+
// Simple hash function
|
31 |
+
simpleHash(str) {
|
32 |
+
let hash = 0;
|
33 |
+
for (let i = 0; i < str.length; i++) {
|
34 |
+
const char = str.charCodeAt(i);
|
35 |
+
hash = ((hash << 5) - hash) + char;
|
36 |
+
hash = hash & hash; // Convert to 32-bit integer
|
37 |
+
}
|
38 |
+
return Math.abs(hash).toString(36);
|
39 |
+
}
|
40 |
+
|
41 |
+
// Multi-layer encryption with salt and additional security
|
42 |
+
encrypt(text) {
|
43 |
+
if (!text || text.trim() === '') return '';
|
44 |
+
|
45 |
+
// Layer 1: Add timestamp and random padding
|
46 |
+
const timestamp = Date.now().toString(36);
|
47 |
+
const randomPadding = Math.random().toString(36).substring(2);
|
48 |
+
const paddedText = `${timestamp}:${text}:${randomPadding}`;
|
49 |
+
|
50 |
+
// Layer 2: XOR with device salt
|
51 |
+
const saltKey = this.salt + this.additionalSalt;
|
52 |
+
let encrypted = '';
|
53 |
+
|
54 |
+
for (let i = 0; i < paddedText.length; i++) {
|
55 |
+
const textChar = paddedText.charCodeAt(i);
|
56 |
+
const saltChar = saltKey.charCodeAt(i % saltKey.length);
|
57 |
+
const encryptedChar = textChar ^ saltChar;
|
58 |
+
encrypted += String.fromCharCode(encryptedChar);
|
59 |
+
}
|
60 |
+
|
61 |
+
// Layer 3: Base64 encode with additional obfuscation
|
62 |
+
const base64 = btoa(encrypted);
|
63 |
+
const obfuscated = base64.split('').reverse().join('');
|
64 |
+
|
65 |
+
return obfuscated;
|
66 |
+
}
|
67 |
+
|
68 |
+
// Multi-layer decryption
|
69 |
+
decrypt(encryptedText) {
|
70 |
+
if (!encryptedText || encryptedText.trim() === '') return '';
|
71 |
+
|
72 |
+
try {
|
73 |
+
// Layer 1: Reverse obfuscation and Base64 decode
|
74 |
+
const deobfuscated = encryptedText.split('').reverse().join('');
|
75 |
+
const encrypted = atob(deobfuscated);
|
76 |
+
|
77 |
+
// Layer 2: XOR decryption with device salt
|
78 |
+
const saltKey = this.salt + this.additionalSalt;
|
79 |
+
let decrypted = '';
|
80 |
+
|
81 |
+
for (let i = 0; i < encrypted.length; i++) {
|
82 |
+
const encryptedChar = encrypted.charCodeAt(i);
|
83 |
+
const saltChar = saltKey.charCodeAt(i % saltKey.length);
|
84 |
+
const decryptedChar = encryptedChar ^ saltChar;
|
85 |
+
decrypted += String.fromCharCode(decryptedChar);
|
86 |
+
}
|
87 |
+
|
88 |
+
// Layer 3: Extract original text from padded format
|
89 |
+
const parts = decrypted.split(':');
|
90 |
+
if (parts.length >= 3) {
|
91 |
+
// Remove timestamp and random padding, return original text
|
92 |
+
return parts.slice(1, -1).join(':');
|
93 |
+
}
|
94 |
+
|
95 |
+
return decrypted; // Fallback for old format
|
96 |
+
} catch (error) {
|
97 |
+
console.warn('Failed to decrypt API key:', error);
|
98 |
+
return '';
|
99 |
+
}
|
100 |
+
}
|
101 |
+
|
102 |
+
// Store encrypted API key with obfuscated key name
|
103 |
+
storeApiKey(apiKey) {
|
104 |
+
if (!apiKey || apiKey.trim() === '') {
|
105 |
+
localStorage.removeItem('luna_secure_config_v2');
|
106 |
+
return;
|
107 |
+
}
|
108 |
+
|
109 |
+
const encrypted = this.encrypt(apiKey);
|
110 |
+
localStorage.setItem('luna_secure_config_v2', encrypted);
|
111 |
+
}
|
112 |
+
|
113 |
+
// Retrieve and decrypt API key
|
114 |
+
retrieveApiKey() {
|
115 |
+
const encrypted = localStorage.getItem('luna_secure_config_v2');
|
116 |
+
if (!encrypted) return '';
|
117 |
+
|
118 |
+
return this.decrypt(encrypted);
|
119 |
+
}
|
120 |
+
|
121 |
+
// Clear stored API key
|
122 |
+
clearApiKey() {
|
123 |
+
localStorage.removeItem('luna_secure_config_v2');
|
124 |
+
}
|
125 |
+
|
126 |
+
// Check if API key exists
|
127 |
+
hasStoredApiKey() {
|
128 |
+
return !!localStorage.getItem('luna_secure_config_v2');
|
129 |
+
}
|
130 |
+
}
|
131 |
+
|
132 |
+
export default new ApiKeyEncryption();
|
start-dev.js
ADDED
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#!/usr/bin/env node
|
2 |
+
|
3 |
+
const { spawn } = require('child_process');
|
4 |
+
const path = require('path');
|
5 |
+
|
6 |
+
console.log('🚀 Starting Luna OCR Development Environment...\n');
|
7 |
+
|
8 |
+
// Start backend server
|
9 |
+
console.log('📡 Starting backend server...');
|
10 |
+
const backend = spawn('npm', ['start'], {
|
11 |
+
cwd: path.join(__dirname, 'server'),
|
12 |
+
stdio: 'inherit',
|
13 |
+
shell: true
|
14 |
+
});
|
15 |
+
|
16 |
+
// Wait a bit for backend to start
|
17 |
+
setTimeout(() => {
|
18 |
+
console.log('\n🎨 Starting frontend...');
|
19 |
+
const frontend = spawn('npm', ['start'], {
|
20 |
+
stdio: 'inherit',
|
21 |
+
shell: true
|
22 |
+
});
|
23 |
+
|
24 |
+
// Handle process termination
|
25 |
+
process.on('SIGINT', () => {
|
26 |
+
console.log('\n🛑 Shutting down...');
|
27 |
+
backend.kill();
|
28 |
+
frontend.kill();
|
29 |
+
process.exit();
|
30 |
+
});
|
31 |
+
|
32 |
+
frontend.on('close', (code) => {
|
33 |
+
console.log(`Frontend exited with code ${code}`);
|
34 |
+
backend.kill();
|
35 |
+
});
|
36 |
+
|
37 |
+
backend.on('close', (code) => {
|
38 |
+
console.log(`Backend exited with code ${code}`);
|
39 |
+
frontend.kill();
|
40 |
+
});
|
41 |
+
|
42 |
+
}, 2000);
|
43 |
+
|
44 |
+
backend.on('close', (code) => {
|
45 |
+
console.log(`Backend exited with code ${code}`);
|
46 |
+
});
|