Reuben_OS / app /components /QuizApp.tsx
Reubencf's picture
fixing layout issue and rendering issues
ef3fe15
'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>
)
}