Phase 4 complete
This commit is contained in:
parent
0cc099c00c
commit
66f15b49b2
9 changed files with 710 additions and 37 deletions
2
App.tsx
2
App.tsx
|
|
@ -46,6 +46,7 @@ function App() {
|
|||
startQuizGen,
|
||||
startManualCreation,
|
||||
finalizeManualQuiz,
|
||||
loadSavedQuiz,
|
||||
joinGame,
|
||||
startGame,
|
||||
handleAnswer,
|
||||
|
|
@ -76,6 +77,7 @@ function App() {
|
|||
<Landing
|
||||
onGenerate={startQuizGen}
|
||||
onCreateManual={startManualCreation}
|
||||
onLoadQuiz={loadSavedQuiz}
|
||||
onJoin={joinGame}
|
||||
isLoading={gameState === 'GENERATING'}
|
||||
error={error}
|
||||
|
|
|
|||
|
|
@ -194,39 +194,42 @@ Add user accounts via Authentik (OIDC) and persist quizzes to SQLite database. U
|
|||
## Phase 4: Quiz Library Feature
|
||||
|
||||
### 4.1 Quiz Library Hook
|
||||
- [ ] Create `src/hooks/useQuizLibrary.ts`:
|
||||
- [ ] State: `quizzes`, `loading`, `error`
|
||||
- [ ] `fetchQuizzes()` - GET /api/quizzes
|
||||
- [ ] `loadQuiz(id)` - GET /api/quizzes/:id, return Quiz
|
||||
- [ ] `saveQuiz(quiz, source, aiTopic?)` - POST /api/quizzes
|
||||
- [ ] `deleteQuiz(id)` - DELETE /api/quizzes/:id
|
||||
- [ ] Handle loading and error states
|
||||
- [x] Create `hooks/useQuizLibrary.ts`:
|
||||
- [x] State: `quizzes`, `loading`, `error`
|
||||
- [x] `fetchQuizzes()` - GET /api/quizzes
|
||||
- [x] `loadQuiz(id)` - GET /api/quizzes/:id, return Quiz
|
||||
- [x] `saveQuiz(quiz, source, aiTopic?)` - POST /api/quizzes
|
||||
- [x] `deleteQuiz(id)` - DELETE /api/quizzes/:id
|
||||
- [x] Handle loading and error states
|
||||
|
||||
### 4.2 Quiz Library UI
|
||||
- [ ] Create `src/components/QuizLibrary.tsx`:
|
||||
- [ ] Modal overlay design (consistent with app style)
|
||||
- [ ] Header: "My Quizzes" with close button
|
||||
- [ ] Search/filter input (optional, future enhancement)
|
||||
- [ ] Quiz list:
|
||||
- [ ] Show title, question count, source badge (AI/Manual), date
|
||||
- [ ] Click to select
|
||||
- [ ] Delete button with confirmation
|
||||
- [ ] Footer: "Load Selected" and "Cancel" buttons
|
||||
- [ ] Empty state: "No saved quizzes yet"
|
||||
- [ ] Loading state: Skeleton/spinner
|
||||
- [x] Create `components/QuizLibrary.tsx`:
|
||||
- [x] Modal overlay design (consistent with app style)
|
||||
- [x] Header: "My Library" with close button
|
||||
- [x] Quiz list:
|
||||
- [x] Show title, question count, source badge (AI/Manual), date
|
||||
- [x] Click to load quiz
|
||||
- [x] Delete button with confirmation
|
||||
- [x] Empty state: "No saved quizzes yet"
|
||||
- [x] Loading state: Spinner
|
||||
|
||||
### 4.3 Landing Page Integration
|
||||
- [ ] Modify `src/components/Landing.tsx`:
|
||||
- [ ] Add "My Quizzes" button (only visible when authenticated)
|
||||
- [ ] Add state for quiz library modal visibility
|
||||
- [ ] Render `<QuizLibrary />` modal when open
|
||||
- [ ] Handle quiz load: call `onLoadQuiz` prop with loaded quiz
|
||||
- [x] Modify `components/Landing.tsx`:
|
||||
- [x] Add "My Quizzes" button (only visible when authenticated)
|
||||
- [x] Add state for quiz library modal visibility
|
||||
- [x] Render `<QuizLibrary />` modal when open
|
||||
- [x] Handle quiz load: call `onLoadQuiz` prop with loaded quiz
|
||||
|
||||
### 4.4 Types Update
|
||||
- [ ] Modify `src/types.ts`:
|
||||
- [ ] Add `SavedQuiz` interface (extends `Quiz` with id, source, dates)
|
||||
- [ ] Add `QuizListItem` interface (for list view)
|
||||
- [ ] Add `QuizSource` type: `'manual' | 'ai_generated'`
|
||||
- [x] Modify `types.ts`:
|
||||
- [x] Add `SavedQuiz` interface (extends `Quiz` with id, source, dates)
|
||||
- [x] Add `QuizListItem` interface (for list view)
|
||||
- [x] Add `QuizSource` type: `'manual' | 'ai_generated'`
|
||||
|
||||
### 4.5 Game Hook Integration
|
||||
- [x] Modify `hooks/useGame.ts`:
|
||||
- [x] Add `loadSavedQuiz(quiz)` function
|
||||
- [x] Export for App.tsx to consume
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -403,7 +406,7 @@ kaboot/
|
|||
| Phase 1 | **COMPLETE** | Docker Compose, .env, setup script, Authentik docs |
|
||||
| Phase 2 | **COMPLETE** | Backend API with Express, SQLite, JWT auth, Quiz CRUD |
|
||||
| Phase 3 | **COMPLETE** | OIDC config, AuthProvider, AuthButton, useAuthenticatedFetch |
|
||||
| Phase 4 | Not Started | |
|
||||
| Phase 4 | **COMPLETE** | useQuizLibrary hook, QuizLibrary modal, Landing integration |
|
||||
| Phase 5 | Not Started | |
|
||||
| Phase 6 | Not Started | |
|
||||
| Phase 7 | Not Started | |
|
||||
|
|
|
|||
|
|
@ -1,21 +1,36 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { BrainCircuit, Loader2, Users, Play, PenTool } from 'lucide-react';
|
||||
import { BrainCircuit, Loader2, Play, PenTool, BookOpen } from 'lucide-react';
|
||||
import { useAuth } from 'react-oidc-context';
|
||||
import { AuthButton } from './AuthButton';
|
||||
import { QuizLibrary } from './QuizLibrary';
|
||||
import { useQuizLibrary } from '../hooks/useQuizLibrary';
|
||||
import type { Quiz } from '../types';
|
||||
|
||||
interface LandingProps {
|
||||
onGenerate: (topic: string) => void;
|
||||
onCreateManual: () => void;
|
||||
onLoadQuiz: (quiz: Quiz) => void;
|
||||
onJoin: (pin: string, name: string) => void;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onJoin, isLoading, error }) => {
|
||||
export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error }) => {
|
||||
const auth = useAuth();
|
||||
const [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST');
|
||||
const [topic, setTopic] = useState('');
|
||||
const [pin, setPin] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [libraryOpen, setLibraryOpen] = useState(false);
|
||||
|
||||
const { quizzes, loading: libraryLoading, error: libraryError, fetchQuizzes, loadQuiz, deleteQuiz } = useQuizLibrary();
|
||||
|
||||
useEffect(() => {
|
||||
if (libraryOpen && auth.isAuthenticated) {
|
||||
fetchQuizzes();
|
||||
}
|
||||
}, [libraryOpen, auth.isAuthenticated, fetchQuizzes]);
|
||||
|
||||
const handleHostSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -27,6 +42,12 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
if (pin.trim() && name.trim()) onJoin(pin, name);
|
||||
};
|
||||
|
||||
const handleLoadQuiz = async (id: string) => {
|
||||
const quiz = await loadQuiz(id);
|
||||
setLibraryOpen(false);
|
||||
onLoadQuiz(quiz);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center relative">
|
||||
<div className="absolute top-4 right-4">
|
||||
|
|
@ -93,6 +114,15 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
>
|
||||
<PenTool size={20} /> Create Manually
|
||||
</button>
|
||||
|
||||
{auth.isAuthenticated && (
|
||||
<button
|
||||
onClick={() => setLibraryOpen(true)}
|
||||
className="w-full bg-gray-100 text-gray-600 py-3 rounded-2xl text-lg font-black hover:bg-gray-200 shadow-[0_4px_0_#d1d5db] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<BookOpen size={20} /> My Quizzes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleJoinSubmit} className="space-y-4">
|
||||
|
|
@ -126,6 +156,16 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<QuizLibrary
|
||||
isOpen={libraryOpen}
|
||||
onClose={() => setLibraryOpen(false)}
|
||||
quizzes={quizzes}
|
||||
loading={libraryLoading}
|
||||
error={libraryError}
|
||||
onLoadQuiz={handleLoadQuiz}
|
||||
onDeleteQuiz={deleteQuiz}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
190
components/QuizLibrary.tsx
Normal file
190
components/QuizLibrary.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Trash2, Play, BrainCircuit, PenTool, Loader2, Calendar } from 'lucide-react';
|
||||
import { QuizListItem } from '../types';
|
||||
|
||||
interface QuizLibraryProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
quizzes: QuizListItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onLoadQuiz: (id: string) => void;
|
||||
onDeleteQuiz: (id: string) => void;
|
||||
}
|
||||
|
||||
export const QuizLibrary: React.FC<QuizLibraryProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
quizzes,
|
||||
loading,
|
||||
error,
|
||||
onLoadQuiz,
|
||||
onDeleteQuiz,
|
||||
}) => {
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
setDeletingId(id);
|
||||
};
|
||||
|
||||
const confirmDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (deletingId) {
|
||||
onDeleteQuiz(deletingId);
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setDeletingId(null);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
transition={{ type: "spring", bounce: 0.4 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-white w-full max-w-2xl max-h-[80vh] flex flex-col rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] border-4 border-white/50 relative overflow-hidden"
|
||||
>
|
||||
<div className="p-6 border-b-2 border-gray-100 flex justify-between items-center bg-white sticky top-0 z-10">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-gray-900 tracking-tight">My Library</h2>
|
||||
<p className="text-gray-500 font-bold text-sm">Select a quiz to play</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl hover:bg-gray-100 transition-colors text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X size={24} strokeWidth={3} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-gray-50">
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<Loader2 size={48} className="animate-spin mb-4 text-theme-primary" />
|
||||
<p className="font-bold">Loading your quizzes...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="bg-red-50 border-2 border-red-100 p-4 rounded-2xl text-red-500 font-bold text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && quizzes.length === 0 && (
|
||||
<div className="text-center py-12 space-y-4">
|
||||
<div className="bg-gray-100 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<BrainCircuit size={40} className="text-gray-300" />
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-gray-400">No saved quizzes yet</h3>
|
||||
<p className="text-gray-400 font-medium">Create or generate a quiz to save it here!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && quizzes.map((quiz) => (
|
||||
<motion.div
|
||||
key={quiz.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="group bg-white p-4 rounded-2xl border-2 border-gray-100 hover:border-theme-primary hover:shadow-md transition-all cursor-pointer relative overflow-hidden"
|
||||
onClick={() => onLoadQuiz(quiz.id)}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{quiz.source === 'ai_generated' ? (
|
||||
<span className="bg-purple-100 text-purple-600 px-2 py-1 rounded-lg text-xs font-black uppercase tracking-wider flex items-center gap-1">
|
||||
<BrainCircuit size={12} /> AI
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-blue-100 text-blue-600 px-2 py-1 rounded-lg text-xs font-black uppercase tracking-wider flex items-center gap-1">
|
||||
<PenTool size={12} /> Manual
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-400 text-xs font-bold flex items-center gap-1">
|
||||
<Calendar size={12} /> {formatDate(quiz.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-black text-gray-800 mb-1 group-hover:text-theme-primary transition-colors">
|
||||
{quiz.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-500 font-medium text-sm">
|
||||
{quiz.questionCount} question{quiz.questionCount !== 1 ? 's' : ''}
|
||||
{quiz.aiTopic && <span className="text-gray-400"> • Topic: {quiz.aiTopic}</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pl-4">
|
||||
{deletingId === quiz.id ? (
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-500 text-white px-3 py-2 rounded-xl text-sm font-bold shadow-[0_3px_0_#991b1b] active:shadow-none active:translate-y-[3px] transition-all"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelDelete}
|
||||
className="bg-gray-200 text-gray-600 px-3 py-2 rounded-xl text-sm font-bold hover:bg-gray-300 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => handleDeleteClick(e, quiz.id)}
|
||||
className="p-3 rounded-xl text-gray-300 hover:bg-red-50 hover:text-red-500 transition-colors"
|
||||
title="Delete quiz"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
<div className="bg-theme-primary/10 p-3 rounded-xl text-theme-primary group-hover:bg-theme-primary group-hover:text-white transition-all">
|
||||
<Play size={24} fill="currentColor" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
|
@ -93,20 +93,73 @@ Note these endpoints (you'll need them for frontend configuration):
|
|||
|
||||
2. You should see a JSON response with all OIDC endpoints
|
||||
|
||||
## Step 6: Create a Test User (Optional)
|
||||
## Step 6: Create a Test User
|
||||
|
||||
Create a regular user for manual browser testing.
|
||||
|
||||
1. Go to **Directory** > **Users**
|
||||
|
||||
2. Click **Create**
|
||||
|
||||
3. Fill in user details:
|
||||
- Username: `testuser`
|
||||
- Name: `Test User`
|
||||
- Email: `test@example.com`
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Username | `kaboottest` |
|
||||
| Name | `Kaboot Test` |
|
||||
| Email | `kaboottest@test.com` |
|
||||
|
||||
4. After creation, click on the user and go to the **Credentials** tab
|
||||
|
||||
5. Click **Set password** to create a password
|
||||
5. Click **Set password** and set it to `kaboottest`
|
||||
|
||||
6. **Bind the user to the Kaboot application**:
|
||||
- Go to **Applications** > **Applications** > **Kaboot**
|
||||
- Click the **Policy / Group / User Bindings** tab
|
||||
- Click **Bind existing user**
|
||||
- Select `kaboottest` and click **Bind**
|
||||
|
||||
## Step 7: Create a Service Account for API Testing
|
||||
|
||||
Create a service account that can obtain tokens programmatically for automated tests.
|
||||
|
||||
1. Go to **Directory** > **Users**
|
||||
|
||||
2. Click **Create Service Account**
|
||||
|
||||
3. Fill in details:
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Username | `kaboot-test-service` |
|
||||
| Create group | Unchecked |
|
||||
|
||||
4. Click **Create**
|
||||
|
||||
5. **Create an App Password** for the service account:
|
||||
- Click on the newly created `kaboot-test-service` user
|
||||
- Go to the **App passwords** tab
|
||||
- Click **Create App Password**
|
||||
- Name it `api-tests`
|
||||
- Copy the generated password (you won't see it again!)
|
||||
|
||||
6. **Bind the service account to the Kaboot application**:
|
||||
- Go to **Applications** > **Applications** > **Kaboot**
|
||||
- Click the **Policy / Group / User Bindings** tab
|
||||
- Click **Bind existing user**
|
||||
- Select `kaboot-test-service` and click **Bind**
|
||||
|
||||
7. **Save credentials to `server/.env.test`**:
|
||||
```bash
|
||||
TEST_USERNAME=kaboot-test-service
|
||||
TEST_PASSWORD=<paste-app-password-here>
|
||||
```
|
||||
|
||||
8. **Verify token generation works**:
|
||||
```bash
|
||||
cd server
|
||||
npm run test:get-token
|
||||
```
|
||||
|
||||
You should see "Token obtained successfully" and the access token printed.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,10 @@ export const useGame = () => {
|
|||
initializeHostGame(manualQuiz);
|
||||
};
|
||||
|
||||
const loadSavedQuiz = (savedQuiz: Quiz) => {
|
||||
initializeHostGame(savedQuiz);
|
||||
};
|
||||
|
||||
// We use a ref to hold the current handleHostData function
|
||||
// This prevents stale closures in the PeerJS event listeners
|
||||
const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {});
|
||||
|
|
@ -379,6 +383,6 @@ export const useGame = () => {
|
|||
|
||||
return {
|
||||
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId,
|
||||
startQuizGen, startManualCreation, finalizeManualQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion
|
||||
startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion
|
||||
};
|
||||
};
|
||||
103
hooks/useQuizLibrary.ts
Normal file
103
hooks/useQuizLibrary.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useAuthenticatedFetch } from './useAuthenticatedFetch';
|
||||
import type { Quiz, QuizSource, SavedQuiz, QuizListItem } from '../types';
|
||||
|
||||
interface UseQuizLibraryReturn {
|
||||
quizzes: QuizListItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
fetchQuizzes: () => Promise<void>;
|
||||
loadQuiz: (id: string) => Promise<SavedQuiz>;
|
||||
saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise<string>;
|
||||
deleteQuiz: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
||||
const { authFetch, isAuthenticated } = useAuthenticatedFetch();
|
||||
const [quizzes, setQuizzes] = useState<QuizListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchQuizzes = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await authFetch('/api/quizzes');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch quizzes');
|
||||
}
|
||||
const data = await response.json();
|
||||
setQuizzes(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch quizzes');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [authFetch, isAuthenticated]);
|
||||
|
||||
const loadQuiz = useCallback(async (id: string): Promise<SavedQuiz> => {
|
||||
const response = await authFetch(`/api/quizzes/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load quiz');
|
||||
}
|
||||
return response.json();
|
||||
}, [authFetch]);
|
||||
|
||||
const saveQuiz = useCallback(async (
|
||||
quiz: Quiz,
|
||||
source: QuizSource,
|
||||
aiTopic?: string
|
||||
): Promise<string> => {
|
||||
const response = await authFetch('/api/quizzes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: quiz.title,
|
||||
source,
|
||||
aiTopic,
|
||||
questions: quiz.questions.map(q => ({
|
||||
text: q.text,
|
||||
timeLimit: q.timeLimit,
|
||||
options: q.options.map(o => ({
|
||||
text: o.text,
|
||||
isCorrect: o.isCorrect,
|
||||
shape: o.shape,
|
||||
color: o.color,
|
||||
reason: o.reason,
|
||||
})),
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save quiz');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.id;
|
||||
}, [authFetch]);
|
||||
|
||||
const deleteQuiz = useCallback(async (id: string): Promise<void> => {
|
||||
const response = await authFetch(`/api/quizzes/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
throw new Error('Failed to delete quiz');
|
||||
}
|
||||
|
||||
setQuizzes(prev => prev.filter(q => q.id !== id));
|
||||
}, [authFetch]);
|
||||
|
||||
return {
|
||||
quizzes,
|
||||
loading,
|
||||
error,
|
||||
fetchQuizzes,
|
||||
loadQuiz,
|
||||
saveQuiz,
|
||||
deleteQuiz,
|
||||
};
|
||||
};
|
||||
|
|
@ -168,6 +168,264 @@ async function runTests() {
|
|||
await request('GET', `/api/quizzes/${createdQuizId}`, undefined, 404);
|
||||
});
|
||||
|
||||
console.log('\nQuiz Validation Tests:');
|
||||
await test('POST /api/quizzes without title returns 400', async () => {
|
||||
const invalidQuiz = {
|
||||
source: 'manual',
|
||||
questions: [
|
||||
{
|
||||
text: 'Question?',
|
||||
options: [
|
||||
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
await request('POST', '/api/quizzes', invalidQuiz, 400);
|
||||
});
|
||||
|
||||
await test('POST /api/quizzes without source returns 400', async () => {
|
||||
const invalidQuiz = {
|
||||
title: 'Missing Source Quiz',
|
||||
questions: [
|
||||
{
|
||||
text: 'Question?',
|
||||
options: [
|
||||
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
await request('POST', '/api/quizzes', invalidQuiz, 400);
|
||||
});
|
||||
|
||||
await test('POST /api/quizzes without questions returns 400', async () => {
|
||||
const invalidQuiz = {
|
||||
title: 'No Questions Quiz',
|
||||
source: 'manual',
|
||||
questions: [],
|
||||
};
|
||||
await request('POST', '/api/quizzes', invalidQuiz, 400);
|
||||
});
|
||||
|
||||
await test('POST /api/quizzes with empty body returns 400', async () => {
|
||||
await request('POST', '/api/quizzes', {}, 400);
|
||||
});
|
||||
|
||||
console.log('\nQuiz Not Found Tests:');
|
||||
await test('GET /api/quizzes/:id with non-existent ID returns 404', async () => {
|
||||
await request('GET', '/api/quizzes/non-existent-uuid-12345', undefined, 404);
|
||||
});
|
||||
|
||||
await test('PUT /api/quizzes/:id with non-existent ID returns 404', async () => {
|
||||
const quiz = {
|
||||
title: 'Update Non-Existent',
|
||||
questions: [
|
||||
{
|
||||
text: 'Q?',
|
||||
options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
await request('PUT', '/api/quizzes/non-existent-uuid-12345', quiz, 404);
|
||||
});
|
||||
|
||||
await test('DELETE /api/quizzes/:id with non-existent ID returns 404', async () => {
|
||||
await request('DELETE', '/api/quizzes/non-existent-uuid-12345', undefined, 404);
|
||||
});
|
||||
|
||||
console.log('\nQuiz Source Types Tests:');
|
||||
let aiQuizId: string | null = null;
|
||||
|
||||
await test('POST /api/quizzes with ai_generated source and aiTopic', async () => {
|
||||
const aiQuiz = {
|
||||
title: 'AI Generated Quiz',
|
||||
source: 'ai_generated',
|
||||
aiTopic: 'Space Exploration',
|
||||
questions: [
|
||||
{
|
||||
text: 'What planet is known as the Red Planet?',
|
||||
timeLimit: 20,
|
||||
options: [
|
||||
{ text: 'Venus', isCorrect: false, shape: 'triangle', color: 'red' },
|
||||
{ text: 'Mars', isCorrect: true, shape: 'diamond', color: 'blue' },
|
||||
{ text: 'Jupiter', isCorrect: false, shape: 'circle', color: 'yellow' },
|
||||
{ text: 'Saturn', isCorrect: false, shape: 'square', color: 'green' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { data } = await request('POST', '/api/quizzes', aiQuiz, 201);
|
||||
const result = data as { id: string };
|
||||
if (!result.id) throw new Error('Missing quiz id');
|
||||
aiQuizId = result.id;
|
||||
});
|
||||
|
||||
await test('GET /api/quizzes/:id returns aiTopic for AI quiz', async () => {
|
||||
if (!aiQuizId) throw new Error('No AI quiz created');
|
||||
const { data } = await request('GET', `/api/quizzes/${aiQuizId}`);
|
||||
const quiz = data as Record<string, unknown>;
|
||||
if (quiz.source !== 'ai_generated') throw new Error('Wrong source');
|
||||
if (quiz.aiTopic !== 'Space Exploration') throw new Error('Missing or wrong aiTopic');
|
||||
});
|
||||
|
||||
await test('GET /api/quizzes list includes source and questionCount', async () => {
|
||||
const { data } = await request('GET', '/api/quizzes');
|
||||
const quizzes = data as Record<string, unknown>[];
|
||||
if (quizzes.length === 0) throw new Error('Expected at least one quiz');
|
||||
const quiz = quizzes.find((q) => q.id === aiQuizId);
|
||||
if (!quiz) throw new Error('AI quiz not in list');
|
||||
if (quiz.source !== 'ai_generated') throw new Error('Missing source in list');
|
||||
if (typeof quiz.questionCount !== 'number') throw new Error('Missing questionCount');
|
||||
if (quiz.questionCount !== 1) throw new Error('Wrong questionCount');
|
||||
});
|
||||
|
||||
await test('DELETE cleanup AI quiz', async () => {
|
||||
if (!aiQuizId) throw new Error('No AI quiz to delete');
|
||||
await request('DELETE', `/api/quizzes/${aiQuizId}`, undefined, 204);
|
||||
});
|
||||
|
||||
console.log('\nQuiz with Multiple Questions Tests:');
|
||||
let multiQuizId: string | null = null;
|
||||
|
||||
await test('POST /api/quizzes with multiple questions', async () => {
|
||||
const multiQuiz = {
|
||||
title: 'Multi-Question Quiz',
|
||||
source: 'manual',
|
||||
questions: [
|
||||
{
|
||||
text: 'Question 1?',
|
||||
timeLimit: 15,
|
||||
options: [
|
||||
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Question 2?',
|
||||
timeLimit: 25,
|
||||
options: [
|
||||
{ text: 'X', isCorrect: false, shape: 'circle', color: 'yellow' },
|
||||
{ text: 'Y', isCorrect: true, shape: 'square', color: 'green' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Question 3?',
|
||||
timeLimit: 30,
|
||||
options: [
|
||||
{ text: 'P', isCorrect: false, shape: 'triangle', color: 'red', reason: 'Wrong because...' },
|
||||
{ text: 'Q', isCorrect: true, shape: 'diamond', color: 'blue', reason: 'Correct because...' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { data } = await request('POST', '/api/quizzes', multiQuiz, 201);
|
||||
const result = data as { id: string };
|
||||
multiQuizId = result.id;
|
||||
});
|
||||
|
||||
await test('GET /api/quizzes/:id returns all questions with correct order', async () => {
|
||||
if (!multiQuizId) throw new Error('No multi-question quiz created');
|
||||
const { data } = await request('GET', `/api/quizzes/${multiQuizId}`);
|
||||
const quiz = data as { questions: { text: string; timeLimit: number; options: { reason?: string }[] }[] };
|
||||
if (quiz.questions.length !== 3) throw new Error(`Expected 3 questions, got ${quiz.questions.length}`);
|
||||
if (quiz.questions[0].text !== 'Question 1?') throw new Error('Wrong order for Q1');
|
||||
if (quiz.questions[1].text !== 'Question 2?') throw new Error('Wrong order for Q2');
|
||||
if (quiz.questions[2].text !== 'Question 3?') throw new Error('Wrong order for Q3');
|
||||
if (quiz.questions[0].timeLimit !== 15) throw new Error('Wrong timeLimit for Q1');
|
||||
if (quiz.questions[2].options[1].reason !== 'Correct because...') throw new Error('Missing reason field');
|
||||
});
|
||||
|
||||
await test('GET /api/quizzes shows correct questionCount for multi-question quiz', async () => {
|
||||
const { data } = await request('GET', '/api/quizzes');
|
||||
const quizzes = data as Record<string, unknown>[];
|
||||
const quiz = quizzes.find((q) => q.id === multiQuizId);
|
||||
if (!quiz) throw new Error('Multi-question quiz not in list');
|
||||
if (quiz.questionCount !== 3) throw new Error(`Expected questionCount 3, got ${quiz.questionCount}`);
|
||||
});
|
||||
|
||||
await test('PUT /api/quizzes/:id replaces all questions', async () => {
|
||||
if (!multiQuizId) throw new Error('No multi-question quiz created');
|
||||
const updatedQuiz = {
|
||||
title: 'Updated Multi Quiz',
|
||||
questions: [
|
||||
{
|
||||
text: 'Only One Question Now',
|
||||
timeLimit: 10,
|
||||
options: [
|
||||
{ text: 'Solo', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await request('PUT', `/api/quizzes/${multiQuizId}`, updatedQuiz);
|
||||
const { data } = await request('GET', `/api/quizzes/${multiQuizId}`);
|
||||
const quiz = data as { title: string; questions: unknown[] };
|
||||
if (quiz.title !== 'Updated Multi Quiz') throw new Error('Title not updated');
|
||||
if (quiz.questions.length !== 1) throw new Error(`Expected 1 question after update, got ${quiz.questions.length}`);
|
||||
});
|
||||
|
||||
await test('DELETE cleanup multi-question quiz', async () => {
|
||||
if (!multiQuizId) throw new Error('No multi-question quiz to delete');
|
||||
await request('DELETE', `/api/quizzes/${multiQuizId}`, undefined, 204);
|
||||
});
|
||||
|
||||
console.log('\nTimestamp Tests:');
|
||||
let timestampQuizId: string | null = null;
|
||||
|
||||
await test('POST /api/quizzes returns quiz with timestamps', async () => {
|
||||
const quiz = {
|
||||
title: 'Timestamp Test Quiz',
|
||||
source: 'manual',
|
||||
questions: [
|
||||
{
|
||||
text: 'Timestamp Q?',
|
||||
options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { data: createData } = await request('POST', '/api/quizzes', quiz, 201);
|
||||
timestampQuizId = (createData as { id: string }).id;
|
||||
|
||||
const { data } = await request('GET', `/api/quizzes/${timestampQuizId}`);
|
||||
const result = data as Record<string, unknown>;
|
||||
if (!result.createdAt) throw new Error('Missing createdAt');
|
||||
if (!result.updatedAt) throw new Error('Missing updatedAt');
|
||||
});
|
||||
|
||||
await test('PUT /api/quizzes/:id updates updatedAt timestamp', async () => {
|
||||
if (!timestampQuizId) throw new Error('No timestamp quiz created');
|
||||
|
||||
const { data: beforeData } = await request('GET', `/api/quizzes/${timestampQuizId}`);
|
||||
const beforeUpdatedAt = (beforeData as Record<string, unknown>).updatedAt;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
await request('PUT', `/api/quizzes/${timestampQuizId}`, {
|
||||
title: 'Updated Timestamp Quiz',
|
||||
questions: [
|
||||
{
|
||||
text: 'Updated Q?',
|
||||
options: [{ text: 'B', isCorrect: true, shape: 'diamond', color: 'blue' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { data: afterData } = await request('GET', `/api/quizzes/${timestampQuizId}`);
|
||||
const afterUpdatedAt = (afterData as Record<string, unknown>).updatedAt;
|
||||
|
||||
if (beforeUpdatedAt === afterUpdatedAt) throw new Error('updatedAt should have changed');
|
||||
});
|
||||
|
||||
await test('DELETE cleanup timestamp quiz', async () => {
|
||||
if (!timestampQuizId) throw new Error('No timestamp quiz to delete');
|
||||
await request('DELETE', `/api/quizzes/${timestampQuizId}`, undefined, 204);
|
||||
});
|
||||
|
||||
console.log('\n=== Results ===');
|
||||
const passed = results.filter((r) => r.passed).length;
|
||||
const failed = results.filter((r) => !r.passed).length;
|
||||
|
|
|
|||
20
types.ts
20
types.ts
|
|
@ -31,6 +31,26 @@ export interface Quiz {
|
|||
questions: Question[];
|
||||
}
|
||||
|
||||
export type QuizSource = 'manual' | 'ai_generated';
|
||||
|
||||
export interface SavedQuiz extends Quiz {
|
||||
id: string;
|
||||
source: QuizSource;
|
||||
aiTopic?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface QuizListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
source: QuizSource;
|
||||
aiTopic?: string;
|
||||
questionCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Player {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue