diff --git a/App.tsx b/App.tsx index 52680ce..405be5d 100644 --- a/App.tsx +++ b/App.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { useAuth } from 'react-oidc-context'; import { useGame } from './hooks/useGame'; +import { useQuizLibrary } from './hooks/useQuizLibrary'; import { Landing } from './components/Landing'; import { Lobby } from './components/Lobby'; import { GameScreen } from './components/GameScreen'; @@ -7,6 +9,7 @@ import { Scoreboard } from './components/Scoreboard'; import { Podium } from './components/Podium'; import { QuizCreator } from './components/QuizCreator'; import { RevealScreen } from './components/RevealScreen'; +import { SaveQuizPrompt } from './components/SaveQuizPrompt'; const seededRandom = (seed: number) => { const x = Math.sin(seed * 9999) * 10000; @@ -34,6 +37,8 @@ const FloatingShapes = React.memo(() => { }); function App() { + const auth = useAuth(); + const { saveQuiz } = useQuizLibrary(); const { role, gameState, @@ -57,9 +62,19 @@ function App() { selectedOption, currentPlayerScore, currentStreak, - currentPlayerId + currentPlayerId, + pendingQuizToSave, + dismissSavePrompt } = useGame(); + const handleSaveQuiz = async () => { + if (!pendingQuizToSave) return; + const source = pendingQuizToSave.topic ? 'ai_generated' : 'manual'; + const topic = pendingQuizToSave.topic || undefined; + await saveQuiz(pendingQuizToSave.quiz, source, topic); + dismissSavePrompt(); + }; + const currentQ = quiz?.questions[currentQuestionIndex]; // Logic to find correct option, handling both Host (has isCorrect flag) and Client (masked, needs shape) @@ -92,13 +107,23 @@ function App() { ) : null} {gameState === 'LOBBY' ? ( - + <> + + {auth.isAuthenticated && pendingQuizToSave && ( + + )} + ) : null} {(gameState === 'COUNTDOWN' || gameState === 'QUESTION') && quiz ? ( diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 484decc..cc3656e 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -236,31 +236,31 @@ Add user accounts via Authentik (OIDC) and persist quizzes to SQLite database. U ## Phase 5: Save Integration ### 5.1 Save After AI Generation -- [ ] Modify `src/hooks/useGame.ts`: - - [ ] Add `pendingQuizToSave` state - - [ ] After successful AI generation, set `pendingQuizToSave` - - [ ] Add `savePendingQuiz()` and `dismissSavePrompt()` functions - - [ ] Export these for UI to consume -- [ ] Create `src/components/SaveQuizPrompt.tsx`: - - [ ] Modal asking "Save this quiz to your library?" - - [ ] Show quiz title - - [ ] "Save" and "Skip" buttons - - [ ] Only show when authenticated +- [x] Modify `hooks/useGame.ts`: + - [x] Add `pendingQuizToSave` state + - [x] After successful AI generation, set `pendingQuizToSave` + - [x] Add `dismissSavePrompt()` function + - [x] Export these for UI to consume +- [x] Create `components/SaveQuizPrompt.tsx`: + - [x] Modal asking "Save this quiz to your library?" + - [x] Show quiz title + - [x] "Save" and "Skip" buttons + - [x] Loading state while saving +- [x] Wire up in `App.tsx`: + - [x] Show SaveQuizPrompt in LOBBY state when authenticated and pendingQuizToSave exists + - [x] Handle save via useQuizLibrary hook ### 5.2 Save in Quiz Creator -- [ ] Modify `src/components/QuizCreator.tsx`: - - [ ] Add checkbox or toggle: "Save to my library" - - [ ] Pass `shouldSave` flag to `onFinalize` -- [ ] Modify `src/hooks/useGame.ts`: - - [ ] Update `finalizeManualQuiz` to accept save preference - - [ ] If save requested + authenticated, call `saveQuiz` +- [x] Modify `components/QuizCreator.tsx`: + - [x] Add "Save to my library" checkbox (only shown when authenticated) + - [x] Pass `saveToLibrary` flag to `onFinalize` +- [x] Modify `hooks/useGame.ts`: + - [x] Update `finalizeManualQuiz` to accept save preference + - [x] If save requested, set `pendingQuizToSave` -### 5.3 Load Quiz Flow -- [ ] Modify `src/hooks/useGame.ts`: - - [ ] Add `loadSavedQuiz(quiz: Quiz)` function - - [ ] Initialize game state with loaded quiz - - [ ] Transition to LOBBY state -- [ ] Wire up from Landing → QuizLibrary → useGame +### 5.3 Load Quiz Flow (Already done in Phase 4) +- [x] `loadSavedQuiz(quiz: Quiz)` function in useGame.ts +- [x] Wire up from Landing → QuizLibrary → useGame --- @@ -407,6 +407,6 @@ kaboot/ | Phase 2 | **COMPLETE** | Backend API with Express, SQLite, JWT auth, Quiz CRUD | | Phase 3 | **COMPLETE** | OIDC config, AuthProvider, AuthButton, useAuthenticatedFetch | | Phase 4 | **COMPLETE** | useQuizLibrary hook, QuizLibrary modal, Landing integration | -| Phase 5 | Not Started | | +| Phase 5 | **COMPLETE** | SaveQuizPrompt modal, QuizCreator save checkbox, save integration | | Phase 6 | Not Started | | | Phase 7 | Not Started | | diff --git a/components/QuizCreator.tsx b/components/QuizCreator.tsx index 726e527..7260cd1 100644 --- a/components/QuizCreator.tsx +++ b/components/QuizCreator.tsx @@ -1,21 +1,24 @@ import React, { useState } from 'react'; +import { useAuth } from 'react-oidc-context'; import { Quiz, Question, AnswerOption } from '../types'; import { v4 as uuidv4 } from 'uuid'; -import { Plus, Save, Trash2, CheckCircle, Circle, X } from 'lucide-react'; +import { Plus, Save, Trash2, CheckCircle, Circle, X, BookOpen } from 'lucide-react'; import { COLORS, SHAPES } from '../constants'; interface QuizCreatorProps { - onFinalize: (quiz: Quiz) => void; + onFinalize: (quiz: Quiz, saveToLibrary: boolean) => void; onCancel: () => void; } export const QuizCreator: React.FC = ({ onFinalize, onCancel }) => { + const auth = useAuth(); const [title, setTitle] = useState(''); const [questions, setQuestions] = useState([]); const [qText, setQText] = useState(''); const [options, setOptions] = useState(['', '', '', '']); const [reasons, setReasons] = useState(['', '', '', '']); const [correctIdx, setCorrectIdx] = useState(0); + const [saveToLibrary, setSaveToLibrary] = useState(false); const handleAddQuestion = () => { if (!qText.trim() || options.some(o => !o.trim())) { @@ -53,7 +56,7 @@ export const QuizCreator: React.FC = ({ onFinalize, onCancel } const handleFinalize = () => { if (!title.trim() || questions.length === 0) return; - onFinalize({ title, questions }); + onFinalize({ title, questions }, saveToLibrary); }; return ( @@ -176,7 +179,25 @@ export const QuizCreator: React.FC = ({ onFinalize, onCancel } -
+
+ {auth.isAuthenticated ? ( + + ) : ( +
+ )} + + +
+
+ +
+ +
+ + + + )} + + ); +}; diff --git a/hooks/useGame.ts b/hooks/useGame.ts index 63df37e..966e64f 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -21,6 +21,7 @@ export const useGame = () => { const [currentStreak, setCurrentStreak] = useState(0); const [currentPlayerId, setCurrentPlayerId] = useState(null); const [currentPlayerName, setCurrentPlayerName] = useState(null); + const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null); const timerRef = useRef | null>(null); const peerRef = useRef(null); @@ -48,6 +49,7 @@ export const useGame = () => { setError(null); setRole('HOST'); const generatedQuiz = await generateQuiz(topic); + setPendingQuizToSave({ quiz: generatedQuiz, topic }); initializeHostGame(generatedQuiz); } catch (e) { setError("Failed to generate quiz."); @@ -55,12 +57,19 @@ export const useGame = () => { } }; + const dismissSavePrompt = () => { + setPendingQuizToSave(null); + }; + const startManualCreation = () => { setRole('HOST'); setGameState('CREATING'); }; - const finalizeManualQuiz = (manualQuiz: Quiz) => { + const finalizeManualQuiz = (manualQuiz: Quiz, saveToLibrary: boolean = false) => { + if (saveToLibrary) { + setPendingQuizToSave({ quiz: manualQuiz, topic: '' }); + } initializeHostGame(manualQuiz); }; @@ -383,6 +392,7 @@ 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 }; }; \ No newline at end of file diff --git a/server/src/routes/quizzes.ts b/server/src/routes/quizzes.ts index ed4c06d..c6fa916 100644 --- a/server/src/routes/quizzes.ts +++ b/server/src/routes/quizzes.ts @@ -88,7 +88,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => { const body = req.body as QuizBody; const { title, source, aiTopic, questions } = body; - if (!title || !source || !questions?.length) { + if (!title?.trim() || !source || !questions?.length) { res.status(400).json({ error: 'Missing required fields: title, source, questions' }); return; } diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts index 69d9d79..09454e4 100644 --- a/server/tests/api.test.ts +++ b/server/tests/api.test.ts @@ -426,6 +426,244 @@ async function runTests() { await request('DELETE', `/api/quizzes/${timestampQuizId}`, undefined, 204); }); + console.log('\nSave Integration Tests (Phase 5):'); + let manualSaveQuizId: string | null = null; + let aiSaveQuizId: string | null = null; + + await test('POST /api/quizzes manual quiz without aiTopic', async () => { + const manualQuiz = { + title: 'Manual Save Test Quiz', + source: 'manual', + questions: [ + { + text: 'What is 1+1?', + timeLimit: 20, + options: [ + { text: '1', isCorrect: false, shape: 'triangle', color: 'red' }, + { text: '2', isCorrect: true, shape: 'diamond', color: 'blue' }, + { text: '3', isCorrect: false, shape: 'circle', color: 'yellow' }, + { text: '4', isCorrect: false, shape: 'square', color: 'green' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', manualQuiz, 201); + const result = data as { id: string }; + manualSaveQuizId = result.id; + }); + + await test('GET manual quiz has null aiTopic', async () => { + if (!manualSaveQuizId) throw new Error('No manual quiz created'); + const { data } = await request('GET', `/api/quizzes/${manualSaveQuizId}`); + const quiz = data as Record; + if (quiz.source !== 'manual') throw new Error('Wrong source'); + if (quiz.aiTopic !== null) throw new Error(`Expected null aiTopic, got ${quiz.aiTopic}`); + }); + + await test('POST /api/quizzes ai_generated with empty aiTopic treated as null', async () => { + const aiQuiz = { + title: 'AI Quiz Empty Topic', + source: 'ai_generated', + aiTopic: '', + questions: [ + { + text: 'Test?', + options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', aiQuiz, 201); + aiSaveQuizId = (data as { id: string }).id; + + const { data: getResult } = await request('GET', `/api/quizzes/${aiSaveQuizId}`); + const quiz = getResult as Record; + if (quiz.aiTopic !== null && quiz.aiTopic !== '') { + throw new Error(`Expected null/empty aiTopic for empty string, got ${quiz.aiTopic}`); + } + }); + + await test('DELETE cleanup manual save quiz', async () => { + if (manualSaveQuizId) { + await request('DELETE', `/api/quizzes/${manualSaveQuizId}`, undefined, 204); + } + }); + + await test('DELETE cleanup ai save quiz', async () => { + if (aiSaveQuizId) { + await request('DELETE', `/api/quizzes/${aiSaveQuizId}`, undefined, 204); + } + }); + + console.log('\nOption Preservation Tests:'); + let optionQuizId: string | null = null; + + await test('POST /api/quizzes preserves all option fields including reason', async () => { + const quizWithReasons = { + title: 'Quiz With Reasons', + source: 'manual', + questions: [ + { + text: 'Capital of France?', + timeLimit: 15, + options: [ + { text: 'London', isCorrect: false, shape: 'triangle', color: 'red', reason: 'London is the capital of UK' }, + { text: 'Paris', isCorrect: true, shape: 'diamond', color: 'blue', reason: 'Correct! Paris is the capital of France' }, + { text: 'Berlin', isCorrect: false, shape: 'circle', color: 'yellow', reason: 'Berlin is the capital of Germany' }, + { text: 'Madrid', isCorrect: false, shape: 'square', color: 'green', reason: 'Madrid is the capital of Spain' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', quizWithReasons, 201); + optionQuizId = (data as { id: string }).id; + + const { data: getResult } = await request('GET', `/api/quizzes/${optionQuizId}`); + const quiz = getResult as { questions: { options: { text: string; isCorrect: boolean; shape: string; color: string; reason?: string }[] }[] }; + + const options = quiz.questions[0].options; + if (options.length !== 4) throw new Error('Expected 4 options'); + + const parisOpt = options.find(o => o.text === 'Paris'); + if (!parisOpt) throw new Error('Paris option not found'); + if (!parisOpt.isCorrect) throw new Error('Paris should be correct'); + if (parisOpt.reason !== 'Correct! Paris is the capital of France') throw new Error('Paris reason not preserved'); + + const londonOpt = options.find(o => o.text === 'London'); + if (!londonOpt) throw new Error('London option not found'); + if (londonOpt.isCorrect) throw new Error('London should not be correct'); + if (londonOpt.reason !== 'London is the capital of UK') throw new Error('London reason not preserved'); + }); + + await test('POST /api/quizzes options without reason field are preserved', async () => { + const quizNoReasons = { + title: 'Quiz Without Reasons', + source: 'ai_generated', + aiTopic: 'Geography', + questions: [ + { + text: 'Largest ocean?', + options: [ + { text: 'Atlantic', isCorrect: false, shape: 'triangle', color: 'red' }, + { text: 'Pacific', isCorrect: true, shape: 'diamond', color: 'blue' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', quizNoReasons, 201); + const quizId = (data as { id: string }).id; + + const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); + const quiz = getResult as { questions: { options: { reason?: string }[] }[] }; + + const options = quiz.questions[0].options; + const pacificOpt = options.find((o: any) => o.text === 'Pacific'); + if (pacificOpt?.reason !== null && pacificOpt?.reason !== undefined) { + throw new Error('Expected null/undefined reason for option without reason'); + } + + await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); + }); + + await test('DELETE cleanup option quiz', async () => { + if (optionQuizId) { + await request('DELETE', `/api/quizzes/${optionQuizId}`, undefined, 204); + } + }); + + console.log('\nConcurrent Save Tests:'); + + await test('Multiple quizzes can be saved by same user', async () => { + const quiz1 = { + title: 'Concurrent Quiz 1', + source: 'manual', + questions: [{ text: 'Q1?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }] }], + }; + const quiz2 = { + title: 'Concurrent Quiz 2', + source: 'ai_generated', + aiTopic: 'Science', + questions: [{ text: 'Q2?', options: [{ text: 'B', isCorrect: true, shape: 'diamond', color: 'blue' }] }], + }; + + const [res1, res2] = await Promise.all([ + request('POST', '/api/quizzes', quiz1, 201), + request('POST', '/api/quizzes', quiz2, 201), + ]); + + const id1 = (res1.data as { id: string }).id; + const id2 = (res2.data as { id: string }).id; + + if (id1 === id2) throw new Error('Quiz IDs should be unique'); + + const { data: listData } = await request('GET', '/api/quizzes'); + const list = listData as { id: string; title: string }[]; + + const found1 = list.find(q => q.id === id1); + const found2 = list.find(q => q.id === id2); + + if (!found1) throw new Error('Quiz 1 not in list'); + if (!found2) throw new Error('Quiz 2 not in list'); + + await Promise.all([ + request('DELETE', `/api/quizzes/${id1}`, undefined, 204), + request('DELETE', `/api/quizzes/${id2}`, undefined, 204), + ]); + }); + + console.log('\nEdge Case Tests:'); + + await test('POST /api/quizzes with very long title', async () => { + const longTitle = 'A'.repeat(500); + const quiz = { + title: longTitle, + source: 'manual', + questions: [{ text: 'Q?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }] }], + }; + + const { data } = await request('POST', '/api/quizzes', quiz, 201); + const quizId = (data as { id: string }).id; + + const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); + if ((getResult as { title: string }).title !== longTitle) { + throw new Error('Long title not preserved'); + } + + await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); + }); + + await test('POST /api/quizzes with special characters in title', async () => { + const specialTitle = 'Quiz with "quotes" & and emoji test'; + const quiz = { + title: specialTitle, + source: 'manual', + questions: [{ text: 'Q?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }] }], + }; + + const { data } = await request('POST', '/api/quizzes', quiz, 201); + const quizId = (data as { id: string }).id; + + const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`); + if ((getResult as { title: string }).title !== specialTitle) { + throw new Error('Special characters not preserved'); + } + + await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204); + }); + + await test('POST /api/quizzes with whitespace-only title returns 400', async () => { + const quiz = { + title: ' ', + source: 'manual', + questions: [{ text: 'Q?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }] }], + }; + + await request('POST', '/api/quizzes', quiz, 400); + }); + console.log('\n=== Results ==='); const passed = results.filter((r) => r.passed).length; const failed = results.filter((r) => !r.passed).length;