// components/Card.tsx import { useEffect, useRef, useState } from "preact/hooks"; import { TargetedKeyboardEvent } from "preact"; import { Signal } from "@preact/signals"; import { decodeData } from "../utils/urlEncoder.ts"; //import { useSpeechSynthesis } from 'react-speech-kit'; interface CardProps { cardInputParams: { setFullAuto: (auto: boolean) => void; runFullAuto: boolean; setTogglePause: (fn: () => void) => void; setIsPausedDisplay: (paused: boolean) => void; questionPhrase: string; answerPhrase: string; nextButton: () => void; audio1: string; audio2: string; onHelpUsed: () => void; flipped: boolean; }; backgroundUrl: string; hideText: boolean; useInputHint: Signal; } export default function Card( { cardInputParams, backgroundUrl, hideText, useInputHint }: CardProps, ) { // const [currentBackgroundImage, setCurrentBackgroundImage] = useState(() => { // //console.log("imageData"); // // if (backgroundUrl) { // // console.log(backgroundUrl.substring(0, 100)); // // } // return backgroundUrl; // }); const audioRef_happy = useRef(null); const audioRef_fail = useRef(null); const inputRef = useRef(null); const [inputValue, setInputValue] = useState(""); const [message, setMessage] = useState(""); const [helpClick, setHelpClick] = useState(false); const [fullAutoInterval, setFullAutoInterval] = useState(0); const [isPaused, setIsPaused] = useState(false); const isPausedRef = useRef(false); const audioRef1 = useRef(null); const audioRef2 = useRef(null); const [initialAudioUrl1, setInitialAudioUrl1] = useState(null); const [initialAudioUrl2, setInitialAudioUrl2] = useState(null); const playSequenceId = useRef(0); const [playSequenceComplete, setPlaySequenceComplete] = useState(true); const [hint, setHintDisplay] = useState(""); // Register toggle pause function useEffect(() => { cardInputParams.setTogglePause(() => { setIsPaused((prev) => { const newVal = !prev; isPausedRef.current = newVal; cardInputParams.setIsPausedDisplay(newVal); return newVal; }); }); }, [cardInputParams]); // Fetch audio files from API when audio1/audio2 props change useEffect(() => { let url1: string | null = null; let url2: string | null = null; if (cardInputParams.audio1 || cardInputParams.audio2) { setPlaySequenceComplete(false); } const fetchAudio = async ( audioFile: string, setter: (url: string | null) => void, ) => { if (!audioFile) { setter(null); return null; } try { const response = await fetch( `/api/audio-blob/${encodeURIComponent(audioFile)}`, { credentials: "include" }, ); if (!response.ok) { console.error("Failed to fetch audio file:", response.statusText); return null; } const blob = await response.blob(); const url = URL.createObjectURL(blob); setter(url); return url; } catch (err) { console.error("Error fetching audio file:", err); return null; } }; const loadAudios = async () => { url1 = cardInputParams.audio1 ? await fetchAudio(cardInputParams.audio1, setInitialAudioUrl1) : ""; url2 = cardInputParams.audio2 ? await fetchAudio(cardInputParams.audio2, setInitialAudioUrl2) : ""; }; loadAudios(); // Cleanup blob URLs on unmount or when audio props change return () => { if (url1) URL.revokeObjectURL(url1); if (url2) URL.revokeObjectURL(url2); }; }, [cardInputParams.audio1, cardInputParams.audio2]); const stopAudio = (audio: HTMLAudioElement | null) => { if (!audio) return; try { audio.pause(); audio.currentTime = 0; } catch (_err) { // Ignore } }; const waitForCanPlayThrough = (audio: HTMLAudioElement) => { if (audio.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA) { return Promise.resolve(); } return new Promise((resolve) => { const onReady = () => { audio.removeEventListener("canplaythrough", onReady); resolve(); }; audio.addEventListener("canplaythrough", onReady); }); }; const playAndWaitDone = async ( audio: HTMLAudioElement, requestId: number, ): Promise => { await waitForCanPlayThrough(audio); if (requestId !== playSequenceId.current) return false; audio.currentTime = 0; try { await audio.play(); } catch (err: unknown) { const errName = (err && typeof err === "object" && "name" in err && typeof (err as { name: unknown }).name === "string") ? (err as { name: string }).name : ""; if (errName === "NotAllowedError") { console.log("Audio autoplay blocked"); return true; } console.error("Audio play error:", err); return true; } await new Promise((resolve) => { const onDone = () => { cleanup(); resolve(); }; const cleanup = () => { audio.removeEventListener("ended", onDone); audio.removeEventListener("pause", onDone); audio.removeEventListener("error", onDone); }; audio.addEventListener("ended", onDone); audio.addEventListener("pause", onDone); audio.addEventListener("error", onDone); }); return true; }; const playSequence = async () => { const requestId = ++playSequenceId.current; stopAudio(audioRef1.current); stopAudio(audioRef2.current); const audio1 = initialAudioUrl1 ? audioRef1.current : null; const audio2 = initialAudioUrl2 ? audioRef2.current : null; if (audio1) { const shouldContinue = await playAndWaitDone(audio1, requestId); if (!shouldContinue || requestId !== playSequenceId.current) { return; } } if (audio2) { await playAndWaitDone(audio2, requestId); } if (cardInputParams.runFullAuto) { let firstCouple = 0; const answerLength = Math.max( 1, (cardInputParams.answerPhrase ?? "").length, ); const autoIntervalMs = Math.max(1, Math.floor(3000 / answerLength)); const autoInterval = setInterval(() => { // Skip if paused if (!isPausedRef.current) { if (firstCouple > 3) { let canClickHelp = false; setHelpClick((prev) => { //if (!prev){ canClickHelp = prev; //return true; //} return prev; }); if (!canClickHelp) { let inputNow = ""; setInputValue((prev) => { inputNow = prev; return prev; }); //console.log("auto!!!",cardInputParams.answerPhrase, inputNow); if (inputNow !== cardInputParams.answerPhrase) { helpNext(inputNow); } else { clearInterval(autoInterval); return; } } } else { firstCouple += 1; } } }, autoIntervalMs); setFullAutoInterval(autoInterval); } if (requestId === playSequenceId.current) { setPlaySequenceComplete(true); } }; // Auto-play audio1 when loaded, then sequence audio2 after audio1 completes useEffect(() => { if (!initialAudioUrl1 && !initialAudioUrl2) return; playSequence(); return () => { playSequenceId.current += 1; stopAudio(audioRef1.current); stopAudio(audioRef2.current); setPlaySequenceComplete(true); }; }, [initialAudioUrl1, initialAudioUrl2]); const effectSetFullAuto = () => { setInputValue(defaultInputValue); return 0; }; //let lastInputValue = ""; //let lastInputValueLength = -1; const defaultInputValue = ""; const setHint = (quizInput: string, specialInput = "") => { const asciitestWord = toAscii(cardInputParams.answerPhrase).toLowerCase(); const hintChars = new Array(asciitestWord.length); let answerInput = ""; if (specialInput === "") { if (quizInput) { answerInput = toAscii(quizInput.trim().toLowerCase()); } } else { answerInput = specialInput.toLowerCase(); } for (let i = 0; i < asciitestWord.length; ++i) { const testCh = asciitestWord[i]; if (/\s/.test(testCh)) { hintChars[i] = " "; } else { if (answerInput && answerInput.length > i) { const answerCh = answerInput[i]; if (!/[a-zA-Z0-9]/.test(answerCh) || answerCh === testCh) { hintChars[i] = cardInputParams.answerPhrase[i]; } else { hintChars[i] = "-"; } } else { if (!/[a-zA-Z0-9]/.test(testCh)) { hintChars[i] = cardInputParams.answerPhrase[i]; } else { hintChars[i] = "-"; } } } } return hintChars.join(""); }; // const loadVoices = () => { // const voices = window.speechSynthesis.getVoices(); // console.log("voices:") // voices.forEach(voice => { // console.log("-> " + voice.name + " " + voice.lang); // }); // } //const { speak } = useSpeechSynthesis(); const speak = (text: any) => { //loadVoices(); // console.log("speech start"); // const utterance = new SpeechSynthesisUtterance("hello world"); // //utterance.lang = 'fil-PH'; // Set language to Filipino // window.speechSynthesis.speak(utterance); // console.log("speech done"); }; // Play the initial audio_file_1 automatically when the component mounts // useEffect(() => { // if (audio_file.startsWith("http") && initialAudioRef.current) { // // Attempt to play programmatically for better browser support // initialAudioRef.current.play().catch(() => { // // Autoplay might be blocked, user must interact // }); // } // }, [audio_file]); useEffect(() => { //console.log("intitial url"); //const decoded = decodeKvURL(initialQuizData.questions[0].background_image_data); //console.log(backgroundImage.substring(0,100)); //setCurrentBackgroundImage(backgroundUrl); if (inputRef.current) { inputRef.current.focus(); } //console.log("background ", backgroundImage); setHintDisplay(setHint("")); }, [cardInputParams]); const handleInputChange = (e: TargetedKeyboardEvent) => { setInputValue((prev) => { // console.log("keypress", // e.currentTarget.value); return e.currentTarget.value.trim(); }); }; const waitForAudioEnd = (audio: HTMLAudioElement): Promise => { return new Promise((resolve) => { const onDone = () => { audio.removeEventListener("ended", onDone); audio.removeEventListener("pause", onDone); audio.removeEventListener("error", onDone); resolve(); }; audio.addEventListener("ended", onDone); audio.addEventListener("pause", onDone); audio.addEventListener("error", onDone); }); }; const playFinalAudio = async () => { if (cardInputParams.audio1 && audioRef1.current) { try { await audioRef1.current.play(); await waitForAudioEnd(audioRef1.current); } catch { // Ignore play errors } } if (audioRef_happy.current) { try { audioRef_happy.current.volume = 0.6; await audioRef_happy.current.play(); await waitForAudioEnd(audioRef_happy.current); } catch { // Ignore play errors } } setTimeout(() => { setInputValue(""); setMessage(""); cardInputParams.nextButton(); setHelpClick(false); }, 1000); }; const checkAnswer = (value: string) => { const processedInput: boolean = handleValidSubmit(value); if (processedInput) { //}.toLowerCase() === answerPhrase.toLowerCase()) { setMessage("Correct! Great job!"); playFinalAudio(); } else { if (audioRef_fail.current) { audioRef_fail.current.volume = 0.6; audioRef_fail.current.play().catch(() => {}); } setMessage(""); } }; const handleKeyPress = (e: TargetedKeyboardEvent) => { if (e.key === "Enter") { if (e.shiftKey) { e.preventDefault(); setFullAutoInterval((prev) => { if (prev) { clearInterval(prev); cardInputParams.setFullAuto(false); } return 0; }); if (!helpClick) { cardInputParams.onHelpUsed(); // Mark as not first attempt setHelpClick(true); helpNext(inputValue); } } else { const timValue = e.currentTarget.value.trim(); //console.log(timValue); setInputValue(timValue); checkAnswer(timValue); } } }; const handleValidSubmit = (quizInput: string) => { let answerInput = ""; if (quizInput) { answerInput = toAscii(quizInput.trim()); //console.log("ascii",answerInput); } else { return false; } const asciitestWord = toAscii(cardInputParams.answerPhrase); if (asciitestWord[0] === "?") { if (answerInput.length === 0) { answerInput = "?"; } else if (answerInput[0] !== "?") { answerInput = "?" + answerInput; } } if (asciitestWord[0] === "!") { if (answerInput.length === 0) { answerInput = "!"; } else if (answerInput[0] !== "!") { answerInput = "!" + answerInput; } } if ( asciitestWord[asciitestWord.length - 1] === "?" && (asciitestWord.length - 1) === answerInput.length ) { answerInput = answerInput + "?"; } if ( asciitestWord[asciitestWord.length - 1] === "!" && (asciitestWord.length - 1) === answerInput.length ) { answerInput = answerInput + "!"; } if ( asciitestWord[asciitestWord.length - 1] === "." && (asciitestWord.length - 1) === answerInput.length ) { answerInput = answerInput + "."; } if (asciitestWord && trimJunk(answerInput) === trimJunk(asciitestWord)) { setInputValue(answerInput); setHintDisplay(setHint(answerInput)); return true; } let outputStr = ""; let i = 0; for (; i < cardInputParams.answerPhrase.length; ++i) { const asciiCh = asciitestWord[i]; if (!/[a-zA-Z0-9]/.test(asciiCh)) { outputStr += cardInputParams.answerPhrase[i]; continue; } if (i >= answerInput.length) { break; } const ch = answerInput[i]; if (ch.toLowerCase() !== asciitestWord[i].toLowerCase()) { break; } outputStr += cardInputParams.answerPhrase[i]; } if (outputStr === cardInputParams.answerPhrase) { setInputValue(outputStr); setHintDisplay(setHint(outputStr)); return true; } setHintDisplay(setHint(outputStr)); setInputValue((prev) => { //console.log("set input end",prev,outputStr); return outputStr; }); return false; }; const helpNext = (value: string) => { const processedInput: string = handleValidHelp(value.trim()); if ( trimJunk(toAscii(processedInput)).toLowerCase() === trimJunk(toAscii(cardInputParams.answerPhrase)).toLowerCase() ) { setFullAutoInterval((prev) => { if (prev) { clearInterval(prev); } return 0; }); setMessage("Correct! Great job!"); playFinalAudio(); } else { setMessage(""); setHelpClick(false); } }; const handleValidHelp = (quizInput: string) => { let answerInput = ""; if (quizInput) { answerInput = toAscii(quizInput.trim()); } const asciitestWord = toAscii(cardInputParams.answerPhrase); if (asciitestWord[0] === "?") { if (answerInput.length === 0) { answerInput = "?"; } else if (answerInput[0] !== "?") { answerInput = "?" + answerInput; } } if (asciitestWord[0] === "!") { if (answerInput.length === 0) { answerInput = "!"; } else if (answerInput[0] !== "!") { answerInput = "!" + answerInput; } } if ( asciitestWord[asciitestWord.length - 1] === "?" && (asciitestWord.length - 1) === answerInput.length ) { answerInput = answerInput + "?"; } if ( asciitestWord[asciitestWord.length - 1] === "!" && (asciitestWord.length - 1) === answerInput.length ) { answerInput = answerInput + "!"; } if ( asciitestWord[asciitestWord.length - 1] === "." && (asciitestWord.length - 1) === answerInput.length ) { answerInput = answerInput + "."; } if (asciitestWord && trimJunk(answerInput) === trimJunk(asciitestWord)) { setInputValue(answerInput); setHintDisplay(setHint(answerInput)); return answerInput; } let outputStr = ""; let i = 0; for (; i < cardInputParams.answerPhrase.length; ++i) { const asciiCh = asciitestWord[i]; if (!/[a-zA-Z0-9]/.test(asciiCh)) { outputStr += cardInputParams.answerPhrase[i]; continue; } if (i >= answerInput.length) { break; } const ch = answerInput[i]; if (ch.toLowerCase() !== asciitestWord[i].toLowerCase()) { break; } outputStr += cardInputParams.answerPhrase[i]; } if (outputStr === cardInputParams.answerPhrase) { setInputValue(outputStr); setHintDisplay(setHint(outputStr)); return outputStr; } if (asciitestWord.length > outputStr.length) { outputStr = asciitestWord.substring(0, outputStr.length + 1); if (outputStr[outputStr.length - 1] === " ") { outputStr = asciitestWord.substring(0, outputStr.length + 1); } setHintDisplay(setHint(quizInput, outputStr)); setInputValue(outputStr); } return outputStr; }; // const divStyle = { // width: "320px", // height: "auto", // backgroundImage: `url(${currentBackgroundImage})`, // backgroundSize: "cover", // backgroundPosition: "center", // // const questionPhrase = cardInputParams.questionPhrase || ""; const trimmedQuestionPhrase = questionPhrase.trim(); const shouldShowPlaceholder = hideText || trimmedQuestionPhrase === ""; const answerPhraseLength = cardInputParams.answerPhrase ? cardInputParams.answerPhrase.length : 0; const placeholderCharCount = Math.min( Math.max(questionPhrase.length, answerPhraseLength, 20), 120, ); const placeholderText = "\u00A0".repeat(placeholderCharCount); if (!shouldShowPlaceholder && !questionPhrase) { return null; } if (cardInputParams.answerPhrase) { return (
{ if (inputRef.current) { inputRef.current.focus(); } }} >
500 ? "url(" + backgroundUrl + ")" : "" : "" }`, backgroundSize: "cover", backgroundPosition: "center", }} className="p-4 rounded-lg theme-card w-80 button-padding" >
{shouldShowPlaceholder ? ( ) : (

{cardInputParams.questionPhrase}

)}
{useInputHint.value && (

{cardInputParams.answerPhrase ? hint : ""}

)}
{cardInputParams.answerPhrase && ( , ) => { if (!helpClick) { handleKeyPress(e); } }} ref={inputRef} placeholder={cardInputParams.flipped ? "Type study language" : "Type meaning language"} className="theme-card-input strokeme w-full px-2 -pt-1 py-2 border rounded-md focus:outline-none focus:ring-2" /> )}
{cardInputParams.answerPhrase && (
)}
); } else { return null; } } function trimJunk(inputStr: string) { if (!inputStr) { return ""; } let i = inputStr.length - 1; while (i > 0 && !/[a-zA-Z0-9]/.test(inputStr[i])) { --i; } return inputStr.substring(0, i + 1).toLowerCase(); } function toAscii(input: string) { if (!input) return ""; return input.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); }