417 lines
13 KiB
TypeScript
417 lines
13 KiB
TypeScript
// islands/Quizzing.tsx
|
|
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
|
//import { QuizData } from "../utils/database.ts";
|
|
import { QuizConfig } from "../utils/types.ts";
|
|
import { Signal, signal } from "@preact/signals";
|
|
import Card from "./Card.tsx";
|
|
import { stringify } from "node:querystring";
|
|
|
|
interface QuizzingIProps {
|
|
initialQuizData: QuizConfig;
|
|
runData: string;
|
|
}
|
|
|
|
export const STORAGE_KEY = "QUIZ_STATE";
|
|
|
|
const useAudio1 = signal(true);
|
|
const useAudio2 = signal(true);
|
|
const useInputHint = signal(false);
|
|
|
|
export default function Quizzing({ initialQuizData, runData }: QuizzingIProps) {
|
|
const hideText = initialQuizData &&
|
|
(initialQuizData.quiz_mode === "photo-hide-english" ||
|
|
initialQuizData.quiz_mode === "photo-hide-bisaya");
|
|
|
|
const [quizData, setQuizData] = useState<QuizConfig>(initialQuizData);
|
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
|
const [questionText, setQuextionText] = useState(
|
|
quizData ? quizData.questions[0].text_1 : "",
|
|
);
|
|
const [meaningText, setMeaningText] = useState("");
|
|
const [backgroundUrl, setCurrentBackgroundUrl] = useState("");
|
|
const [quizCompleted, setQuizCompleted] = useState(false);
|
|
const [runFullAuto, setRunFullAuto] = useState(false);
|
|
const [isPausedDisplay, setIsPausedDisplay] = useState(false);
|
|
const togglePauseRef = useRef<() => void>(() => {});
|
|
const [countdown, setCountdown] = useState(3);
|
|
const postButtonRef = useRef<HTMLButtonElement>(null);
|
|
const [count, setCount] = useState(0);
|
|
const [cardKey, setCardKey] = useState(0);
|
|
|
|
const runFullAutoRef = useRef(runFullAuto);
|
|
const meaningTextRef = useRef(meaningText);
|
|
const quizDataRef = useRef(quizData);
|
|
|
|
useEffect(() => {
|
|
runFullAutoRef.current = runFullAuto;
|
|
}, [runFullAuto]);
|
|
|
|
useEffect(() => {
|
|
meaningTextRef.current = meaningText;
|
|
}, [meaningText]);
|
|
|
|
useEffect(() => {
|
|
quizDataRef.current = quizData;
|
|
}, [quizData]);
|
|
|
|
const [cardInputParams, seCardInputParams] = useState({
|
|
setFullAuto: setRunFullAuto,
|
|
runFullAuto: false,
|
|
setTogglePause: (fn: () => void) => {
|
|
togglePauseRef.current = fn;
|
|
},
|
|
setIsPausedDisplay: setIsPausedDisplay,
|
|
questionPhrase: quizData ? quizData.questions[0].text_1 : "",
|
|
answerPhrase: "",
|
|
nextButton: () => {},
|
|
audio1: "",
|
|
audio2: "",
|
|
onHelpUsed: () => {},
|
|
flipped: false,
|
|
});
|
|
|
|
const handleClick = () => {
|
|
setCount((prev) => {
|
|
const newCount = prev + 1;
|
|
//eventEmitter.emit("countUpdated", newCount);
|
|
return newCount;
|
|
});
|
|
};
|
|
|
|
// Ref to track current index synchronously (state updates are async)
|
|
const currentIndexRef = useRef(0);
|
|
|
|
const incTheIndex = (auto: boolean): number => {
|
|
let nextIndex = currentIndexRef.current + 1;
|
|
if (auto && nextIndex >= initialQuizData.questions.length) {
|
|
nextIndex = 0;
|
|
}
|
|
currentIndexRef.current = nextIndex;
|
|
setCurrentQuestionIndex(nextIndex);
|
|
return nextIndex;
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (quizCompleted && countdown > 0) {
|
|
const timer = setTimeout(() => {
|
|
setCountdown(countdown - 1);
|
|
}, 1000);
|
|
return () => clearTimeout(timer);
|
|
} else if (quizCompleted && countdown === 0) {
|
|
if (postButtonRef.current) {
|
|
//console.log(runData);
|
|
postButtonRef.current.click();
|
|
}
|
|
//wind ow.location.href = "/language/dashboard";
|
|
}
|
|
}, [quizCompleted, countdown]);
|
|
|
|
const handleNextQuestionInternal = useCallback((forceAuto?: boolean) => {
|
|
if (forceAuto === true && !runFullAutoRef.current) {
|
|
runFullAutoRef.current = true;
|
|
setRunFullAuto(true);
|
|
}
|
|
|
|
const thisAuto = typeof forceAuto === "boolean"
|
|
? forceAuto
|
|
: runFullAutoRef.current;
|
|
const quizDataNow = quizDataRef.current;
|
|
const meaningTextNow = meaningTextRef.current;
|
|
|
|
// Determine the next index synchronously
|
|
// On first call (meaningText === ""), stay at index 0
|
|
// On subsequent calls, increment
|
|
const isFirstCall = meaningTextNow === "";
|
|
const thisIndex = isFirstCall ? 0 : incTheIndex(thisAuto);
|
|
|
|
if (thisIndex < quizDataNow.questions.length) {
|
|
// Get ALL data from the SAME question object
|
|
const thisQuestion = quizDataNow.questions[thisIndex];
|
|
const text_1 = thisQuestion.text_1;
|
|
const text_2 = thisQuestion.text_2;
|
|
const audio1 = useAudio1.value ? thisQuestion.audio_file_1 : "";
|
|
const audio2 = useAudio2.value ? thisQuestion.audio_file_2 : "";
|
|
const thisDataUrl = thisQuestion.background_image_data || "";
|
|
|
|
// Update all state together
|
|
setQuextionText(text_1);
|
|
setMeaningText(text_2);
|
|
meaningTextRef.current = text_2;
|
|
setCurrentBackgroundUrl(thisDataUrl);
|
|
|
|
const cardParams = {
|
|
setFullAuto: setRunFullAuto,
|
|
runFullAuto: thisAuto,
|
|
setTogglePause: (fn: () => void) => {
|
|
togglePauseRef.current = fn;
|
|
},
|
|
setIsPausedDisplay: setIsPausedDisplay,
|
|
questionPhrase: text_1,
|
|
answerPhrase: text_2,
|
|
nextButton: handleNextQuestion,
|
|
audio1: audio1,
|
|
audio2: audio2,
|
|
onHelpUsed: () => {},
|
|
flipped: initialQuizData.quiz_mode === "photo-hide-english" ||
|
|
initialQuizData.quiz_mode === "photo-see-english" ||
|
|
initialQuizData.quiz_mode === "blank-see-english",
|
|
};
|
|
|
|
seCardInputParams(cardParams);
|
|
setCardKey((prev) => prev + 1); // Force Card remount
|
|
handleClick();
|
|
} else {
|
|
//localStorage.removeItem("QUIZ_STATE");
|
|
setQuizCompleted(true);
|
|
}
|
|
}, [initialQuizData]);
|
|
|
|
const handleNextQuestion = useCallback(() => {
|
|
handleNextQuestionInternal();
|
|
}, [handleNextQuestionInternal]);
|
|
|
|
if (!initialQuizData || !runData) {
|
|
return <div>Unable to load quiz</div>;
|
|
}
|
|
|
|
if (!quizData) {
|
|
return <div>Loading...</div>;
|
|
}
|
|
|
|
if (quizCompleted) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-start pt-20 min-h-screen">
|
|
<h2 className="text-2xl font-bold mb-4 theme-text">Quiz Completed!</h2>
|
|
<div className="relative w-24 h-24">
|
|
<svg
|
|
className="animate-spin -ml-1 mr-3 h-24 w-24 text-blue-500"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
stroke-width="4"
|
|
>
|
|
</circle>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
>
|
|
</path>
|
|
</svg>
|
|
<span className="absolute inset-0 flex items-center justify-center text-2xl font-bold">
|
|
{countdown}
|
|
</span>
|
|
</div>
|
|
<p className="mt-4 theme-text">Redirecting ...</p>
|
|
|
|
<form
|
|
hidden
|
|
action="/language/set-quiz"
|
|
method="POST"
|
|
>
|
|
<input
|
|
type="hidden"
|
|
name="quizConfigData"
|
|
value={runData}
|
|
/>
|
|
<input
|
|
type="hidden"
|
|
name="quizMode"
|
|
value={initialQuizData.quiz_mode}
|
|
/>
|
|
<button hidden ref={postButtonRef} type="submit">
|
|
</button>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
//const currentQuestion = quizData.questions[currentQuestionIndex];
|
|
//if (quizStarted){
|
|
|
|
return (
|
|
<div
|
|
className={`theme-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-clip`}
|
|
>
|
|
<div hidden>
|
|
<button onClick={handleClick}>Increment</button>
|
|
<p>Count: {count}</p>
|
|
</div>
|
|
{meaningText !== ""
|
|
? (
|
|
<Card
|
|
key={cardKey}
|
|
cardInputParams={cardInputParams}
|
|
backgroundUrl={backgroundUrl}
|
|
hideText={hideText}
|
|
useInputHint={useInputHint}
|
|
/>
|
|
)
|
|
: (
|
|
<div className="text-center p-0">
|
|
<p className="theme-text text-xl mb-4">Ready to start quiz</p>
|
|
<p className="theme-text text-sm">
|
|
Questions: {quizData.questions.length}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{meaningText === ""
|
|
? (
|
|
<div className="mt-0 flex justify-center w-full px-2">
|
|
<div className="theme-card w-80 p-4 rounded-lg shadow-md theme-text">
|
|
<div className="text-sm opacity-90 mb-3">
|
|
Keyboard: shift + return = help
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<label className="flex items-center justify-between gap-3">
|
|
<span>Play recording 1</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={useAudio1.value}
|
|
onChange={(
|
|
e,
|
|
) => (useAudio1.value = e.currentTarget.checked)}
|
|
/>
|
|
</label>
|
|
|
|
<label className="flex items-center justify-between gap-3">
|
|
<span>Play recording 2</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={useAudio2.value}
|
|
onChange={(
|
|
e,
|
|
) => (useAudio2.value = e.currentTarget.checked)}
|
|
/>
|
|
</label>
|
|
|
|
<label className="flex items-center justify-between gap-3">
|
|
<span>Input hint on</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={useInputHint.value}
|
|
onChange={(
|
|
e,
|
|
) => (useInputHint.value = e.currentTarget.checked)}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-4">
|
|
<button
|
|
onClick={() => {
|
|
setRunFullAuto(true);
|
|
handleNextQuestionInternal(true);
|
|
}}
|
|
className="bg-blue-500 text-white px-4 py-2 rounded-xl hover:bg-blue-600 w-full whitespace-nowrap"
|
|
title={"Listen Through"}
|
|
>
|
|
Listen Through
|
|
</button>
|
|
<button
|
|
onClick={handleNextQuestion}
|
|
className="bg-blue-500 text-white px-4 py-2 rounded-xl hover:bg-blue-600 w-full whitespace-nowrap"
|
|
title={"Start"}
|
|
>
|
|
Start Quiz
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
: runFullAuto
|
|
? (
|
|
<button
|
|
type="button"
|
|
id="auto-pause-play-button"
|
|
onClick={() => togglePauseRef.current()}
|
|
style={{ top: "320px", left: "50%", marginLeft: "-12px" }}
|
|
className="bg-opacity-50 fixed bg-blue-500 hover:bg-blue-500 hover:bg-opacity-50 transition duration-300 text-white font-bold p-1 rounded-full"
|
|
aria-label={isPausedDisplay ? "Resume auto" : "Pause auto"}
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className="h-6 w-6"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
{isPausedDisplay
|
|
? (
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
|
/>
|
|
)
|
|
: (
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M10 9v6m4-6v6"
|
|
/>
|
|
)}
|
|
</svg>
|
|
</button>
|
|
)
|
|
: (
|
|
<button
|
|
type="button"
|
|
id="next-question-button"
|
|
onClick={handleNextQuestion}
|
|
style={{ top: "305px", left: "50%", marginLeft: "-12px" }}
|
|
className="bg-opacity-50 fixed bg-blue-500 hover:bg-blue-500 hover:bg-opacity-50 transition duration-300 text-white font-bold p-1 rounded-full"
|
|
aria-label="Next question"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className="h-6 w-6"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M9 5l7 7-7 7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
// }
|
|
// else{
|
|
// return (
|
|
// <div className=" bg-opacity-10 backdrop-filter backdrop-blur-lg rounded-xl shadow-2xl p-8 max-w-md w-full">
|
|
|
|
// <button
|
|
// //type="submit"
|
|
// onClick={() => {setQuizStarted(true)}}
|
|
// className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 w-full"
|
|
// >
|
|
// Start
|
|
// </button>
|
|
// </div>
|
|
// );
|
|
// }
|
|
|
|
// Placeholder function for fetching quiz data
|
|
// async function fetchQuizData(quiz_id: number): Promise<{}> {
|
|
// // Implement the actual data fetching logic here
|
|
// // This is just a placeholder
|
|
// return { quiz_id: quiz_id, questions: [] };
|
|
// }
|
|
}
|