857 lines
24 KiB
TypeScript
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, "");
|
|
}
|