kaboot/hooks/useQuizLibrary.ts

398 lines
12 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, ExportedQuiz, QuizExportFile } from '../types';
interface UseQuizLibraryReturn {
quizzes: QuizListItem[];
loading: boolean;
loadingQuizId: string | null;
deletingQuizId: string | null;
saving: boolean;
exporting: boolean;
importing: 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>;
exportQuizzes: (quizIds: string[]) => Promise<void>;
importQuizzes: (quizzes: ExportedQuiz[]) => Promise<void>;
parseImportFile: (file: File) => Promise<QuizExportFile>;
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 [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const lastOperationRef = useRef<(() => Promise<void>) | null>(null);
const savingRef = useRef(false);
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);
}
const data = await response.json();
toast.success('Quiz loaded!');
return {
...data,
config: data.gameConfig,
};
} 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 (savingRef.current) {
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');
}
}
savingRef.current = true;
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 {
savingRef.current = false;
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 exportQuizzes = useCallback(async (quizIds: string[]): Promise<void> => {
if (quizIds.length === 0) {
toast.error('No quizzes selected');
return;
}
setExporting(true);
try {
const quizzesToExport: ExportedQuiz[] = [];
for (const id of quizIds) {
const response = await authFetch(`/api/quizzes/${id}`);
if (!response.ok) continue;
const data = await response.json();
quizzesToExport.push({
title: data.title,
source: data.source,
aiTopic: data.aiTopic,
config: data.gameConfig,
questions: data.questions.map((q: { id: string; text: string; timeLimit: number; options: { text: string; isCorrect: boolean; shape: string; color: string; reason?: string }[] }) => ({
id: q.id,
text: q.text,
timeLimit: q.timeLimit,
options: q.options,
})),
});
}
const exportData: QuizExportFile = {
version: 1,
exportedAt: new Date().toISOString(),
quizzes: quizzesToExport,
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `kaboot-quizzes-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(`Exported ${quizzesToExport.length} quiz${quizzesToExport.length !== 1 ? 'zes' : ''}`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to export quizzes';
toast.error(message);
throw err;
} finally {
setExporting(false);
}
}, [authFetch]);
const parseImportFile = useCallback(async (file: File): Promise<QuizExportFile> => {
const text = await file.text();
const data = JSON.parse(text);
if (!data.version || !data.quizzes || !Array.isArray(data.quizzes)) {
throw new Error('Invalid export file format');
}
return data as QuizExportFile;
}, []);
const importQuizzes = useCallback(async (quizzesToImport: ExportedQuiz[]): Promise<void> => {
if (quizzesToImport.length === 0) {
toast.error('No quizzes selected');
return;
}
setImporting(true);
let successCount = 0;
try {
for (const quiz of quizzesToImport) {
await saveQuiz(
{ title: quiz.title, questions: quiz.questions, config: quiz.config },
quiz.source,
quiz.aiTopic
);
successCount++;
}
toast.success(`Imported ${successCount} quiz${successCount !== 1 ? 'zes' : ''}`);
await fetchQuizzes();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to import quizzes';
toast.error(`Imported ${successCount} of ${quizzesToImport.length}. ${message}`);
throw err;
} finally {
setImporting(false);
}
}, [saveQuiz, fetchQuizzes]);
const retry = useCallback(async () => {
if (lastOperationRef.current) {
await lastOperationRef.current();
}
}, []);
const clearError = useCallback(() => {
setError(null);
}, []);
return {
quizzes,
loading,
loadingQuizId,
deletingQuizId,
saving,
exporting,
importing,
error,
fetchQuizzes,
loadQuiz,
saveQuiz,
updateQuiz,
updateQuizConfig,
deleteQuiz,
exportQuizzes,
importQuizzes,
parseImportFile,
retry,
clearError,
};
};