Add a centralized game configuration system that allows customizable scoring mechanics and game rules. Users can now set default game configurations that persist across sessions, and individual quizzes can have their own configuration overrides. ## New Features ### Game Configuration Options - Shuffle Questions: Randomize question order when starting a game - Shuffle Answers: Randomize answer positions for each question - Host Participates: Toggle whether the host plays as a competitor or spectates (host now shows as 'Spectator' when not participating) - Streak Bonus: Multiplied points for consecutive correct answers, with configurable threshold and multiplier values - Comeback Bonus: Extra points for players ranked below top 3 - Wrong Answer Penalty: Deduct percentage of max points for incorrect answers (configurable percentage) - First Correct Bonus: Extra points for the first player to answer correctly on each question ### Default Settings Management - New Settings icon in landing page header (authenticated users only) - DefaultConfigModal for editing user-wide default game settings - Default configs are loaded when creating new quizzes - Defaults persist to database via new user API endpoints ### Reusable UI Components - GameConfigPanel: Comprehensive toggle-based settings panel with expandable sub-options, tooltips, and suggested values based on question count - DefaultConfigModal: Modal wrapper for editing default configurations ## Technical Changes ### Frontend - New useUserConfig hook for fetching/saving user default configurations - QuizEditor now uses GameConfigPanel instead of inline toggle checkboxes - GameScreen handles spectator mode with disabled answer buttons - Updated useGame hook with new scoring calculations and config state - Improved useAuthenticatedFetch with deduped silent refresh and redirect-once pattern to prevent multiple auth redirects ### Backend - Added game_config column to quizzes table (JSON storage) - Added default_game_config column to users table - New PATCH endpoint for quiz config updates: /api/quizzes/:id/config - New PUT endpoint for user defaults: /api/users/me/default-config - Auto-migration in connection.ts for existing databases ### Scoring System - New calculatePoints() function in constants.ts handles all scoring logic including streaks, comebacks, penalties, and first-correct bonus - New calculateBasePoints() for time-based point calculation - New getPlayerRank() helper for comeback bonus eligibility ### Tests - Added tests for DefaultConfigModal component - Added tests for GameConfigPanel component - Added tests for QuizEditor config integration - Added tests for useUserConfig hook - Updated API tests for new endpoints ## Type Changes - Added GameConfig interface with all configuration options - Added DEFAULT_GAME_CONFIG constant with sensible defaults - Quiz type now includes optional config property
283 lines
8.2 KiB
TypeScript
283 lines
8.2 KiB
TypeScript
import { useState, useCallback, useRef } from 'react';
|
|
import toast from 'react-hot-toast';
|
|
import { useAuthenticatedFetch } from './useAuthenticatedFetch';
|
|
import type { Quiz, QuizSource, SavedQuiz, QuizListItem, GameConfig } from '../types';
|
|
|
|
interface UseQuizLibraryReturn {
|
|
quizzes: QuizListItem[];
|
|
loading: boolean;
|
|
loadingQuizId: string | null;
|
|
deletingQuizId: string | null;
|
|
saving: boolean;
|
|
error: string | null;
|
|
fetchQuizzes: () => Promise<void>;
|
|
loadQuiz: (id: string) => Promise<SavedQuiz>;
|
|
saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise<string>;
|
|
updateQuiz: (id: string, quiz: Quiz) => Promise<void>;
|
|
updateQuizConfig: (id: string, config: GameConfig) => Promise<void>;
|
|
deleteQuiz: (id: string) => Promise<void>;
|
|
retry: () => Promise<void>;
|
|
clearError: () => void;
|
|
}
|
|
|
|
export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
|
const { authFetch, isAuthenticated } = useAuthenticatedFetch();
|
|
const [quizzes, setQuizzes] = useState<QuizListItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [loadingQuizId, setLoadingQuizId] = useState<string | null>(null);
|
|
const [deletingQuizId, setDeletingQuizId] = useState<string | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const lastOperationRef = useRef<(() => Promise<void>) | null>(null);
|
|
|
|
const fetchQuizzes = useCallback(async () => {
|
|
if (!isAuthenticated) return;
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
lastOperationRef.current = fetchQuizzes;
|
|
|
|
try {
|
|
const response = await authFetch('/api/quizzes');
|
|
if (!response.ok) {
|
|
const errorText = response.status === 500
|
|
? 'Server error. Please try again.'
|
|
: 'Failed to load your quizzes.';
|
|
throw new Error(errorText);
|
|
}
|
|
const data = await response.json();
|
|
setQuizzes(data);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to load quizzes';
|
|
setError(message);
|
|
if (!message.includes('redirecting')) {
|
|
toast.error(message);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [authFetch, isAuthenticated]);
|
|
|
|
const loadQuiz = useCallback(async (id: string): Promise<SavedQuiz> => {
|
|
setLoadingQuizId(id);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await authFetch(`/api/quizzes/${id}`);
|
|
if (!response.ok) {
|
|
const errorText = response.status === 404
|
|
? 'Quiz not found. It may have been deleted.'
|
|
: 'Failed to load quiz.';
|
|
throw new Error(errorText);
|
|
}
|
|
toast.success('Quiz loaded!');
|
|
return response.json();
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to load quiz';
|
|
if (!message.includes('redirecting')) {
|
|
toast.error(message);
|
|
}
|
|
throw err;
|
|
} finally {
|
|
setLoadingQuizId(null);
|
|
}
|
|
}, [authFetch]);
|
|
|
|
const saveQuiz = useCallback(async (
|
|
quiz: Quiz,
|
|
source: QuizSource,
|
|
aiTopic?: string
|
|
): Promise<string> => {
|
|
if (saving) {
|
|
toast.error('Save already in progress');
|
|
throw new Error('Save already in progress');
|
|
}
|
|
|
|
if (!quiz.title?.trim()) {
|
|
toast.error('Quiz must have a title');
|
|
throw new Error('Quiz must have a title');
|
|
}
|
|
if (!quiz.questions || quiz.questions.length === 0) {
|
|
toast.error('Quiz must have at least one question');
|
|
throw new Error('Quiz must have at least one question');
|
|
}
|
|
for (const q of quiz.questions) {
|
|
if (!q.text?.trim()) {
|
|
toast.error('All questions must have text');
|
|
throw new Error('All questions must have text');
|
|
}
|
|
if (!q.options || q.options.length < 2) {
|
|
toast.error('Each question must have at least 2 options');
|
|
throw new Error('Each question must have at least 2 options');
|
|
}
|
|
const hasCorrect = q.options.some(o => o.isCorrect);
|
|
if (!hasCorrect) {
|
|
toast.error('Each question must have a correct answer');
|
|
throw new Error('Each question must have a correct answer');
|
|
}
|
|
}
|
|
|
|
setSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await authFetch('/api/quizzes', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
title: quiz.title,
|
|
source,
|
|
aiTopic,
|
|
gameConfig: quiz.config,
|
|
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) {
|
|
const errorText = response.status === 400
|
|
? 'Invalid quiz data. Please check and try again.'
|
|
: 'Failed to save quiz.';
|
|
throw new Error(errorText);
|
|
}
|
|
|
|
const data = await response.json();
|
|
toast.success('Quiz saved to your library!');
|
|
return data.id;
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to save quiz';
|
|
if (!message.includes('redirecting')) {
|
|
toast.error(message);
|
|
}
|
|
throw err;
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [authFetch]);
|
|
|
|
const updateQuiz = useCallback(async (id: string, quiz: Quiz): Promise<void> => {
|
|
setSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await authFetch(`/api/quizzes/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
title: quiz.title,
|
|
gameConfig: quiz.config,
|
|
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) {
|
|
const errorText = response.status === 404
|
|
? 'Quiz not found.'
|
|
: 'Failed to update quiz.';
|
|
throw new Error(errorText);
|
|
}
|
|
|
|
toast.success('Quiz updated!');
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to update quiz';
|
|
if (!message.includes('redirecting')) {
|
|
toast.error(message);
|
|
}
|
|
throw err;
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [authFetch]);
|
|
|
|
const updateQuizConfig = useCallback(async (id: string, config: GameConfig): Promise<void> => {
|
|
try {
|
|
const response = await authFetch(`/api/quizzes/${id}/config`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ gameConfig: config }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to update config');
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to update config';
|
|
if (!message.includes('redirecting')) {
|
|
toast.error(message);
|
|
}
|
|
throw err;
|
|
}
|
|
}, [authFetch]);
|
|
|
|
const deleteQuiz = useCallback(async (id: string): Promise<void> => {
|
|
setDeletingQuizId(id);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await authFetch(`/api/quizzes/${id}`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
if (!response.ok && response.status !== 204) {
|
|
const errorText = response.status === 404
|
|
? 'Quiz not found.'
|
|
: 'Failed to delete quiz.';
|
|
throw new Error(errorText);
|
|
}
|
|
|
|
setQuizzes(prev => prev.filter(q => q.id !== id));
|
|
toast.success('Quiz deleted');
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to delete quiz';
|
|
if (!message.includes('redirecting')) {
|
|
toast.error(message);
|
|
}
|
|
throw err;
|
|
} finally {
|
|
setDeletingQuizId(null);
|
|
}
|
|
}, [authFetch]);
|
|
|
|
const retry = useCallback(async () => {
|
|
if (lastOperationRef.current) {
|
|
await lastOperationRef.current();
|
|
}
|
|
}, []);
|
|
|
|
const clearError = useCallback(() => {
|
|
setError(null);
|
|
}, []);
|
|
|
|
return {
|
|
quizzes,
|
|
loading,
|
|
loadingQuizId,
|
|
deletingQuizId,
|
|
saving,
|
|
error,
|
|
fetchQuizzes,
|
|
loadQuiz,
|
|
saveQuiz,
|
|
updateQuiz,
|
|
updateQuizConfig,
|
|
deleteQuiz,
|
|
retry,
|
|
clearError,
|
|
};
|
|
};
|