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} + + )} + + + { e.stopPropagation(); onEdit(); }} + className="p-2 text-gray-400 hover:text-theme-primary hover:bg-theme-primary/10 rounded-lg transition-colors" + > + + + { e.stopPropagation(); onDelete(); }} + className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors" + > + + + + {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 + + + + + + + + Question Text + setText(e.target.value)} + className="w-full p-4 border-2 border-gray-200 rounded-xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none transition-all resize-none font-medium" + rows={3} + placeholder="Enter your question..." + /> + + + + + + Time Limit + + + setTimeLimit(Number(e.target.value))} + className="w-32 accent-theme-primary" + /> + {timeLimit}s + + + + + + + Options ({options.length}/6) - Select the correct answer + + {options.length < 6 && ( + + Add Option + + )} + + + + {options.map((option, index) => ( + + + handleCorrectChange(index)} + className={`mt-1 w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 transition-all ${ + option.isCorrect + ? colorMap[option.color] + : 'bg-gray-200 hover:bg-gray-300' + }`} + > + + + + + handleOptionTextChange(index, e.target.value)} + className="w-full p-2 border-2 border-gray-200 rounded-lg focus:border-theme-primary outline-none font-medium" + placeholder={`Option ${index + 1}`} + /> + handleReasonChange(index, e.target.value)} + className="w-full p-2 border-2 border-gray-100 rounded-lg focus:border-theme-primary outline-none text-sm text-gray-600" + placeholder="Explanation (optional)" + /> + + + {options.length > 2 && ( + handleDeleteOption(index)} + className="mt-1 p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition" + > + + + )} + + + ))} + + + + + + + Cancel + + + Save Changes + + + + + ); +}; diff --git a/components/QuizEditor.tsx b/components/QuizEditor.tsx new file mode 100644 index 0000000..97d275b --- /dev/null +++ b/components/QuizEditor.tsx @@ -0,0 +1,273 @@ +import React, { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { ArrowLeft, Save, Plus, Play, AlertTriangle } from 'lucide-react'; +import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core'; +import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { Quiz, Question } from '../types'; +import { SortableQuestionCard } from './SortableQuestionCard'; +import { QuestionEditModal } from './QuestionEditModal'; +import { v4 as uuidv4 } from 'uuid'; + +interface QuizEditorProps { + quiz: Quiz; + onSave: (quiz: Quiz) => void; + onStartGame: (quiz: Quiz) => void; + onBack: () => void; + sourceQuizId?: string | null; + isSaving?: boolean; +} + +export const QuizEditor: React.FC = ({ + quiz: initialQuiz, + onSave, + onStartGame, + onBack, + sourceQuizId, + isSaving +}) => { + const [quiz, setQuiz] = useState(initialQuiz); + const [expandedId, setExpandedId] = useState(null); + const [editingQuestion, setEditingQuestion] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); + const [titleEditing, setTitleEditing] = useState(false); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 } + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates + }) + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + setQuiz(prev => { + const oldIndex = prev.questions.findIndex(q => q.id === active.id); + const newIndex = prev.questions.findIndex(q => q.id === over.id); + return { + ...prev, + questions: arrayMove(prev.questions, oldIndex, newIndex) + }; + }); + } + }; + + const handleTitleChange = (newTitle: string) => { + setQuiz(prev => ({ ...prev, title: newTitle })); + }; + + const handleQuestionUpdate = (updated: Question) => { + setQuiz(prev => ({ + ...prev, + questions: prev.questions.map(q => q.id === updated.id ? updated : q) + })); + setEditingQuestion(null); + }; + + const handleQuestionDelete = (id: string) => { + if (quiz.questions.length <= 1) { + return; + } + setQuiz(prev => ({ + ...prev, + questions: prev.questions.filter(q => q.id !== id) + })); + setShowDeleteConfirm(null); + }; + + const handleAddQuestion = () => { + const shapes = ['triangle', 'diamond', 'circle', 'square'] as const; + const colors = ['red', 'blue', 'yellow', 'green'] as const; + + const newQuestion: Question = { + id: uuidv4(), + text: '', + options: shapes.map((shape, idx) => ({ + text: '', + isCorrect: idx === 0, + shape, + color: colors[idx], + reason: '' + })), + timeLimit: 20 + }; + + setQuiz(prev => ({ + ...prev, + questions: [...prev.questions, newQuestion] + })); + setEditingQuestion(newQuestion); + }; + + const canStartGame = quiz.title.trim() && quiz.questions.length > 0 && + quiz.questions.every(q => + q.text.trim() && + q.options.every(o => o.text.trim()) && + q.options.some(o => o.isCorrect) + ); + + return ( + + + + + + + + + + {titleEditing ? ( + handleTitleChange(e.target.value)} + onBlur={() => setTitleEditing(false)} + onKeyDown={(e) => e.key === 'Enter' && setTitleEditing(false)} + autoFocus + className="bg-white/20 text-white text-2xl md:text-3xl font-black text-center w-full max-w-md px-4 py-2 rounded-xl outline-none placeholder:text-white/50" + placeholder="Quiz Title" + /> + ) : ( + setTitleEditing(true)} + className="text-2xl md:text-3xl font-black cursor-pointer hover:bg-white/10 px-4 py-2 rounded-xl transition inline-block" + > + {quiz.title || 'Untitled Quiz'} + + )} + + {quiz.questions.length} question{quiz.questions.length !== 1 ? 's' : ''} + + + + onSave(quiz)} + disabled={isSaving} + className="bg-white/20 p-2 rounded-full hover:bg-white/30 transition disabled:opacity-50" + title={sourceQuizId ? 'Save changes' : 'Save to library'} + > + + + + + + + + + + + Questions + + Add Question + + + + + q.id)} + strategy={verticalListSortingStrategy} + > + + + {quiz.questions.map((question, index) => ( + setExpandedId(expandedId === question.id ? null : question.id)} + onEdit={() => setEditingQuestion(question)} + onDelete={() => quiz.questions.length > 1 ? setShowDeleteConfirm(question.id) : null} + /> + ))} + + + + + + {quiz.questions.length === 0 && ( + + No questions yet + Click "Add Question" to get started + + )} + + + + onStartGame(quiz)} + disabled={!canStartGame} + className="w-full bg-green-500 text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#16a34a] active:shadow-none active:translate-y-[6px] transition-all hover:bg-green-600 flex items-center justify-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed disabled:active:translate-y-0 disabled:active:shadow-[0_6px_0_#16a34a]" + > + Start Game with {quiz.questions.length} Questions + + + {!canStartGame && quiz.questions.length > 0 && ( + + + Some questions are incomplete + + )} + + + + {editingQuestion && ( + setEditingQuestion(null)} + /> + )} + + + {showDeleteConfirm && ( + setShowDeleteConfirm(null)} + > + e.stopPropagation()} + > + Delete Question? + This action cannot be undone. + + setShowDeleteConfirm(null)} + className="flex-1 py-3 rounded-xl font-bold border-2 border-gray-200 text-gray-600 hover:bg-gray-50 transition" + > + Cancel + + handleQuestionDelete(showDeleteConfirm)} + className="flex-1 py-3 rounded-xl font-bold bg-red-500 text-white hover:bg-red-600 transition" + > + Delete + + + + + )} + + + ); +}; diff --git a/components/SortableQuestionCard.tsx b/components/SortableQuestionCard.tsx new file mode 100644 index 0000000..6d55d36 --- /dev/null +++ b/components/SortableQuestionCard.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { QuestionCard } from './QuestionCard'; +import { Question } from '../types'; + +interface SortableQuestionCardProps { + question: Question; + index: number; + onEdit: () => void; + onDelete: () => void; + isExpanded: boolean; + onToggleExpand: () => void; +} + +export const SortableQuestionCard: React.FC = ({ + question, + index, + onEdit, + onDelete, + isExpanded, + onToggleExpand +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging + } = useSortable({ id: question.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 50 : undefined, + position: 'relative' as const + }; + + return ( + + + + ); +}; diff --git a/data/kaboot.db-shm b/data/kaboot.db-shm deleted file mode 100644 index c385666..0000000 Binary files a/data/kaboot.db-shm and /dev/null differ diff --git a/data/kaboot.db-wal b/data/kaboot.db-wal deleted file mode 100644 index 788e385..0000000 Binary files a/data/kaboot.db-wal and /dev/null differ diff --git a/docs/QUIZ_EDITOR_PLAN.md b/docs/QUIZ_EDITOR_PLAN.md new file mode 100644 index 0000000..0e158de --- /dev/null +++ b/docs/QUIZ_EDITOR_PLAN.md @@ -0,0 +1,232 @@ +# Quiz Editor Feature Plan + +## Overview + +Add the ability to view and edit quizzes: +1. **Post-generation editing** - Review/edit AI-generated quizzes before starting a game +2. **Library editing** - Edit saved quizzes from the quiz library + +## Current Flow + +``` +AI Generation → Lobby (with save prompt) → Start Game +Library Load → Lobby → Start Game +``` + +## Proposed Flow + +``` +AI Generation → Quiz Editor → Lobby (with save prompt) → Start Game +Library Load → Quiz Editor → Lobby → Start Game + ↓ + Save changes back to library (if from library) +``` + +## User Stories + +1. As a host, I want to review AI-generated questions before starting a game so I can remove inappropriate or incorrect questions +2. As a host, I want to edit question text and answers to fix mistakes or improve clarity +3. As a host, I want to delete questions I don't want to include +4. As a host, I want to add new questions to an AI-generated or saved quiz +5. As a host, I want to reorder questions +6. As a host, I want to edit quizzes from my library and save changes + +## UI Design + +### Quiz Editor Screen + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ← Back [Save to Library] │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Quiz Title (editable) ✏️ │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Questions (12) [+ Add Question] │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ ≡ 1. What is the capital of France? ✏️ 🗑️ │ │ +│ │ ○ London ○ Berlin ● Paris ○ Madrid │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ ≡ 2. Which planet is known as the Red Planet? ✏️ 🗑️│ │ +│ │ ○ Venus ● Mars ○ Jupiter ○ Saturn │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ... (scrollable list) │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ [Start Game with 12 Questions] │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Question Edit Modal + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Edit Question ✕ │ +│ │ +│ Question Text │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ What is the capital of France? │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Time Limit: [20] seconds │ +│ │ +│ Options (select correct answer) │ +│ │ +│ ○ ┌──────────────────────────────────────────┐ 🗑️ │ +│ │ London │ │ +│ └──────────────────────────────────────────┘ │ +│ Reason: ┌──────────────────────────────────┐ │ +│ │ London is the capital of the UK │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ ● ┌──────────────────────────────────────────┐ 🗑️ │ +│ │ Paris │ │ +│ └──────────────────────────────────────────┘ │ +│ Reason: ┌──────────────────────────────────┐ │ +│ │ Paris has been France's capital │ │ +│ │ since the 10th century │ │ +│ └──────────────────────────────────┘ │ +│ ... │ +│ │ +│ [+ Add Option] │ +│ │ +│ ┌───────────────┐ ┌───────────────────────────────────┐ │ +│ │ Cancel │ │ Save Changes │ │ +│ └───────────────┘ └───────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Implementation Phases + +### Phase 1: Quiz Editor Component + +**New files:** +- `components/QuizEditor.tsx` - Main editor screen +- `components/QuestionCard.tsx` - Collapsible question display card +- `components/QuestionEditModal.tsx` - Modal for editing a single question + +**Features:** +- Display quiz title (editable inline) +- List all questions in collapsible cards +- Show question text and options in collapsed view +- Expand to see answer reasons +- Edit/delete buttons per question +- Drag handle for reordering (optional, Phase 2) + +### Phase 2: Question Editing + +**Features:** +- Edit question text +- Edit option text and reasons +- Change correct answer (radio button) +- Delete options (minimum 2 required) +- Add new options (maximum 6) +- Edit time limit per question +- Validation before save + +### Phase 3: Add/Delete Questions + +**Features:** +- Delete question with confirmation +- Add new blank question +- Duplicate existing question +- Minimum 1 question required to start game + +### Phase 4: Drag-and-Drop Reordering + +**Dependencies:** +- `@dnd-kit/core` and `@dnd-kit/sortable` (already may be available, or add) + +**Features:** +- Drag handle on each question card +- Visual feedback during drag +- Reorder questions in the list + +### Phase 5: Integration with Flows + +**Modifications to existing files:** + +1. `App.tsx` + - Add `EDITING` game state + - Route to QuizEditor after generation or library load + - Pass quiz and callbacks to editor + +2. `hooks/useGame.ts` + - Add `editQuiz()` function to update quiz state + - Add `startGameFromEditor()` to proceed to lobby + - Track if quiz came from library (for save-back) + +3. `types.ts` + - Add `EDITING` to GameState type + +4. `components/QuizLibrary.tsx` + - Change "Load" to open editor instead of going directly to lobby + - Or add separate "Edit" button alongside "Load" + +### Phase 6: Save Changes to Library + +**Features:** +- "Save to Library" button in editor +- If quiz was loaded from library, offer "Save Changes" (PUT) +- If quiz is new (AI-generated), offer "Save as New" (POST) +- Show save confirmation toast + +**API:** +- Existing `PUT /api/quizzes/:id` endpoint already supports updates + +## State Management + +```typescript +// New state in useGame or separate useQuizEditor hook +interface EditorState { + originalQuiz: Quiz | null; // For detecting changes + editedQuiz: Quiz; // Current working copy + sourceQuizId: string | null; // If loaded from library + hasUnsavedChanges: boolean; + validationErrors: string[]; +} +``` + +## File Changes Summary + +| File | Changes | +|------|---------| +| `components/QuizEditor.tsx` | NEW - Main editor component | +| `components/QuestionCard.tsx` | NEW - Question display card | +| `components/QuestionEditModal.tsx` | NEW - Edit modal | +| `App.tsx` | Add EDITING state routing | +| `hooks/useGame.ts` | Add editor-related functions | +| `types.ts` | Add EDITING to GameState | +| `components/QuizLibrary.tsx` | Add edit button/flow | + +## Validation Rules + +1. Quiz must have a title +2. Quiz must have at least 1 question +3. Each question must have text +4. Each question must have 2-6 options +5. Each question must have exactly 1 correct answer +6. Each option must have text +7. Time limit must be 5-60 seconds + +## Edge Cases + +1. **Empty quiz from AI** - Show error, allow retry or manual add +2. **Unsaved changes on back** - Confirm dialog "Discard changes?" +3. **Delete last question** - Prevent, show error +4. **Delete correct answer option** - Auto-select another as correct, or prevent +5. **Network error on save** - Show error toast, keep editor open + +## Future Enhancements (Out of Scope) + +- Image support in questions +- Bulk import/export (JSON, CSV) +- Question bank / templates +- Collaborative editing +- Version history diff --git a/features.md b/features.md index fa248b3..81c7b2c 100644 --- a/features.md +++ b/features.md @@ -1,4 +1,2 @@ -- [ ] All data stored in sqlite db. -- [ ] AI generated content based on document upload - [ ] Moderation (kick player, lock game, filter names) - [ ] Persistent game urls while game is active diff --git a/hooks/useGame.ts b/hooks/useGame.ts index 259cc53..76bd4db 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -24,6 +24,7 @@ export const useGame = () => { const [currentPlayerId, setCurrentPlayerId] = useState(null); const [currentPlayerName, setCurrentPlayerName] = useState(null); const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null); + const [sourceQuizId, setSourceQuizId] = useState(null); const timerRef = useRef | null>(null); const peerRef = useRef(null); @@ -85,7 +86,8 @@ export const useGame = () => { const generatedQuiz = await generateQuiz(generateOptions); const saveLabel = options.topic || options.files?.map(f => f.name).join(', ') || ''; setPendingQuizToSave({ quiz: generatedQuiz, topic: saveLabel }); - initializeHostGame(generatedQuiz); + setQuiz(generatedQuiz); + setGameState('EDITING'); } catch (e) { const message = e instanceof Error ? e.message : "Failed to generate quiz."; setError(message); @@ -109,8 +111,28 @@ export const useGame = () => { initializeHostGame(manualQuiz); }; - const loadSavedQuiz = (savedQuiz: Quiz) => { - initializeHostGame(savedQuiz); + const loadSavedQuiz = (savedQuiz: Quiz, quizId?: string) => { + setRole('HOST'); + setQuiz(savedQuiz); + setSourceQuizId(quizId || null); + setGameState('EDITING'); + }; + + const updateQuizFromEditor = (updatedQuiz: Quiz) => { + setQuiz(updatedQuiz); + setPendingQuizToSave(prev => prev ? { ...prev, quiz: updatedQuiz } : { quiz: updatedQuiz, topic: '' }); + }; + + const startGameFromEditor = (finalQuiz: Quiz) => { + setQuiz(finalQuiz); + initializeHostGame(finalQuiz); + }; + + const backFromEditor = () => { + setQuiz(null); + setPendingQuizToSave(null); + setSourceQuizId(null); + setGameState('LANDING'); }; // We use a ref to hold the current handleHostData function @@ -441,7 +463,8 @@ export const useGame = () => { return { role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, - pendingQuizToSave, dismissSavePrompt, - startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard + pendingQuizToSave, dismissSavePrompt, sourceQuizId, + startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard, + updateQuizFromEditor, startGameFromEditor, backFromEditor }; }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f223115..d29d414 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "kaboot", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@google/genai": "^1.35.0", "canvas-confetti": "^1.9.4", "framer-motion": "^12.26.1", @@ -311,6 +314,60 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", diff --git a/package.json b/package.json index be6e5d3..8f9d39c 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@google/genai": "^1.35.0", "canvas-confetti": "^1.9.4", "framer-motion": "^12.26.1", diff --git a/types.ts b/types.ts index c9eb4e6..84004ea 100644 --- a/types.ts +++ b/types.ts @@ -2,6 +2,7 @@ export type GameState = | 'LANDING' | 'CREATING' | 'GENERATING' + | 'EDITING' | 'LOBBY' | 'COUNTDOWN' | 'QUESTION'
{truncatedText}
{question.text}
{option.reason}
+ {quiz.questions.length} question{quiz.questions.length !== 1 ? 's' : ''} +
No questions yet
Click "Add Question" to get started
+ + Some questions are incomplete +
This action cannot be undone.