Spaces:
Sleeping
Sleeping
| 'use client'; | |
| /** | |
| * OCR Scanner Page | |
| * | |
| * Capture or upload images to extract text using OCR. | |
| */ | |
| import { useState, useRef, useCallback } from 'react'; | |
| import { | |
| Camera, | |
| Upload, | |
| FileText, | |
| Copy, | |
| Check, | |
| Loader2, | |
| X, | |
| RotateCcw, | |
| AlertCircle, | |
| } from 'lucide-react'; | |
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; | |
| import { AppShell } from '@/components/layout/AppShell'; | |
| import { AskAIPanel } from '@/components/common/AskAIPanel'; | |
| import { useOCRProcessor, useOCRStatus } from '@/lib/hooks/use-ocr'; | |
| import type { OCRResponse, StructuredNote } from '@/lib/types/ocr'; | |
| export default function ScannerPage() { | |
| const [activeTab, setActiveTab] = useState('upload'); | |
| const [preview, setPreview] = useState<string | null>(null); | |
| const [result, setResult] = useState<OCRResponse | null>(null); | |
| const [copied, setCopied] = useState(false); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const { data: status } = useOCRStatus(); | |
| const { processFile, isProcessing, reset } = useOCRProcessor(); | |
| const handleFileSelect = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = event.target.files?.[0]; | |
| if (!file) return; | |
| // Create preview | |
| const reader = new FileReader(); | |
| reader.onloadend = () => { | |
| setPreview(reader.result as string); | |
| }; | |
| reader.readAsDataURL(file); | |
| // Process file | |
| const ocrResult = await processFile(file); | |
| if (ocrResult) { | |
| setResult(ocrResult); | |
| } | |
| }, [processFile]); | |
| const handleDrop = useCallback(async (event: React.DragEvent<HTMLDivElement>) => { | |
| event.preventDefault(); | |
| const file = event.dataTransfer.files?.[0]; | |
| if (!file) return; | |
| // Validate type | |
| const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp', 'image/bmp']; | |
| if (!validTypes.includes(file.type)) { | |
| return; | |
| } | |
| // Create preview | |
| const reader = new FileReader(); | |
| reader.onloadend = () => { | |
| setPreview(reader.result as string); | |
| }; | |
| reader.readAsDataURL(file); | |
| // Process file | |
| const ocrResult = await processFile(file); | |
| if (ocrResult) { | |
| setResult(ocrResult); | |
| } | |
| }, [processFile]); | |
| const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => { | |
| event.preventDefault(); | |
| }; | |
| const handleCopy = async (text: string) => { | |
| await navigator.clipboard.writeText(text); | |
| setCopied(true); | |
| setTimeout(() => setCopied(false), 2000); | |
| }; | |
| const handleReset = () => { | |
| setPreview(null); | |
| setResult(null); | |
| reset(); | |
| if (fileInputRef.current) { | |
| fileInputRef.current.value = ''; | |
| } | |
| }; | |
| const isAvailable = status?.available ?? true; | |
| // Build context for AI from OCR result | |
| const buildContext = () => { | |
| if (!result) return ""; | |
| let context = "Extracted text from scanned image:\n\n"; | |
| if (result.structured) { | |
| if (result.structured.title) { | |
| context += `Title: ${result.structured.title}\n\n`; | |
| } | |
| context += `Content:\n${result.structured.content}\n\n`; | |
| if (result.structured.key_points.length > 0) { | |
| context += `Key Points:\n`; | |
| result.structured.key_points.forEach((point, i) => { | |
| context += `${i + 1}. ${point}\n`; | |
| }); | |
| context += `\n`; | |
| } | |
| if (result.structured.tags.length > 0) { | |
| context += `Tags: ${result.structured.tags.join(', ')}\n`; | |
| } | |
| } else { | |
| context += result.raw_text; | |
| } | |
| return context; | |
| }; | |
| const suggestedQuestions = result ? [ | |
| "Summarize the main points from this note", | |
| "What are the key concepts in this text?", | |
| "Can you explain this in simpler terms?", | |
| "What questions should I ask about this content?", | |
| "How can I organize this information better?" | |
| ] : [ | |
| "Summarize the main points", | |
| "What are the key takeaways?", | |
| "Explain this in simpler terms", | |
| "What questions should I ask about this?" | |
| ]; | |
| return ( | |
| <AppShell> | |
| <div className="flex-1 overflow-y-auto"> | |
| <div className="container mx-auto py-6 space-y-6"> | |
| {/* Header */} | |
| <div> | |
| <h1 className="text-3xl font-bold tracking-tight">Note Scanner</h1> | |
| <p className="text-muted-foreground"> | |
| Capture handwritten or printed notes and convert them to text | |
| </p> | |
| </div> | |
| {/* Status Alert */} | |
| {status && !status.available && ( | |
| <Card className="border-amber-500"> | |
| <CardContent className="p-6"> | |
| <div className="space-y-4"> | |
| <div className="flex items-start gap-3"> | |
| <AlertCircle className="h-6 w-6 text-amber-500 flex-shrink-0 mt-0.5" /> | |
| <div className="flex-1"> | |
| <h3 className="font-semibold text-lg mb-2">Tesseract OCR Required</h3> | |
| <p className="text-sm text-muted-foreground mb-4"> | |
| The Note Scanner requires Tesseract OCR to be installed on your system to extract text from images. | |
| </p> | |
| <div className="space-y-3 text-sm"> | |
| <div> | |
| <p className="font-medium mb-2">Quick Install Options:</p> | |
| <div className="space-y-2 pl-4"> | |
| <div className="flex items-start gap-2"> | |
| <span className="text-primary font-mono">1.</span> | |
| <div> | |
| <span className="font-medium">Chocolatey:</span> | |
| <code className="block mt-1 bg-muted px-2 py-1 rounded text-xs"> | |
| choco install tesseract | |
| </code> | |
| </div> | |
| </div> | |
| <div className="flex items-start gap-2"> | |
| <span className="text-primary font-mono">2.</span> | |
| <div> | |
| <span className="font-medium">Winget:</span> | |
| <code className="block mt-1 bg-muted px-2 py-1 rounded text-xs"> | |
| winget install --id=UB-Mannheim.TesseractOCR -e | |
| </code> | |
| </div> | |
| </div> | |
| <div className="flex items-start gap-2"> | |
| <span className="text-primary font-mono">3.</span> | |
| <div> | |
| <span className="font-medium">Manual Download:</span> | |
| <a | |
| href="https://github.com/UB-Mannheim/tesseract/wiki" | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="block mt-1 text-primary hover:underline text-xs" | |
| > | |
| Download from GitHub → | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="pt-2 border-t"> | |
| <p className="font-medium mb-1">After Installation:</p> | |
| <ol className="list-decimal list-inside space-y-1 text-muted-foreground pl-2"> | |
| <li>Restart your terminal/IDE</li> | |
| <li>Restart the backend API server</li> | |
| <li>Refresh this page</li> | |
| </ol> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| )} | |
| <div className="grid gap-6 lg:grid-cols-3"> | |
| {/* Input and Result Section - Takes 2 columns */} | |
| <div className="lg:col-span-2 space-y-6"> | |
| <div className="grid gap-6 md:grid-cols-2"> | |
| {/* Input Section */} | |
| <Card> | |
| <CardHeader> | |
| <CardTitle>Capture or Upload</CardTitle> | |
| <CardDescription> | |
| Take a photo or upload an image of your notes | |
| </CardDescription> | |
| </CardHeader> | |
| <CardContent> | |
| <Tabs value={activeTab} onValueChange={setActiveTab}> | |
| <TabsList className="grid w-full grid-cols-2"> | |
| <TabsTrigger value="upload"> | |
| <Upload className="h-4 w-4 mr-2" /> | |
| Upload | |
| </TabsTrigger> | |
| <TabsTrigger value="camera" disabled> | |
| <Camera className="h-4 w-4 mr-2" /> | |
| Camera | |
| </TabsTrigger> | |
| </TabsList> | |
| <TabsContent value="upload" className="mt-4"> | |
| {!preview ? ( | |
| <div | |
| className="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:border-primary/50 transition-colors" | |
| onClick={() => fileInputRef.current?.click()} | |
| onDrop={handleDrop} | |
| onDragOver={handleDragOver} | |
| > | |
| <Upload className="h-12 w-12 mx-auto mb-4 text-muted-foreground" /> | |
| <p className="font-medium">Click or drag image to upload</p> | |
| <p className="text-sm text-muted-foreground mt-1"> | |
| PNG, JPEG, GIF, WebP, or BMP up to 10MB | |
| </p> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept="image/*" | |
| className="hidden" | |
| onChange={handleFileSelect} | |
| disabled={!isAvailable || isProcessing} | |
| /> | |
| </div> | |
| ) : ( | |
| <div className="space-y-4"> | |
| <div className="relative"> | |
| <img | |
| src={preview} | |
| alt="Preview" | |
| className="w-full rounded-lg border" | |
| /> | |
| <Button | |
| variant="secondary" | |
| size="icon" | |
| className="absolute top-2 right-2" | |
| onClick={handleReset} | |
| > | |
| <X className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| {isProcessing && ( | |
| <div className="flex items-center justify-center gap-2 text-muted-foreground"> | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| <span>Processing image...</span> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </TabsContent> | |
| <TabsContent value="camera" className="mt-4"> | |
| <div className="text-center py-8 text-muted-foreground"> | |
| <Camera className="h-12 w-12 mx-auto mb-4 opacity-20" /> | |
| <p>Camera capture coming soon</p> | |
| <p className="text-sm">Use the upload option for now</p> | |
| </div> | |
| </TabsContent> | |
| </Tabs> | |
| </CardContent> | |
| </Card> | |
| {/* Result Section */} | |
| <Card> | |
| <CardHeader> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <CardTitle>Extracted Text</CardTitle> | |
| <CardDescription> | |
| OCR results from your image | |
| </CardDescription> | |
| </div> | |
| {result && ( | |
| <div className="flex items-center gap-2"> | |
| {result.confidence !== undefined && result.confidence !== null && ( | |
| <Badge variant="outline"> | |
| {Math.round(result.confidence * 100)}% confidence | |
| </Badge> | |
| )} | |
| <Badge variant="secondary"> | |
| {result.processing_time_ms}ms | |
| </Badge> | |
| </div> | |
| )} | |
| </div> | |
| </CardHeader> | |
| <CardContent> | |
| {result ? ( | |
| <div className="space-y-4"> | |
| {/* Raw Text */} | |
| <div> | |
| <div className="flex items-center justify-between mb-2"> | |
| <h4 className="text-sm font-medium">Raw Text</h4> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => handleCopy(result.raw_text)} | |
| > | |
| {copied ? ( | |
| <Check className="h-4 w-4" /> | |
| ) : ( | |
| <Copy className="h-4 w-4" /> | |
| )} | |
| </Button> | |
| </div> | |
| <div className="p-3 bg-muted rounded-lg max-h-48 overflow-y-auto"> | |
| <pre className="text-sm whitespace-pre-wrap font-mono"> | |
| {result.raw_text || 'No text detected'} | |
| </pre> | |
| </div> | |
| </div> | |
| {/* Structured Result */} | |
| {result.structured && ( | |
| <StructuredResult note={result.structured} onCopy={handleCopy} /> | |
| )} | |
| {/* Actions */} | |
| <div className="flex gap-2"> | |
| <Button variant="outline" onClick={handleReset}> | |
| <RotateCcw className="h-4 w-4 mr-2" /> | |
| Scan Another | |
| </Button> | |
| <Button | |
| onClick={() => | |
| handleCopy( | |
| result.structured?.content || result.raw_text | |
| ) | |
| } | |
| > | |
| <Copy className="h-4 w-4 mr-2" /> | |
| Copy All | |
| </Button> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="text-center py-12 text-muted-foreground"> | |
| <FileText className="h-12 w-12 mx-auto mb-4 opacity-20" /> | |
| <p>No results yet</p> | |
| <p className="text-sm">Upload an image to extract text</p> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| </div> | |
| </div> | |
| {/* Ask AI Panel - Takes 1 column */} | |
| <div className="lg:col-span-1"> | |
| <AskAIPanel | |
| title="Ask AI About Scan" | |
| description="Get insights from the extracted text" | |
| context={buildContext()} | |
| suggestedQuestions={suggestedQuestions} | |
| className="sticky top-6" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </AppShell> | |
| ); | |
| } | |
| // Structured Result Component | |
| function StructuredResult({ | |
| note, | |
| onCopy | |
| }: { | |
| note: StructuredNote; | |
| onCopy: (text: string) => void; | |
| }) { | |
| return ( | |
| <div className="space-y-3"> | |
| {note.title && ( | |
| <div> | |
| <h4 className="text-sm font-medium mb-1">Title</h4> | |
| <p className="text-sm">{note.title}</p> | |
| </div> | |
| )} | |
| <div> | |
| <div className="flex items-center justify-between mb-1"> | |
| <h4 className="text-sm font-medium">Structured Content</h4> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => onCopy(note.content)} | |
| > | |
| <Copy className="h-3 w-3" /> | |
| </Button> | |
| </div> | |
| <div className="p-3 bg-muted rounded-lg max-h-48 overflow-y-auto"> | |
| <pre className="text-sm whitespace-pre-wrap"> | |
| {note.content} | |
| </pre> | |
| </div> | |
| </div> | |
| {note.key_points.length > 0 && ( | |
| <div> | |
| <h4 className="text-sm font-medium mb-1">Key Points</h4> | |
| <ul className="list-disc list-inside text-sm space-y-1"> | |
| {note.key_points.map((point, i) => ( | |
| <li key={i}>{point}</li> | |
| ))} | |
| </ul> | |
| </div> | |
| )} | |
| {note.dates_mentioned.length > 0 && ( | |
| <div> | |
| <h4 className="text-sm font-medium mb-1">Dates Mentioned</h4> | |
| <div className="flex flex-wrap gap-2"> | |
| {note.dates_mentioned.map((date, i) => ( | |
| <Badge key={i} variant="secondary">{date}</Badge> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {note.tags.length > 0 && ( | |
| <div> | |
| <h4 className="text-sm font-medium mb-1">Tags</h4> | |
| <div className="flex flex-wrap gap-2"> | |
| {note.tags.map((tag, i) => ( | |
| <Badge key={i} variant="outline">{tag}</Badge> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |