2026-01-20 09:02:26 -06:00

857 lines
24 KiB
TypeScript

// 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<boolean>;
}
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<HTMLAudioElement>(null);
const audioRef_fail = useRef<HTMLAudioElement>(null);
const inputRef = useRef<HTMLInputElement>(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<HTMLAudioElement>(null);
const audioRef2 = useRef<HTMLAudioElement>(null);
const [initialAudioUrl1, setInitialAudioUrl1] = useState<string | null>(null);
const [initialAudioUrl2, setInitialAudioUrl2] = useState<string | null>(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<void>((resolve) => {
const onReady = () => {
audio.removeEventListener("canplaythrough", onReady);
resolve();
};
audio.addEventListener("canplaythrough", onReady);
});
};
const playAndWaitDone = async (
audio: HTMLAudioElement,
requestId: number,
): Promise<boolean> => {
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<void>((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<HTMLInputElement>) => {
setInputValue((prev) => {
// console.log("keypress",
// e.currentTarget.value);
return e.currentTarget.value.trim();
});
};
const waitForAudioEnd = (audio: HTMLAudioElement): Promise<void> => {
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<HTMLInputElement>) => {
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 (
<div
onClick={() => {
if (inputRef.current) {
inputRef.current.focus();
}
}}
>
<div
style={{
width: "320px",
height: "320px",
backgroundImage: `${
backgroundUrl
? backgroundUrl.startsWith("/api")
? "url(" + backgroundUrl + ")"
: backgroundUrl.startsWith("http")
? "url(" + CSS.escape(decodeData(backgroundUrl)) + ")"
: backgroundUrl.length > 500
? "url(" + backgroundUrl + ")"
: ""
: ""
}`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
className="p-4 rounded-lg theme-card w-80 button-padding"
>
<div className="flex flex-col h-full">
<div className="text-center">
{shouldShowPlaceholder
? (
<span aria-hidden="true" className="select-none">
</span>
)
: (
<p className="strokeme theme-question-text text-2xl">
{cardInputParams.questionPhrase}
</p>
)}
</div>
<div className="absolute bottom-[-20px] left-0 right-0 px-4 space-y-2">
<div className="text-center">
{useInputHint.value &&
(
<p className="strokeme theme-question-text">
{cardInputParams.answerPhrase ? hint : ""}
</p>
)}
</div>
<div>
<div hidden>
{initialAudioUrl1 &&
(
<audio
ref={audioRef1}
src={initialAudioUrl1}
preload="auto"
controls={false}
/>
)}
{initialAudioUrl2 &&
(
<audio
ref={audioRef2}
src={initialAudioUrl2}
preload="auto"
controls={false}
/>
)}
</div>
{cardInputParams.answerPhrase &&
(
<input
type="text"
id="input1"
name="input1"
value={inputValue}
onChange={handleInputChange}
onKeyPress={(
e: TargetedKeyboardEvent<HTMLInputElement>,
) => {
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"
/>
)}
</div>
{cardInputParams.answerPhrase &&
(
<div className="flex pt-0 pb-[30px]">
<span className="w-[30px]"></span>
<button
onClick={() => {
setFullAutoInterval((prev) => {
if (prev) {
clearInterval(prev);
cardInputParams.setFullAuto(false);
}
return 0;
});
if (!helpClick) {
cardInputParams.onHelpUsed(); // Mark as not first attempt
setHelpClick(true);
helpNext(inputValue);
}
}}
className="w-[100px] theme-card-button py-2 px-4 rounded-md transition duration-300"
>
Help
</button>
<span className="w-[65px]"></span>
<button
onClick={() => {
setFullAutoInterval((prev) => {
if (prev) {
clearInterval(prev);
cardInputParams.setFullAuto(false);
}
return 0;
});
if (!helpClick) {
checkAnswer(inputValue);
}
}}
className="w-[100px] theme-card-button py-2 px-4 rounded-md transition duration-300"
>
Enter
</button>
</div>
)}
</div>
</div>
</div>
<audio
ref={audioRef_happy}
src="/116779__domrodrig-bell-happy-trim.mp3"
/>
<audio ref={audioRef_fail} src="/342756__rhodesmas__failure-01.mp3" />
</div>
);
} 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, "");
}