diff --git a/.gitignore b/.gitignore index 5a58438..5ae49c6 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ authentik/certs/* # Backend data server/data/ *.db +*.db-shm +*.db-wal diff --git a/App.tsx b/App.tsx index 47ed8e4..18cecb2 100644 --- a/App.tsx +++ b/App.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useAuth } from 'react-oidc-context'; import { useGame } from './hooks/useGame'; import { useQuizLibrary } from './hooks/useQuizLibrary'; @@ -10,6 +10,9 @@ import { Podium } from './components/Podium'; import { QuizCreator } from './components/QuizCreator'; import { RevealScreen } from './components/RevealScreen'; import { SaveQuizPrompt } from './components/SaveQuizPrompt'; +import { QuizEditor } from './components/QuizEditor'; +import { SaveOptionsModal } from './components/SaveOptionsModal'; +import type { Quiz } from './types'; const seededRandom = (seed: number) => { const x = Math.sin(seed * 9999) * 10000; @@ -38,7 +41,9 @@ const FloatingShapes = React.memo(() => { function App() { const auth = useAuth(); - const { saveQuiz } = useQuizLibrary(); + const { saveQuiz, updateQuiz, saving } = useQuizLibrary(); + const [showSaveOptions, setShowSaveOptions] = useState(false); + const [pendingEditedQuiz, setPendingEditedQuiz] = useState(null); const { role, gameState, @@ -65,7 +70,11 @@ function App() { currentStreak, currentPlayerId, pendingQuizToSave, - dismissSavePrompt + dismissSavePrompt, + sourceQuizId, + updateQuizFromEditor, + startGameFromEditor, + backFromEditor } = useGame(); const handleSaveQuiz = async () => { @@ -76,6 +85,36 @@ function App() { dismissSavePrompt(); }; + const handleEditorSave = async (editedQuiz: Quiz) => { + updateQuizFromEditor(editedQuiz); + if (auth.isAuthenticated) { + if (sourceQuizId) { + // Quiz was loaded from library - show options modal + setPendingEditedQuiz(editedQuiz); + setShowSaveOptions(true); + } else { + // New quiz (AI-generated or manual) - save as new + const source = pendingQuizToSave?.topic ? 'ai_generated' : 'manual'; + const topic = pendingQuizToSave?.topic || undefined; + await saveQuiz(editedQuiz, source, topic); + } + } + }; + + const handleOverwriteQuiz = async () => { + if (!pendingEditedQuiz || !sourceQuizId) return; + await updateQuiz(sourceQuizId, pendingEditedQuiz); + setShowSaveOptions(false); + setPendingEditedQuiz(null); + }; + + const handleSaveAsNew = async () => { + if (!pendingEditedQuiz) return; + await saveQuiz(pendingEditedQuiz, 'manual'); + setShowSaveOptions(false); + setPendingEditedQuiz(null); + }; + const currentQ = quiz?.questions[currentQuestionIndex]; // Logic to find correct option, handling both Host (has isCorrect flag) and Client (masked, needs shape) @@ -107,6 +146,16 @@ function App() { /> ) : null} + {gameState === 'EDITING' && quiz ? ( + + ) : null} + {gameState === 'LOBBY' ? ( <> ) : null} + + { + setShowSaveOptions(false); + setPendingEditedQuiz(null); + }} + onOverwrite={handleOverwriteQuiz} + onSaveNew={handleSaveAsNew} + isSaving={saving} + /> ); } diff --git a/components/Landing.tsx b/components/Landing.tsx index a1b56c0..b82c94d 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -12,7 +12,7 @@ type GenerateMode = 'topic' | 'document'; interface LandingProps { onGenerate: (options: { topic?: string; questionCount?: number; files?: File[]; useOcr?: boolean }) => void; onCreateManual: () => void; - onLoadQuiz: (quiz: Quiz) => void; + onLoadQuiz: (quiz: Quiz, quizId?: string) => void; onJoin: (pin: string, name: string) => void; isLoading: boolean; error: string | null; @@ -121,7 +121,7 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on try { const quiz = await loadQuiz(id); setLibraryOpen(false); - onLoadQuiz(quiz); + onLoadQuiz(quiz, id); } catch (err) { if (err instanceof Error && err.message.includes('redirecting')) { return; diff --git a/components/QuestionCard.tsx b/components/QuestionCard.tsx new file mode 100644 index 0000000..b3c35b5 --- /dev/null +++ b/components/QuestionCard.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { GripVertical, ChevronDown, ChevronUp, Pencil, Trash2, Check, X, Triangle, Diamond, Circle, Square } from 'lucide-react'; +import { Question } from '../types'; + +interface QuestionCardProps { + question: Question; + index: number; + onEdit: () => void; + onDelete: () => void; + isExpanded: boolean; + onToggleExpand: () => void; + isDragging?: boolean; + dragListeners?: any; +} + +const ShapeIcon: React.FC<{ shape: string; className?: string }> = ({ shape, className }) => { + const props = { size: 16, className }; + switch (shape) { + case 'triangle': return ; + case 'diamond': return ; + case 'circle': return ; + case 'square': return ; + default: return ; + } +}; + +const colorMap: Record = { + red: 'bg-red-500', + blue: 'bg-blue-500', + yellow: 'bg-yellow-500', + green: 'bg-green-500' +}; + +export const QuestionCard: React.FC = ({ + question, + index, + onEdit, + onDelete, + isExpanded, + onToggleExpand, + isDragging, + dragListeners +}) => { + const correctOption = question.options.find(o => o.isCorrect); + const truncatedText = question.text.length > 60 + ? question.text.slice(0, 60) + '...' + : question.text; + + return ( + +
+
+ +
+ +
+ {index + 1} +
+ +
+

{truncatedText}

+
+ + {correctOption && ( +
+ + {correctOption.text} +
+ )} + +
+ + +
+ {isExpanded ? : } +
+
+
+ + + {isExpanded && ( + +
+

{question.text}

+ +
+ {question.options.map((option, idx) => ( +
+
+
+ +
+ + {option.text} + + {option.isCorrect ? ( + + ) : ( + + )} +
+ {option.reason && ( +

{option.reason}

+ )} +
+ ))} +
+ +
+ Time limit: {question.timeLimit}s +
+
+
+ )} +
+
+ ); +}; diff --git a/components/QuestionEditModal.tsx b/components/QuestionEditModal.tsx new file mode 100644 index 0000000..653fbba --- /dev/null +++ b/components/QuestionEditModal.tsx @@ -0,0 +1,258 @@ +import React, { useState } from 'react'; +import { motion } from 'framer-motion'; +import { X, Plus, Trash2, Triangle, Diamond, Circle, Square, Clock } from 'lucide-react'; +import { Question, AnswerOption } from '../types'; +import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; + +interface QuestionEditModalProps { + question: Question; + onSave: (question: Question) => void; + onClose: () => void; +} + +const ShapeIcon: React.FC<{ shape: string; className?: string; size?: number }> = ({ shape, className, size = 20 }) => { + const props = { size, className }; + switch (shape) { + case 'triangle': return ; + case 'diamond': return ; + case 'circle': return ; + case 'square': return ; + default: return ; + } +}; + +const colorMap: Record = { + red: 'bg-red-500 border-red-500', + blue: 'bg-blue-500 border-blue-500', + yellow: 'bg-yellow-500 border-yellow-500', + green: 'bg-green-500 border-green-500' +}; + +const shapes = ['triangle', 'diamond', 'circle', 'square'] as const; +const colors = ['red', 'blue', 'yellow', 'green'] as const; + +export const QuestionEditModal: React.FC = ({ + question, + onSave, + onClose +}) => { + const [text, setText] = useState(question.text); + const [options, setOptions] = useState(question.options); + const [timeLimit, setTimeLimit] = useState(question.timeLimit); + + useBodyScrollLock(true); + + const handleOptionTextChange = (index: number, newText: string) => { + setOptions(prev => prev.map((opt, i) => + i === index ? { ...opt, text: newText } : opt + )); + }; + + const handleReasonChange = (index: number, reason: string) => { + setOptions(prev => prev.map((opt, i) => + i === index ? { ...opt, reason } : opt + )); + }; + + const handleCorrectChange = (index: number) => { + setOptions(prev => prev.map((opt, i) => ({ + ...opt, + isCorrect: i === index + }))); + }; + + const handleDeleteOption = (index: number) => { + if (options.length <= 2) return; + + const deletedWasCorrect = options[index].isCorrect; + const newOptions = options.filter((_, i) => i !== index); + + if (deletedWasCorrect && newOptions.length > 0) { + newOptions[0].isCorrect = true; + } + + setOptions(newOptions.map((opt, i) => ({ + ...opt, + shape: shapes[i % 4], + color: colors[i % 4] + }))); + }; + + const handleAddOption = () => { + if (options.length >= 6) return; + + const newIndex = options.length; + setOptions(prev => [...prev, { + text: '', + isCorrect: false, + shape: shapes[newIndex % 4], + color: colors[newIndex % 4], + reason: '' + }]); + }; + + const handleSave = () => { + if (!text.trim()) return; + if (options.some(o => !o.text.trim())) return; + if (!options.some(o => o.isCorrect)) return; + + onSave({ + ...question, + text: text.trim(), + options, + timeLimit + }); + }; + + const isValid = text.trim() && + options.every(o => o.text.trim()) && + options.some(o => o.isCorrect) && + options.length >= 2; + + return ( + + e.stopPropagation()} + > +
+

Edit Question

+ +
+ +
+
+ +