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,
|
startQuizGen,
|
||||||
startManualCreation,
|
startManualCreation,
|
||||||
finalizeManualQuiz,
|
finalizeManualQuiz,
|
||||||
|
loadSavedQuiz,
|
||||||
joinGame,
|
joinGame,
|
||||||
startGame,
|
startGame,
|
||||||
handleAnswer,
|
handleAnswer,
|
||||||
|
|
@ -76,6 +77,7 @@ function App() {
|
||||||
<Landing
|
<Landing
|
||||||
onGenerate={startQuizGen}
|
onGenerate={startQuizGen}
|
||||||
onCreateManual={startManualCreation}
|
onCreateManual={startManualCreation}
|
||||||
|
onLoadQuiz={loadSavedQuiz}
|
||||||
onJoin={joinGame}
|
onJoin={joinGame}
|
||||||
isLoading={gameState === 'GENERATING'}
|
isLoading={gameState === 'GENERATING'}
|
||||||
error={error}
|
error={error}
|
||||||
|
|
|
||||||
|
|
@ -194,39 +194,42 @@ Add user accounts via Authentik (OIDC) and persist quizzes to SQLite database. U
|
||||||
## Phase 4: Quiz Library Feature
|
## Phase 4: Quiz Library Feature
|
||||||
|
|
||||||
### 4.1 Quiz Library Hook
|
### 4.1 Quiz Library Hook
|
||||||
- [ ] Create `src/hooks/useQuizLibrary.ts`:
|
- [x] Create `hooks/useQuizLibrary.ts`:
|
||||||
- [ ] State: `quizzes`, `loading`, `error`
|
- [x] State: `quizzes`, `loading`, `error`
|
||||||
- [ ] `fetchQuizzes()` - GET /api/quizzes
|
- [x] `fetchQuizzes()` - GET /api/quizzes
|
||||||
- [ ] `loadQuiz(id)` - GET /api/quizzes/:id, return Quiz
|
- [x] `loadQuiz(id)` - GET /api/quizzes/:id, return Quiz
|
||||||
- [ ] `saveQuiz(quiz, source, aiTopic?)` - POST /api/quizzes
|
- [x] `saveQuiz(quiz, source, aiTopic?)` - POST /api/quizzes
|
||||||
- [ ] `deleteQuiz(id)` - DELETE /api/quizzes/:id
|
- [x] `deleteQuiz(id)` - DELETE /api/quizzes/:id
|
||||||
- [ ] Handle loading and error states
|
- [x] Handle loading and error states
|
||||||
|
|
||||||
### 4.2 Quiz Library UI
|
### 4.2 Quiz Library UI
|
||||||
- [ ] Create `src/components/QuizLibrary.tsx`:
|
- [x] Create `components/QuizLibrary.tsx`:
|
||||||
- [ ] Modal overlay design (consistent with app style)
|
- [x] Modal overlay design (consistent with app style)
|
||||||
- [ ] Header: "My Quizzes" with close button
|
- [x] Header: "My Library" with close button
|
||||||
- [ ] Search/filter input (optional, future enhancement)
|
- [x] Quiz list:
|
||||||
- [ ] Quiz list:
|
- [x] Show title, question count, source badge (AI/Manual), date
|
||||||
- [ ] Show title, question count, source badge (AI/Manual), date
|
- [x] Click to load quiz
|
||||||
- [ ] Click to select
|
- [x] Delete button with confirmation
|
||||||
- [ ] Delete button with confirmation
|
- [x] Empty state: "No saved quizzes yet"
|
||||||
- [ ] Footer: "Load Selected" and "Cancel" buttons
|
- [x] Loading state: Spinner
|
||||||
- [ ] Empty state: "No saved quizzes yet"
|
|
||||||
- [ ] Loading state: Skeleton/spinner
|
|
||||||
|
|
||||||
### 4.3 Landing Page Integration
|
### 4.3 Landing Page Integration
|
||||||
- [ ] Modify `src/components/Landing.tsx`:
|
- [x] Modify `components/Landing.tsx`:
|
||||||
- [ ] Add "My Quizzes" button (only visible when authenticated)
|
- [x] Add "My Quizzes" button (only visible when authenticated)
|
||||||
- [ ] Add state for quiz library modal visibility
|
- [x] Add state for quiz library modal visibility
|
||||||
- [ ] Render `<QuizLibrary />` modal when open
|
- [x] Render `<QuizLibrary />` modal when open
|
||||||
- [ ] Handle quiz load: call `onLoadQuiz` prop with loaded quiz
|
- [x] Handle quiz load: call `onLoadQuiz` prop with loaded quiz
|
||||||
|
|
||||||
### 4.4 Types Update
|
### 4.4 Types Update
|
||||||
- [ ] Modify `src/types.ts`:
|
- [x] Modify `types.ts`:
|
||||||
- [ ] Add `SavedQuiz` interface (extends `Quiz` with id, source, dates)
|
- [x] Add `SavedQuiz` interface (extends `Quiz` with id, source, dates)
|
||||||
- [ ] Add `QuizListItem` interface (for list view)
|
- [x] Add `QuizListItem` interface (for list view)
|
||||||
- [ ] Add `QuizSource` type: `'manual' | 'ai_generated'`
|
- [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 1 | **COMPLETE** | Docker Compose, .env, setup script, Authentik docs |
|
||||||
| Phase 2 | **COMPLETE** | Backend API with Express, SQLite, JWT auth, Quiz CRUD |
|
| Phase 2 | **COMPLETE** | Backend API with Express, SQLite, JWT auth, Quiz CRUD |
|
||||||
| Phase 3 | **COMPLETE** | OIDC config, AuthProvider, AuthButton, useAuthenticatedFetch |
|
| 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 5 | Not Started | |
|
||||||
| Phase 6 | Not Started | |
|
| Phase 6 | Not Started | |
|
||||||
| Phase 7 | 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 { 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 { AuthButton } from './AuthButton';
|
||||||
|
import { QuizLibrary } from './QuizLibrary';
|
||||||
|
import { useQuizLibrary } from '../hooks/useQuizLibrary';
|
||||||
|
import type { Quiz } from '../types';
|
||||||
|
|
||||||
interface LandingProps {
|
interface LandingProps {
|
||||||
onGenerate: (topic: string) => void;
|
onGenerate: (topic: string) => void;
|
||||||
onCreateManual: () => void;
|
onCreateManual: () => void;
|
||||||
|
onLoadQuiz: (quiz: Quiz) => void;
|
||||||
onJoin: (pin: string, name: string) => void;
|
onJoin: (pin: string, name: string) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
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 [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST');
|
||||||
const [topic, setTopic] = useState('');
|
const [topic, setTopic] = useState('');
|
||||||
const [pin, setPin] = useState('');
|
const [pin, setPin] = useState('');
|
||||||
const [name, setName] = 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) => {
|
const handleHostSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -27,6 +42,12 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
||||||
if (pin.trim() && name.trim()) onJoin(pin, name);
|
if (pin.trim() && name.trim()) onJoin(pin, name);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLoadQuiz = async (id: string) => {
|
||||||
|
const quiz = await loadQuiz(id);
|
||||||
|
setLibraryOpen(false);
|
||||||
|
onLoadQuiz(quiz);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center relative">
|
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center relative">
|
||||||
<div className="absolute top-4 right-4">
|
<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
|
<PenTool size={20} /> Create Manually
|
||||||
</button>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleJoinSubmit} className="space-y-4">
|
<form onSubmit={handleJoinSubmit} className="space-y-4">
|
||||||
|
|
@ -126,6 +156,16 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
<QuizLibrary
|
||||||
|
isOpen={libraryOpen}
|
||||||
|
onClose={() => setLibraryOpen(false)}
|
||||||
|
quizzes={quizzes}
|
||||||
|
loading={libraryLoading}
|
||||||
|
error={libraryError}
|
||||||
|
onLoadQuiz={handleLoadQuiz}
|
||||||
|
onDeleteQuiz={deleteQuiz}
|
||||||
|
/>
|
||||||
</div>
|
</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
|
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**
|
1. Go to **Directory** > **Users**
|
||||||
|
|
||||||
2. Click **Create**
|
2. Click **Create**
|
||||||
|
|
||||||
3. Fill in user details:
|
3. Fill in user details:
|
||||||
- Username: `testuser`
|
| Field | Value |
|
||||||
- Name: `Test User`
|
|-------|-------|
|
||||||
- Email: `test@example.com`
|
| Username | `kaboottest` |
|
||||||
|
| Name | `Kaboot Test` |
|
||||||
|
| Email | `kaboottest@test.com` |
|
||||||
|
|
||||||
4. After creation, click on the user and go to the **Credentials** tab
|
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
|
## Environment Variables
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,10 @@ export const useGame = () => {
|
||||||
initializeHostGame(manualQuiz);
|
initializeHostGame(manualQuiz);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSavedQuiz = (savedQuiz: Quiz) => {
|
||||||
|
initializeHostGame(savedQuiz);
|
||||||
|
};
|
||||||
|
|
||||||
// We use a ref to hold the current handleHostData function
|
// We use a ref to hold the current handleHostData function
|
||||||
// This prevents stale closures in the PeerJS event listeners
|
// This prevents stale closures in the PeerJS event listeners
|
||||||
const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {});
|
const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {});
|
||||||
|
|
@ -379,6 +383,6 @@ export const useGame = () => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId,
|
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);
|
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 ===');
|
console.log('\n=== Results ===');
|
||||||
const passed = results.filter((r) => r.passed).length;
|
const passed = results.filter((r) => r.passed).length;
|
||||||
const failed = 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[];
|
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 {
|
export interface Player {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue