Spaces:
Sleeping
Sleeping
| 'use client'; | |
| import { useState, useRef } from 'react'; | |
| import ReactMarkdown from 'react-markdown'; | |
| import remarkGfm from 'remark-gfm'; | |
| import { Upload, FileText, Image as ImageIcon, Send, Loader2, RefreshCcw, MessageCircle, BookOpen, Camera } from 'lucide-react'; | |
| import clsx from 'clsx'; | |
| import WikipediaCard from '@/components/WikipediaCard'; | |
| import AskQuestionModal from '@/components/AskQuestionModal'; | |
| import QuizModal from '@/components/QuizModal'; | |
| import CameraModal from '@/components/CameraModal'; | |
| export default function Home() { | |
| const [file, setFile] = useState<File | null>(null); | |
| const [preview, setPreview] = useState<string | null>(null); | |
| const [loading, setLoading] = useState(false); | |
| const [result, setResult] = useState<string | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| const [topics, setTopics] = useState<string[]>([]); | |
| const [rawContent, setRawContent] = useState<string>(''); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| // Modal states | |
| const [askModalOpen, setAskModalOpen] = useState(false); | |
| const [quizModalOpen, setQuizModalOpen] = useState(false); | |
| const [cameraModalOpen, setCameraModalOpen] = useState(false); | |
| const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| if (e.target.files && e.target.files[0]) { | |
| const selectedFile = e.target.files[0]; | |
| setFileWithPreview(selectedFile); | |
| } | |
| }; | |
| const setFileWithPreview = (selectedFile: File) => { | |
| setFile(selectedFile); | |
| setError(null); | |
| if (selectedFile.type.startsWith('image/')) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| setPreview(e.target?.result as string); | |
| }; | |
| reader.readAsDataURL(selectedFile); | |
| } else { | |
| setPreview(null); | |
| } | |
| }; | |
| const handleCameraCapture = (capturedFile: File) => { | |
| setFileWithPreview(capturedFile); | |
| }; | |
| const handleSubmit = async () => { | |
| if (!file) { | |
| setError("Please select an image or document first."); | |
| return; | |
| } | |
| setLoading(true); | |
| setError(null); | |
| setResult(null); | |
| setTopics([]); | |
| setRawContent(''); | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| try { | |
| const res = await fetch('/api/analyze', { | |
| method: 'POST', | |
| body: formData, | |
| }); | |
| if (!res.ok) { | |
| const errData = await res.json(); | |
| throw new Error(errData.error || 'Failed to analyze'); | |
| } | |
| const data = await res.json(); | |
| const rawText = data.result || ""; | |
| setRawContent(rawText); | |
| let extractedTopics: string[] = []; | |
| const multiMatch = rawText.match(/\[\[TOPICS?:\s*(.*?)\]\]/i); | |
| if (multiMatch) { | |
| extractedTopics = multiMatch[1].split(',').map((t: string) => t.trim()).filter((t: string) => t.length > 0); | |
| } | |
| const cleanText = rawText.replace(/\[\[TOPICS?:.*?\]\]/i, "").trim(); | |
| setResult(cleanText); | |
| setTopics(extractedTopics); | |
| } catch (err: any) { | |
| setError(err.message || "Something went wrong."); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const reset = () => { | |
| setFile(null); | |
| setPreview(null); | |
| setResult(null); | |
| setError(null); | |
| setTopics([]); | |
| setRawContent(''); | |
| }; | |
| return ( | |
| <> | |
| <main className="min-h-screen p-6 md:p-12"> | |
| <div className="max-w-6xl mx-auto flex flex-col gap-8"> | |
| {/* Header */} | |
| <header className="flex items-center justify-between"> | |
| <h1 className="text-3xl font-bold tracking-tight text-gray-800 dark:text-gray-100 flex items-center gap-3"> | |
| <span className="p-2 bg-sky-100 dark:bg-sky-900 rounded-xl text-sky-600 dark:text-sky-300"> | |
| <ImageIcon size={24} /> | |
| </span> | |
| Socratic Lens | |
| </h1> | |
| <div className="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-800 rounded-full font-medium text-gray-600 dark:text-gray-400"> | |
| Beta | |
| </div> | |
| </header> | |
| {/* Main Interaction Area */} | |
| {!result ? ( | |
| <section className="flex-1 flex flex-col items-center justify-center min-h-[50vh] transition-all"> | |
| {/* Two Upload Boxes Side by Side */} | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full max-w-3xl"> | |
| {/* Upload Document Box */} | |
| <div | |
| onClick={() => fileInputRef.current?.click()} | |
| className={clsx( | |
| "w-full aspect-square rounded-[32px] border-4 border-dashed flex flex-col items-center justify-center cursor-pointer transition-all hover:scale-[1.02] active:scale-[0.98] overflow-hidden", | |
| file && !preview | |
| ? "border-green-400 bg-green-50 dark:bg-green-900/20" | |
| : "border-gray-300 dark:border-gray-700 hover:border-sky-400 hover:bg-sky-50 dark:hover:bg-gray-800" | |
| )} | |
| > | |
| <input | |
| type="file" | |
| ref={fileInputRef} | |
| onChange={handleFileChange} | |
| className="hidden" | |
| accept="image/*,application/pdf" | |
| /> | |
| {file && !preview ? ( | |
| <> | |
| <FileText className="w-12 h-12 text-green-600 mb-3" /> | |
| <p className="text-sm font-medium text-green-800 dark:text-green-300 text-center px-4 truncate max-w-full">{file.name}</p> | |
| <p className="text-xs text-green-600 dark:text-green-400 mt-1">Tap to change</p> | |
| </> | |
| ) : ( | |
| <> | |
| <Upload className="w-12 h-12 text-gray-400 mb-3" /> | |
| <p className="text-base font-medium text-gray-600 dark:text-gray-300">Upload Document</p> | |
| <p className="text-xs text-gray-400 mt-1">Images or PDFs</p> | |
| </> | |
| )} | |
| </div> | |
| {/* Take Photo Box - Opens Camera Modal */} | |
| <div | |
| onClick={() => setCameraModalOpen(true)} | |
| className={clsx( | |
| "w-full aspect-square rounded-[32px] border-4 border-dashed flex flex-col items-center justify-center cursor-pointer transition-all hover:scale-[1.02] active:scale-[0.98] overflow-hidden", | |
| preview | |
| ? "border-green-400 bg-green-50 dark:bg-green-900/20 p-2" | |
| : "border-gray-300 dark:border-gray-700 hover:border-sky-400 hover:bg-sky-50 dark:hover:bg-gray-800" | |
| )} | |
| > | |
| {preview ? ( | |
| <div className="relative w-full h-full"> | |
| <img | |
| src={preview} | |
| alt="Preview" | |
| className="w-full h-full object-contain rounded-2xl" | |
| /> | |
| <div className="absolute bottom-2 left-1/2 -translate-x-1/2 bg-black/60 text-white text-xs px-3 py-1 rounded-full"> | |
| Tap to retake | |
| </div> | |
| </div> | |
| ) : ( | |
| <> | |
| <Camera className="w-12 h-12 text-gray-400 mb-3" /> | |
| <p className="text-base font-medium text-gray-600 dark:text-gray-300">Take Photo</p> | |
| <p className="text-xs text-gray-400 mt-1">Use Camera</p> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| {/* Analyze Button */} | |
| <div className="mt-8"> | |
| <button | |
| onClick={handleSubmit} | |
| disabled={loading || !file} | |
| className={clsx( | |
| "flex items-center gap-3 px-8 py-4 rounded-full text-lg font-semibold shadow-xl transition-all", | |
| loading || !file | |
| ? "bg-gray-300 text-gray-500 cursor-not-allowed grayscale" | |
| : "bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:scale-105 active:scale-95" | |
| )} | |
| > | |
| {loading ? <Loader2 className="animate-spin" /> : <Send />} | |
| {loading ? "Analyzing..." : "Analyze with Lens"} | |
| </button> | |
| </div> | |
| {error && ( | |
| <div className="mt-6 p-4 bg-red-100 text-red-700 rounded-2xl flex items-center gap-2"> | |
| <span>⚠️</span> {error} | |
| </div> | |
| )} | |
| </section> | |
| ) : ( | |
| <section className="animate-fade-in-up"> | |
| <div className="flex justify-between items-center mb-6"> | |
| <button | |
| onClick={reset} | |
| className="flex items-center gap-2 text-sm font-medium text-gray-500 hover:text-black dark:hover:text-white transition-colors px-4 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800" | |
| > | |
| <RefreshCcw size={16} /> | |
| Analyze Another | |
| </button> | |
| <span className="text-xs text-gray-400 uppercase tracking-wider">AI Generated Content</span> | |
| </div> | |
| {/* 2-Column Layout */} | |
| <div className="flex flex-col lg:flex-row gap-8 justify-center"> | |
| {/* Main AI Content (Center/Left) */} | |
| <div className="flex-1 max-w-3xl"> | |
| <div className="bg-white dark:bg-neutral-900 rounded-[24px] p-6 md:p-8 shadow-xl border border-gray-200 dark:border-neutral-800"> | |
| <article className="prose prose-lg dark:prose-invert max-w-none markdown-content"> | |
| <ReactMarkdown remarkPlugins={[remarkGfm]}> | |
| {result} | |
| </ReactMarkdown> | |
| </article> | |
| </div> | |
| {/* Action Buttons */} | |
| <div className="flex flex-wrap gap-3 mt-6"> | |
| <button | |
| onClick={() => setAskModalOpen(true)} | |
| className="flex items-center gap-2 px-5 py-3 bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-700 rounded-xl font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-neutral-800 hover:border-sky-500 transition-all shadow-sm" | |
| > | |
| <MessageCircle size={18} className="text-sky-500" /> | |
| Ask a Question | |
| </button> | |
| <button | |
| onClick={() => setQuizModalOpen(true)} | |
| className="flex items-center gap-2 px-5 py-3 bg-sky-500 text-white rounded-xl font-medium hover:bg-sky-600 transition-all shadow-sm" | |
| > | |
| <BookOpen size={18} /> | |
| Quiz Me | |
| </button> | |
| </div> | |
| </div> | |
| {/* Sidebar (Right) - Wikipedia */} | |
| {topics.length > 0 && ( | |
| <div className="w-full lg:w-80 flex-shrink-0"> | |
| <WikipediaCard topics={topics} /> | |
| </div> | |
| )} | |
| </div> | |
| </section> | |
| )} | |
| </div> | |
| </main> | |
| {/* Modals */} | |
| <AskQuestionModal | |
| isOpen={askModalOpen} | |
| onClose={() => setAskModalOpen(false)} | |
| context={rawContent} | |
| /> | |
| <QuizModal | |
| isOpen={quizModalOpen} | |
| onClose={() => setQuizModalOpen(false)} | |
| context={rawContent} | |
| /> | |
| <CameraModal | |
| isOpen={cameraModalOpen} | |
| onClose={() => setCameraModalOpen(false)} | |
| onCapture={handleCameraCapture} | |
| /> | |
| </> | |
| ); | |
| } | |