Spaces:
Running
Running
| 'use client' | |
| import React, { useState, useEffect } from 'react' | |
| import { motion, AnimatePresence } from 'framer-motion' | |
| import { | |
| CheckCircle, | |
| XCircle, | |
| CaretRight, | |
| CaretLeft, | |
| Brain, | |
| Spinner, | |
| Trophy, | |
| ArrowClockwise, | |
| Lock, | |
| Key | |
| } from '@phosphor-icons/react' | |
| import Window from './Window' | |
| interface QuizQuestion { | |
| id: number | string | |
| question: string | |
| options: string[] | |
| correctAnswer?: string | number | boolean | |
| explanation?: string | |
| } | |
| interface QuizAppProps { | |
| onClose: () => void | |
| onMinimize: () => void | |
| onFocus?: () => void | |
| zIndex: number | |
| } | |
| export function QuizApp({ onClose, onMinimize, onFocus, zIndex }: QuizAppProps) { | |
| const [windowSize, setWindowSize] = useState({ width: 800, height: 600 }) | |
| const [passkey, setPasskey] = useState('') | |
| const [tempPasskey, setTempPasskey] = useState('') | |
| const [isLocked, setIsLocked] = useState(true) | |
| const [timeLeft, setTimeLeft] = useState<number | null>(null) | |
| const [timeLimit, setTimeLimit] = useState<number | null>(null) | |
| const [startTime, setStartTime] = useState<number | null>(null) | |
| const [questions, setQuestions] = useState<QuizQuestion[]>([]) | |
| const [loading, setLoading] = useState(false) | |
| const [error, setError] = useState<string | null>(null) | |
| const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0) | |
| const [answers, setAnswers] = useState<Record<string, string>>({}) | |
| const [completed, setCompleted] = useState(false) | |
| const [saving, setSaving] = useState(false) | |
| // Handle window resize | |
| useEffect(() => { | |
| const handleResize = () => { | |
| setWindowSize({ | |
| width: Math.min(800, window.innerWidth - 40), | |
| height: Math.min(600, window.innerHeight - 40) | |
| }) | |
| } | |
| handleResize() // Set initial size | |
| window.addEventListener('resize', handleResize) | |
| return () => window.removeEventListener('resize', handleResize) | |
| }, []) | |
| const handlePasskeySubmit = () => { | |
| if (tempPasskey.trim().length >= 4) { | |
| setPasskey(tempPasskey.trim()) | |
| setIsLocked(false) | |
| loadQuiz(tempPasskey.trim()) | |
| } else { | |
| alert('Passkey must be at least 4 characters') | |
| } | |
| } | |
| const loadQuiz = async (key: string) => { | |
| setLoading(true) | |
| setError(null) | |
| try { | |
| // Fetch from secure data using passkey | |
| const response = await fetch(`/api/data?key=${encodeURIComponent(key)}&folder=`) | |
| const data = await response.json() | |
| if (data.error) { | |
| throw new Error(data.error) | |
| } | |
| // Look for quiz.json | |
| const quizFile = data.files?.find((f: any) => f.name === 'quiz.json') | |
| if (!quizFile) { | |
| setError('No quiz found. Ask Claude to "create a quiz" first!') | |
| setLoading(false) | |
| return | |
| } | |
| // Parse content | |
| let quizData | |
| console.log('Raw quiz content:', quizFile.content) // Debug log | |
| if (typeof quizFile.content === 'string') { | |
| try { | |
| quizData = JSON.parse(quizFile.content) | |
| } catch (e) { | |
| console.error('JSON parse error:', e) | |
| // Maybe it's double encoded or raw text | |
| quizData = quizFile.content | |
| } | |
| } else { | |
| quizData = quizFile.content | |
| } | |
| if (!quizData) { | |
| throw new Error('Quiz file content is empty or invalid') | |
| } | |
| // Handle different structures (array or object with questions) | |
| let questionsArray = [] | |
| if (Array.isArray(quizData)) { | |
| questionsArray = quizData | |
| } else if (quizData.questions) { | |
| questionsArray = quizData.questions | |
| if (quizData.timeLimit) { | |
| const limitInSeconds = quizData.timeLimit * 60 | |
| setTimeLimit(limitInSeconds) | |
| setTimeLeft(limitInSeconds) | |
| } | |
| } else { | |
| throw new Error('Invalid quiz format: missing questions array') | |
| } | |
| if (questionsArray.length === 0) { | |
| throw new Error('Quiz has no questions') | |
| } | |
| // Ensure all questions have IDs (add them if missing) | |
| const questionsWithIds = questionsArray.map((q: any, index: number) => ({ | |
| ...q, | |
| id: q.id || `question_${index}` | |
| })) | |
| setQuestions(questionsWithIds) | |
| setStartTime(Date.now()) | |
| } catch (err: any) { | |
| console.error('Error loading quiz:', err) | |
| setError(err.message || 'Could not load quiz.json') | |
| } finally { | |
| setLoading(false) | |
| } | |
| } | |
| // Timer effect | |
| useEffect(() => { | |
| if (!loading && !error && !completed && !isLocked && timeLeft !== null && timeLeft > 0) { | |
| const timer = setInterval(() => { | |
| setTimeLeft(prev => { | |
| if (prev === null || prev <= 0) { | |
| clearInterval(timer) | |
| finishQuiz(true) // Time exceeded | |
| return 0 | |
| } | |
| return prev - 1 | |
| }) | |
| }, 1000) | |
| return () => clearInterval(timer) | |
| } | |
| }, [loading, error, completed, isLocked, timeLeft]) | |
| const formatTime = (seconds: number) => { | |
| const mins = Math.floor(seconds / 60) | |
| const secs = seconds % 60 | |
| return `${mins}:${secs.toString().padStart(2, '0')}` | |
| } | |
| const handleOptionSelect = (option: string) => { | |
| const currentQ = questions[currentQuestionIndex] | |
| console.log('Selected option:', option, 'for question ID:', currentQ.id) | |
| setAnswers(prev => { | |
| const updated = { | |
| ...prev, | |
| [currentQ.id]: option | |
| } | |
| console.log('Updated answers:', updated) | |
| return updated | |
| }) | |
| } | |
| const handleNext = () => { | |
| if (currentQuestionIndex < questions.length - 1) { | |
| setCurrentQuestionIndex(prev => prev + 1) | |
| } else { | |
| finishQuiz(false) | |
| } | |
| } | |
| const handlePrev = () => { | |
| if (currentQuestionIndex > 0) { | |
| setCurrentQuestionIndex(prev => prev - 1) | |
| } | |
| } | |
| const finishQuiz = async (timeExceeded: boolean = false) => { | |
| setSaving(true) | |
| try { | |
| const timeTaken = startTime ? Math.floor((Date.now() - startTime) / 1000) : 0 | |
| // Save answers to secure storage | |
| const answersData = { | |
| answers: Object.entries(answers).map(([id, answer]) => ({ | |
| questionId: id, | |
| answer | |
| })), | |
| metadata: { | |
| completedAt: new Date().toISOString(), | |
| timeTakenSeconds: timeTaken, | |
| timeLimitSeconds: timeLimit, | |
| timeExceeded: timeExceeded, | |
| status: timeExceeded ? 'TIME_EXCEEDED' : 'COMPLETED' | |
| } | |
| } | |
| console.log('Saving answers with passkey:', passkey) | |
| console.log('Answers data:', answersData) | |
| const response = await fetch('/api/data', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| passkey, | |
| action: 'save_file', | |
| fileName: 'quiz_answers.json', | |
| content: JSON.stringify(answersData, null, 2) | |
| }) | |
| }) | |
| const result = await response.json() | |
| console.log('Save response:', result) | |
| if (!response.ok || !result.success) { | |
| throw new Error(result.error || 'Failed to save answers') | |
| } | |
| console.log('✅ Answers saved successfully!') | |
| setCompleted(true) | |
| } catch (err) { | |
| console.error('Error saving answers:', err) | |
| alert(`Failed to save answers: ${(err as any).message}`) | |
| setCompleted(true) // Still show completion screen | |
| } finally { | |
| setSaving(false) | |
| } | |
| } | |
| return ( | |
| <Window | |
| id="quiz-app" | |
| title="Quiz Master" | |
| isOpen={true} | |
| onClose={onClose} | |
| onMinimize={onMinimize} | |
| onFocus={onFocus} | |
| zIndex={zIndex} | |
| width={windowSize.width} | |
| height={windowSize.height} | |
| className="bg-[#F5F5F7] font-sans" | |
| > | |
| <div className="h-full flex flex-col overflow-hidden"> | |
| {/* Header */} | |
| <div className="bg-[#F5F5F7]/80 backdrop-blur-xl border-b border-[#000000]/10 p-4 flex items-center justify-between sticky top-0 z-10"> | |
| <div className="flex items-center gap-3"> | |
| <div className="w-10 h-10 bg-gradient-to-br from-teal-400 to-emerald-500 rounded-lg shadow-sm flex items-center justify-center border border-white/10"> | |
| <Brain size={24} weight="fill" className="text-white" /> | |
| </div> | |
| <div> | |
| <h1 className="text-lg font-semibold text-gray-900 tracking-tight">Quiz Master</h1> | |
| <p className="text-xs text-gray-500">Knowledge Check</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| {timeLeft !== null && !completed && !isLocked && ( | |
| <div className={`flex items-center gap-2 px-3 py-1 rounded-md border ${timeLeft < 60 ? 'bg-red-50 border-red-200 text-red-600' : 'bg-white/50 border-black/5 text-gray-600'}`}> | |
| <ArrowClockwise size={14} className={timeLeft < 60 ? 'animate-spin' : ''} /> | |
| <span className="text-xs font-mono font-medium">{formatTime(timeLeft)}</span> | |
| </div> | |
| )} | |
| {!isLocked && !loading && !error && !completed && ( | |
| <div className="flex items-center gap-2 bg-white/50 px-3 py-1 rounded-md border border-black/5"> | |
| <span className="text-xs font-medium text-gray-500">Question {currentQuestionIndex + 1} of {questions.length}</span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* Content */} | |
| <div className="flex-1 p-4 sm:p-6 md:p-8 overflow-y-auto flex flex-col items-center justify-center min-h-0"> | |
| {isLocked ? ( | |
| <div className="flex flex-col items-center gap-6 max-w-md w-full bg-white p-8 rounded-2xl shadow-sm border border-gray-200"> | |
| <div className="w-16 h-16 bg-blue-50 rounded-full flex items-center justify-center"> | |
| <Lock size={32} weight="duotone" className="text-blue-500" /> | |
| </div> | |
| <div className="text-center"> | |
| <h2 className="text-xl font-bold text-gray-900 mb-2">Enter Passkey</h2> | |
| <p className="text-gray-500 text-sm">Enter your passkey to load your secure quiz.</p> | |
| </div> | |
| <div className="w-full space-y-4"> | |
| <div className="relative"> | |
| <Key size={20} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" /> | |
| <input | |
| type="password" | |
| value={tempPasskey} | |
| onChange={(e) => setTempPasskey(e.target.value)} | |
| onKeyPress={(e) => e.key === 'Enter' && handlePasskeySubmit()} | |
| placeholder="Your Passkey" | |
| className="w-full pl-10 pr-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all" | |
| autoFocus | |
| /> | |
| </div> | |
| <button | |
| onClick={handlePasskeySubmit} | |
| className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-medium transition-colors shadow-lg shadow-blue-500/20" | |
| > | |
| Unlock Quiz | |
| </button> | |
| </div> | |
| </div> | |
| ) : loading ? ( | |
| <div className="flex flex-col items-center gap-4 text-gray-500"> | |
| <Spinner size={32} className="animate-spin text-gray-400" /> | |
| <p className="text-sm font-medium">Loading quiz...</p> | |
| </div> | |
| ) : error ? ( | |
| <div className="flex flex-col items-center justify-center h-full max-w-md text-center"> | |
| <div className="w-16 h-16 bg-red-50 rounded-full flex items-center justify-center mb-4"> | |
| <XCircle size={32} weight="duotone" className="text-red-500" /> | |
| </div> | |
| <h3 className="text-lg font-semibold text-gray-900 mb-2">Quiz Load Error</h3> | |
| <p className="text-sm text-gray-500 mb-6 leading-relaxed">{error}</p> | |
| <button | |
| onClick={() => setIsLocked(true)} | |
| className="px-4 py-2 bg-white border border-gray-300 rounded-lg shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" | |
| > | |
| Try Different Passkey | |
| </button> | |
| </div> | |
| ) : completed ? ( | |
| <div className="flex flex-col items-center justify-center h-full max-w-md text-center animate-in fade-in zoom-in duration-300"> | |
| <div className={`w-20 h-20 rounded-full flex items-center justify-center mb-6 shadow-lg ${timeLeft === 0 ? 'bg-red-500 shadow-red-500/20' : 'bg-gradient-to-br from-green-400 to-emerald-500 shadow-green-500/20'}`}> | |
| {timeLeft === 0 ? ( | |
| <XCircle size={40} weight="fill" className="text-white" /> | |
| ) : ( | |
| <Trophy size={40} weight="fill" className="text-white" /> | |
| )} | |
| </div> | |
| <h2 className="text-2xl font-bold text-gray-900 mb-2"> | |
| {timeLeft === 0 ? "Time's Up!" : "Quiz Completed!"} | |
| </h2> | |
| <p className="text-gray-500 mb-4"> | |
| {timeLeft === 0 | |
| ? "You exceeded the time limit. Answers saved for evaluation." | |
| : "Your answers have been saved to quiz_answers.json"} | |
| </p> | |
| <div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6 text-left"> | |
| <p className="text-sm text-gray-700 font-medium mb-2">📋 Next Steps:</p> | |
| <ol className="text-sm text-gray-600 space-y-1 list-decimal list-inside"> | |
| <li>Open Claude Desktop</li> | |
| <li>Tell Claude: <span className="font-mono bg-white px-2 py-0.5 rounded text-xs">"Grade my quiz using passkey: {passkey}"</span></li> | |
| <li>Claude will read your answers and provide feedback!</li> | |
| </ol> | |
| </div> | |
| <div className="w-full space-y-3"> | |
| <button | |
| onClick={onClose} | |
| className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-medium transition-colors shadow-lg shadow-blue-500/20" | |
| > | |
| Close Quiz | |
| </button> | |
| </div> | |
| </div> | |
| ) : questions.length > 0 ? ( | |
| <div className="w-full max-w-2xl flex flex-col h-full justify-between py-2"> | |
| {/* Question Card */} | |
| <div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden flex-1 flex flex-col"> | |
| <div className="p-3 sm:p-4 md:p-6 border-b border-gray-100 flex-shrink-0"> | |
| <h3 className="text-base sm:text-lg md:text-xl font-semibold text-gray-900 leading-snug"> | |
| {questions[currentQuestionIndex].question} | |
| </h3> | |
| </div> | |
| <div className="p-3 sm:p-4 bg-gray-50/50 overflow-y-auto flex-1 min-h-0"> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3"> | |
| {questions[currentQuestionIndex].options.map((option, idx) => { | |
| const currentQId = questions[currentQuestionIndex].id | |
| const isSelected = answers[currentQId] === option | |
| return ( | |
| <button | |
| key={idx} | |
| onClick={() => handleOptionSelect(option)} | |
| className={`text-left px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg border transition-all duration-200 flex items-start gap-2 text-sm ${isSelected | |
| ? 'bg-blue-600 border-blue-600 text-white shadow-md shadow-blue-500/20' | |
| : 'bg-white border-gray-200 hover:border-gray-300 hover:bg-gray-50 text-gray-700' | |
| }`} | |
| > | |
| <div className={`w-4 h-4 rounded-full border flex items-center justify-center flex-shrink-0 mt-0.5 ${isSelected ? 'border-white bg-white/20' : 'border-gray-300' | |
| }`}> | |
| {isSelected && <div className="w-2 h-2 bg-white rounded-full" />} | |
| </div> | |
| <span className="font-medium break-words flex-1 leading-tight">{option}</span> | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| <div className="px-3 sm:px-6 py-3 sm:py-4 bg-white border-t border-gray-100 flex justify-between items-center flex-shrink-0"> | |
| <button | |
| onClick={handlePrev} | |
| disabled={currentQuestionIndex === 0} | |
| className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 text-gray-500 hover:text-gray-800 disabled:opacity-30 disabled:hover:text-gray-500 transition-colors text-xs sm:text-sm font-medium" | |
| > | |
| <CaretLeft size={14} className="sm:w-4 sm:h-4" weight="bold" /> | |
| <span className="hidden sm:inline">Back</span> | |
| </button> | |
| <button | |
| onClick={handleNext} | |
| disabled={!answers[questions[currentQuestionIndex]?.id]} | |
| className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-5 py-1.5 sm:py-2 rounded-lg text-xs sm:text-sm font-medium shadow-sm transition-all ${!answers[questions[currentQuestionIndex]?.id] | |
| ? 'bg-gray-100 text-gray-400 cursor-not-allowed' | |
| : 'bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800' | |
| }`} | |
| > | |
| {currentQuestionIndex === questions.length - 1 ? 'Finish' : 'Next'} | |
| {currentQuestionIndex !== questions.length - 1 && <CaretRight size={14} className="sm:w-4 sm:h-4" weight="bold" />} | |
| </button> | |
| </div> | |
| </div> | |
| {/* Progress Indicator */} | |
| <div className="mt-2 sm:mt-3 flex items-center justify-center gap-1 flex-shrink-0"> | |
| {questions.map((_, idx) => ( | |
| <div | |
| key={idx} | |
| className={`h-1 rounded-full transition-all duration-300 ${idx === currentQuestionIndex | |
| ? 'w-6 bg-blue-600' | |
| : idx < currentQuestionIndex | |
| ? 'w-1 bg-gray-300' | |
| : 'w-1 bg-gray-200' | |
| }`} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="text-center text-gray-500"> | |
| <p>No questions available.</p> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </Window> | |
| ) | |
| } | |