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..b50611a 100644 --- a/App.tsx +++ b/App.tsx @@ -10,6 +10,8 @@ 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 type { Quiz } from './types'; const seededRandom = (seed: number) => { const x = Math.sin(seed * 9999) * 10000; @@ -65,7 +67,11 @@ function App() { currentStreak, currentPlayerId, pendingQuizToSave, - dismissSavePrompt + dismissSavePrompt, + sourceQuizId, + updateQuizFromEditor, + startGameFromEditor, + backFromEditor } = useGame(); const handleSaveQuiz = async () => { @@ -76,6 +82,15 @@ function App() { dismissSavePrompt(); }; + const handleEditorSave = async (editedQuiz: Quiz) => { + updateQuizFromEditor(editedQuiz); + if (auth.isAuthenticated) { + const source = pendingQuizToSave?.topic ? 'ai_generated' : 'manual'; + const topic = pendingQuizToSave?.topic || undefined; + await saveQuiz(editedQuiz, source, topic); + } + }; + const currentQ = quiz?.questions[currentQuestionIndex]; // Logic to find correct option, handling both Host (has isCorrect flag) and Client (masked, needs shape) @@ -107,6 +122,16 @@ function App() { /> ) : null} + {gameState === 'EDITING' && quiz ? ( + + ) : null} + {gameState === 'LOBBY' ? ( <> 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..6f22183 --- /dev/null +++ b/components/QuestionEditModal.tsx @@ -0,0 +1,255 @@ +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'; + +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); + + 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

+ +
+ +
+
+ +