771 lines
24 KiB
TypeScript
771 lines
24 KiB
TypeScript
// islands/BrainStudy.tsx
|
|
// Brain study component with locked-set mastery behavior
|
|
// Cards must be mastered through all 6 modes before new cards are introduced
|
|
|
|
import { useEffect, useRef, useState } from "preact/hooks";
|
|
import { signal } from "@preact/signals";
|
|
import Card from "./Card.tsx";
|
|
import { BrainQueueItem } from "../utils/types.ts";
|
|
|
|
// Import mode utilities (these will be inlined for client-side use)
|
|
const QUIZ_MODES = [
|
|
"photo-see-bisaya",
|
|
"photo-see-english",
|
|
"photo-hide-bisaya",
|
|
"photo-hide-english",
|
|
"blank-see-bisaya",
|
|
"blank-see-english",
|
|
] as const;
|
|
|
|
const MODE_LABELS: Record<string, string> = {
|
|
"photo-see-bisaya": "Photo, See Bisaya",
|
|
"photo-see-english": "Photo, See English",
|
|
"photo-hide-bisaya": "Blank, Hide Bisaya",
|
|
"photo-hide-english": "Photo, Hide English",
|
|
"blank-see-bisaya": "Blank, See Bisaya",
|
|
"blank-see-english": "Blank, See English",
|
|
};
|
|
|
|
function getModeDisplayInfo(mode: string) {
|
|
const index = QUIZ_MODES.indexOf(mode as typeof QUIZ_MODES[number]);
|
|
const safeIndex = index >= 0 ? index : 0;
|
|
return {
|
|
index: safeIndex + 1,
|
|
total: QUIZ_MODES.length,
|
|
label: MODE_LABELS[mode] || mode,
|
|
isFinal: safeIndex === QUIZ_MODES.length - 1,
|
|
};
|
|
}
|
|
|
|
function getNextMode(currentMode: string): string | null {
|
|
const index = QUIZ_MODES.indexOf(currentMode as typeof QUIZ_MODES[number]);
|
|
if (index >= 0 && index < QUIZ_MODES.length - 1) {
|
|
return QUIZ_MODES[index + 1];
|
|
}
|
|
return null; // Already at final mode
|
|
}
|
|
|
|
interface BrainStudyProps {
|
|
userId: string;
|
|
role: string;
|
|
initialMode: string;
|
|
tab_index?: number;
|
|
}
|
|
|
|
interface QueueResponse {
|
|
success: boolean;
|
|
queue: BrainQueueItem[];
|
|
stats: {
|
|
totalReviewed: number;
|
|
totalCorrect: number;
|
|
dueNow: number;
|
|
accuracy: number;
|
|
reviewOnlyCount?: number;
|
|
};
|
|
mode: string;
|
|
totalInQueue: number;
|
|
dueCount: number;
|
|
newCount: number;
|
|
activeSetCount?: number;
|
|
}
|
|
|
|
interface SessionStats {
|
|
reviewed: number;
|
|
correctFirstTry: number;
|
|
sessionStart: number;
|
|
modeAdvances: number; // Track how many cards advanced to next mode
|
|
cardsRetired: number; // Cards that completed all 6 modes this session
|
|
}
|
|
|
|
// Extended queue item for client-side tracking
|
|
interface ActiveQueueItem extends BrainQueueItem {
|
|
// Client-side tracking
|
|
clientModeIndex: number; // Current mode being studied (client-side, may differ from server after correct answer)
|
|
clientDisplayMode: string; // Current mode for display
|
|
hasRetired: boolean; // Has completed all 6 modes
|
|
}
|
|
|
|
const useInputHint = signal(false);
|
|
|
|
export default function BrainStudy(
|
|
{ userId, role, initialMode, tab_index }: BrainStudyProps,
|
|
) {
|
|
const [loading, setLoading] = useState(true);
|
|
const [hasStarted, setHasStarted] = useState(false);
|
|
// Active set: the locked set of cards being mastered
|
|
const [activeSet, setActiveSet] = useState<ActiveQueueItem[]>([]);
|
|
// Pool of available new cards (from server) that haven't been added to active set yet
|
|
const [newCardPool, setNewCardPool] = useState<BrainQueueItem[]>([]);
|
|
// Current position in active set rotation
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const [sessionStats, setSessionStats] = useState<SessionStats>({
|
|
reviewed: 0,
|
|
correctFirstTry: 0,
|
|
sessionStart: Date.now(),
|
|
modeAdvances: 0,
|
|
cardsRetired: 0,
|
|
});
|
|
const [overallStats, setOverallStats] = useState({
|
|
totalReviewed: 0,
|
|
totalCorrect: 0,
|
|
dueNow: 0,
|
|
accuracy: 0,
|
|
reviewOnlyCount: 0,
|
|
});
|
|
const [isComplete, setIsComplete] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isFirstAttempt, setIsFirstAttempt] = useState(true);
|
|
const [cardKey, setCardKey] = useState(0);
|
|
|
|
// Card state
|
|
const [cardInputParams, setCardInputParams] = useState({
|
|
setFullAuto: (_auto: boolean) => {},
|
|
runFullAuto: false,
|
|
setTogglePause: (_fn: () => void) => {},
|
|
setIsPausedDisplay: (_paused: boolean) => {},
|
|
questionPhrase: "",
|
|
answerPhrase: "",
|
|
nextButton: () => {},
|
|
audio1: "",
|
|
audio2: "",
|
|
onHelpUsed: () => {},
|
|
flipped: false,
|
|
});
|
|
const [backgroundUrl, setBackgroundUrl] = useState("");
|
|
|
|
// Track first attempt per question
|
|
const firstAttemptRef = useRef(true);
|
|
|
|
// Maximum active set size
|
|
const MAX_ACTIVE_SET_SIZE = 10;
|
|
|
|
// Fetch queue on mount
|
|
useEffect(() => {
|
|
fetchQueue();
|
|
}, []);
|
|
|
|
// Convert server queue items to active queue items with client-side tracking
|
|
const initializeActiveSet = (items: BrainQueueItem[]): ActiveQueueItem[] => {
|
|
return items.map((item) => ({
|
|
...item,
|
|
clientModeIndex: item.mode_index,
|
|
clientDisplayMode: item.display_mode,
|
|
hasRetired: false,
|
|
}));
|
|
};
|
|
|
|
const fetchQueue = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
// Request more cards than we need for the active set so we have a pool to draw from
|
|
// Include tab_index if provided to filter quiz configs by tab
|
|
const tabParam = tab_index !== undefined ? `&tab_index=${tab_index}` : "";
|
|
const response = await fetch(
|
|
`/api/brain/queue?limit=100&newLimit=100${tabParam}`,
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error("Failed to fetch queue");
|
|
}
|
|
const data: QueueResponse = await response.json();
|
|
if (data.success) {
|
|
// Separate the queue into active set (up to MAX_ACTIVE_SET_SIZE) and pool
|
|
|
|
const allCards = data.queue.filter((q) => !q.is_review_only);
|
|
|
|
const initialActiveCards = allCards.slice(0, MAX_ACTIVE_SET_SIZE);
|
|
const remainingPool = allCards.slice(MAX_ACTIVE_SET_SIZE);
|
|
|
|
setActiveSet(initializeActiveSet(initialActiveCards));
|
|
setNewCardPool(remainingPool);
|
|
setOverallStats({
|
|
...data.stats,
|
|
reviewOnlyCount: data.stats.reviewOnlyCount ?? 0,
|
|
});
|
|
setCurrentIndex(0);
|
|
setIsComplete(allCards.length === 0);
|
|
} else {
|
|
throw new Error("Failed to build queue");
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Unknown error");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Configure card display based on mode
|
|
* Each mode determines:
|
|
* - What text is shown (questionPhrase)
|
|
* - What the user types (answerPhrase)
|
|
* - Whether to show the photo (backgroundUrl)
|
|
* - Whether to hide certain text (done via questionPhrase)
|
|
*
|
|
* Both audio tracks always play for every mode.
|
|
*/
|
|
const loadCard = (item: ActiveQueueItem) => {
|
|
const question = item.question_data;
|
|
// Use client-side mode tracking for display
|
|
const mode = item.clientDisplayMode;
|
|
firstAttemptRef.current = true;
|
|
setIsFirstAttempt(true);
|
|
|
|
let questionPhrase = "";
|
|
let answerPhrase = "";
|
|
let showPhoto = true;
|
|
let flipped = false;
|
|
|
|
// Configure based on mode
|
|
switch (mode) {
|
|
case "photo-see-bisaya":
|
|
// Show photo, display Bisaya text, user types English
|
|
questionPhrase = question.text_1; // Bisaya
|
|
answerPhrase = question.text_2; // English
|
|
showPhoto = true;
|
|
flipped = false;
|
|
break;
|
|
|
|
case "photo-see-english":
|
|
// Show photo, display English text, user types Bisaya
|
|
questionPhrase = question.text_2; // English
|
|
answerPhrase = question.text_1; // Bisaya
|
|
showPhoto = true;
|
|
flipped = true;
|
|
break;
|
|
|
|
case "photo-hide-bisaya":
|
|
// Show photo, hide Bisaya (rely on audio), user types Bisaya
|
|
questionPhrase = "";
|
|
answerPhrase = question.text_2; // Bisaya
|
|
showPhoto = false;
|
|
flipped = false;
|
|
break;
|
|
|
|
case "photo-hide-english":
|
|
// Show photo, hide English, user types English
|
|
questionPhrase = "";
|
|
answerPhrase = question.text_1; // English
|
|
showPhoto = true;
|
|
flipped = true;
|
|
break;
|
|
|
|
case "blank-see-bisaya":
|
|
// No photo, display Bisaya text, user types English
|
|
questionPhrase = question.text_2; // Bisaya
|
|
answerPhrase = question.text_1; // English
|
|
showPhoto = false;
|
|
flipped = true;
|
|
break;
|
|
|
|
case "blank-see-english":
|
|
// No photo, display English text, user types Bisaya
|
|
questionPhrase = question.text_1; // English
|
|
answerPhrase = question.text_2; // Bisaya
|
|
showPhoto = false;
|
|
flipped = false;
|
|
break;
|
|
|
|
default:
|
|
// Fallback to photo-see-english behavior
|
|
questionPhrase = question.text_2;
|
|
answerPhrase = question.text_1;
|
|
showPhoto = true;
|
|
}
|
|
|
|
setCardInputParams({
|
|
setFullAuto: () => {},
|
|
runFullAuto: false,
|
|
setTogglePause: () => {},
|
|
setIsPausedDisplay: () => {},
|
|
questionPhrase,
|
|
answerPhrase,
|
|
nextButton: () => handleNextCard(true),
|
|
// Both audio tracks always provided for every mode
|
|
audio1: question.audio_file_1,
|
|
audio2: "",
|
|
onHelpUsed: handleWrongAttempt,
|
|
flipped,
|
|
});
|
|
|
|
// Set background only if mode requires photo
|
|
setBackgroundUrl(showPhoto ? (question.background_image_data || "") : "");
|
|
setCardKey((prev) => prev + 1);
|
|
};
|
|
|
|
// Load the current card whenever the active set or index changes
|
|
useEffect(() => {
|
|
if (!hasStarted) return;
|
|
if (!activeSet.length) return;
|
|
if (isComplete) return;
|
|
// Wrap around if we've gone past the end (for rotation)
|
|
const safeIndex = currentIndex % activeSet.length;
|
|
if (safeIndex !== currentIndex) {
|
|
setCurrentIndex(safeIndex);
|
|
return;
|
|
}
|
|
loadCard(activeSet[safeIndex]);
|
|
}, [activeSet, currentIndex, isComplete, hasStarted]);
|
|
|
|
const recordProgress = async (
|
|
wasCorrectFirstTry: boolean,
|
|
): Promise<{ modeAdvanced: boolean; isReviewOnly: boolean } | null> => {
|
|
const safeIndex = currentIndex % activeSet.length;
|
|
const currentItem = activeSet[safeIndex];
|
|
if (!currentItem) return null;
|
|
|
|
try {
|
|
const response = await fetch("/api/brain/progress", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
questionId: currentItem.question_id,
|
|
quizId: currentItem.quiz_id,
|
|
wasCorrectFirstTry,
|
|
}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
return {
|
|
modeAdvanced: data.modeAdvanced ?? false,
|
|
isReviewOnly: data.isReviewOnly ?? false,
|
|
};
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to record progress:", err);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Pull cards from the pool to fill the active set
|
|
const refillActiveSet = () => {
|
|
if (newCardPool.length === 0) return;
|
|
|
|
const currentActiveCount =
|
|
activeSet.filter((item) => !item.hasRetired).length;
|
|
const slotsAvailable = MAX_ACTIVE_SET_SIZE - currentActiveCount;
|
|
|
|
if (slotsAvailable > 0 && newCardPool.length > 0) {
|
|
const cardsToAdd = newCardPool.slice(0, slotsAvailable);
|
|
const remainingPool = newCardPool.slice(slotsAvailable);
|
|
|
|
setNewCardPool(remainingPool);
|
|
setActiveSet((prev) => {
|
|
// Filter out retired cards and add new ones
|
|
const activeCards = prev.filter((item) => !item.hasRetired);
|
|
const newActiveItems = initializeActiveSet(cardsToAdd);
|
|
return [...activeCards, ...newActiveItems];
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleNextCard = async (wasCorrect: boolean) => {
|
|
// Only count first attempt
|
|
const wasFirstAttempt = firstAttemptRef.current;
|
|
firstAttemptRef.current = false;
|
|
|
|
const safeIndex = currentIndex % activeSet.length;
|
|
const currentItem = activeSet[safeIndex];
|
|
|
|
if (!currentItem) {
|
|
setIsComplete(true);
|
|
return;
|
|
}
|
|
|
|
let cardRetired = false;
|
|
|
|
if (wasFirstAttempt) {
|
|
// Record progress to server
|
|
await recordProgress(wasCorrect);
|
|
|
|
// Update session stats
|
|
setSessionStats((prev) => ({
|
|
...prev,
|
|
reviewed: prev.reviewed + 1,
|
|
correctFirstTry: prev.correctFirstTry + (wasCorrect ? 1 : 0),
|
|
}));
|
|
|
|
if (wasCorrect) {
|
|
// Correct on first try: advance mode
|
|
const nextMode = getNextMode(currentItem.clientDisplayMode);
|
|
|
|
if (nextMode !== null) {
|
|
// Advance to next mode in the progression
|
|
setSessionStats((prev) => ({
|
|
...prev,
|
|
modeAdvances: prev.modeAdvances + 1,
|
|
}));
|
|
|
|
// Update the card's client-side mode
|
|
setActiveSet((prev) => {
|
|
const updated = [...prev];
|
|
const idx = safeIndex;
|
|
if (updated[idx]) {
|
|
updated[idx] = {
|
|
...updated[idx],
|
|
clientModeIndex: updated[idx].clientModeIndex + 1,
|
|
clientDisplayMode: nextMode,
|
|
};
|
|
}
|
|
return updated;
|
|
});
|
|
} else {
|
|
// Already at final mode and correct on first try - card is RETIRED
|
|
cardRetired = true;
|
|
setSessionStats((prev) => ({
|
|
...prev,
|
|
cardsRetired: prev.cardsRetired + 1,
|
|
}));
|
|
|
|
// Mark the card as retired and remove from active set
|
|
setActiveSet((prev) => {
|
|
const updated = prev.filter((_, idx) => idx !== safeIndex);
|
|
return updated;
|
|
});
|
|
}
|
|
}
|
|
// If not correct on first try: card stays in rotation at same mode (no state change needed)
|
|
}
|
|
|
|
// Move to next card in the rotation
|
|
setActiveSet((prevActiveSet) => {
|
|
if (prevActiveSet.length === 0) {
|
|
// All cards retired, try to refill
|
|
if (newCardPool.length > 0) {
|
|
// Will be handled by refillActiveSet
|
|
return prevActiveSet;
|
|
}
|
|
setIsComplete(true);
|
|
return prevActiveSet;
|
|
}
|
|
return prevActiveSet;
|
|
});
|
|
|
|
// Adjust index for next card
|
|
setCurrentIndex((prevIndex) => {
|
|
// Get current active set length (accounting for possible removal)
|
|
const currentSetLength = cardRetired
|
|
? activeSet.length - 1
|
|
: activeSet.length;
|
|
|
|
if (currentSetLength === 0) {
|
|
return 0;
|
|
}
|
|
|
|
// Move to next card, wrapping around
|
|
const nextIndex = cardRetired
|
|
? prevIndex % currentSetLength // Stay at same index (next card shifted into position)
|
|
: (prevIndex + 1) % currentSetLength;
|
|
|
|
return nextIndex;
|
|
});
|
|
|
|
// Check if we need to refill the active set
|
|
if (cardRetired) {
|
|
// Use setTimeout to ensure state has updated
|
|
setTimeout(() => {
|
|
refillActiveSet();
|
|
}, 0);
|
|
}
|
|
|
|
// Check if session is complete
|
|
setTimeout(() => {
|
|
setActiveSet((currentActiveSet) => {
|
|
if (currentActiveSet.length === 0 && newCardPool.length === 0) {
|
|
setIsComplete(true);
|
|
}
|
|
return currentActiveSet;
|
|
});
|
|
}, 100);
|
|
};
|
|
|
|
const handleWrongAttempt = () => {
|
|
// Mark that this is no longer a first attempt
|
|
if (firstAttemptRef.current) {
|
|
firstAttemptRef.current = false;
|
|
setIsFirstAttempt(false);
|
|
}
|
|
};
|
|
|
|
const handleReturnToDashboard = () => {
|
|
const baseUrl = "/language/dashboard";
|
|
const returnUrl = tab_index !== undefined
|
|
? `${baseUrl}?tab_index=${tab_index}`
|
|
: baseUrl;
|
|
globalThis.location.href = returnUrl;
|
|
};
|
|
|
|
const handleRestartSession = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
// Reset all quiz progress on server (back to first study mode)
|
|
const response = await fetch("/api/brain/reset-session", {
|
|
method: "DELETE",
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error("Failed to reset session");
|
|
}
|
|
|
|
// Reset local state
|
|
setSessionStats({
|
|
reviewed: 0,
|
|
correctFirstTry: 0,
|
|
sessionStart: Date.now(),
|
|
modeAdvances: 0,
|
|
cardsRetired: 0,
|
|
});
|
|
setHasStarted(false);
|
|
setActiveSet([]);
|
|
setNewCardPool([]);
|
|
setCurrentIndex(0);
|
|
setIsComplete(false);
|
|
await fetchQueue();
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof Error
|
|
? err.message
|
|
: "Failed to reset session. Please try again.",
|
|
);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Loading state
|
|
if (loading) {
|
|
return (
|
|
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
|
|
<h2 className="text-2xl font-bold mb-4 theme-text">Brain Study</h2>
|
|
<div className="animate-spin w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full mx-auto">
|
|
</div>
|
|
<p className="mt-4 theme-text">Loading study queue...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Error state
|
|
if (error) {
|
|
return (
|
|
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
|
|
<h2 className="text-2xl font-bold mb-4 theme-text">Error</h2>
|
|
<p className="text-red-500 mb-4">{error}</p>
|
|
<button
|
|
onClick={fetchQueue}
|
|
className="bg-blue-500 text-white px-6 py-2 rounded-full hover:bg-blue-600"
|
|
>
|
|
Try Again
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Complete state
|
|
if (isComplete) {
|
|
const accuracy = sessionStats.reviewed > 0
|
|
? Math.round((sessionStats.correctFirstTry / sessionStats.reviewed) * 100)
|
|
: 0;
|
|
|
|
return (
|
|
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
|
|
<h2 className="text-2xl font-bold mb-4 theme-text">
|
|
Session Complete!
|
|
</h2>
|
|
|
|
<div className="space-y-4 mb-6">
|
|
<div className="bg-green-100 dark:bg-green-900 p-4 rounded-lg">
|
|
<p className="text-3xl font-bold text-green-600 dark:text-green-400">
|
|
{sessionStats.reviewed}
|
|
</p>
|
|
<p className="text-sm theme-text">Cards Reviewed</p>
|
|
</div>
|
|
|
|
<div className="bg-blue-100 dark:bg-blue-900 p-4 rounded-lg">
|
|
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
|
{accuracy}%
|
|
</p>
|
|
<p className="text-sm theme-text">First-Try Accuracy</p>
|
|
</div>
|
|
|
|
<div className="bg-purple-100 dark:bg-purple-900 p-4 rounded-lg">
|
|
<p className="text-xl font-bold text-purple-600 dark:text-purple-400">
|
|
{sessionStats.correctFirstTry} / {sessionStats.reviewed}
|
|
</p>
|
|
<p className="text-sm theme-text">Correct on First Try</p>
|
|
</div>
|
|
|
|
{sessionStats.modeAdvances > 0 && (
|
|
<div className="bg-yellow-100 dark:bg-yellow-900 p-4 rounded-lg">
|
|
<p className="text-xl font-bold text-yellow-600 dark:text-yellow-400">
|
|
{sessionStats.modeAdvances}
|
|
</p>
|
|
<p className="text-sm theme-text">Mode Advances</p>
|
|
</div>
|
|
)}
|
|
|
|
{sessionStats.cardsRetired > 0 && (
|
|
<div className="bg-emerald-100 dark:bg-emerald-900 p-4 rounded-lg">
|
|
<p className="text-xl font-bold text-emerald-600 dark:text-emerald-400">
|
|
{sessionStats.cardsRetired}
|
|
</p>
|
|
<p className="text-sm theme-text">
|
|
Cards Fully Mastered (All 6 Modes)
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<button
|
|
onClick={handleRestartSession}
|
|
className="w-full bg-blue-500 text-white px-6 py-3 rounded-full hover:bg-blue-600 font-semibold"
|
|
>
|
|
Start New Session
|
|
</button>
|
|
<button
|
|
onClick={handleReturnToDashboard}
|
|
className="w-full bg-gray-500 text-white px-6 py-3 rounded-full hover:bg-gray-600 font-semibold"
|
|
>
|
|
Return to Dashboard
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!hasStarted) {
|
|
return (
|
|
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
|
|
<h2 className="text-2xl font-bold mb-4 theme-text">Brain Study</h2>
|
|
<p className="theme-text mb-6">
|
|
Press Start to begin. This enables audio playback for the first card.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
try {
|
|
const Webkit = globalThis as unknown as {
|
|
webkitAudioContext?: typeof AudioContext;
|
|
};
|
|
const Ctx = globalThis.AudioContext || Webkit.webkitAudioContext;
|
|
if (Ctx) {
|
|
const ctx = new Ctx();
|
|
ctx.resume?.();
|
|
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
gain.gain.value = 0;
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
osc.start();
|
|
osc.stop(ctx.currentTime + 0.01);
|
|
setTimeout(() => ctx.close?.(), 50);
|
|
}
|
|
} catch (_err) {
|
|
void _err;
|
|
}
|
|
|
|
setHasStarted(true);
|
|
}}
|
|
className="w-full bg-blue-500 text-white px-6 py-3 rounded-full hover:bg-blue-600 font-semibold"
|
|
>
|
|
Start
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Study state
|
|
const safeIndex = activeSet.length > 0 ? currentIndex % activeSet.length : 0;
|
|
const currentItem = activeSet[safeIndex];
|
|
// Show progress as "X cards remaining in active set"
|
|
const progress = `${safeIndex + 1} / ${activeSet.length}`;
|
|
const modeInfo = getModeDisplayInfo(
|
|
currentItem?.clientDisplayMode || QUIZ_MODES[0],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="theme-container p-2 sm:p-3 md:p-2 rounded-lg shadow-md max-w-md mx-auto w-full overflow-clip">
|
|
{/* Header with progress and mode indicator */}
|
|
<div className="flex flex-col gap-2 mb-4">
|
|
{/* Mode indicator row */}
|
|
|
|
{/* Progress row */}
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex-[0.7]">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-medium px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300">
|
|
Step {modeInfo.index}/{modeInfo.total}
|
|
</span>
|
|
</div>
|
|
{currentItem?.is_review_only && (
|
|
<span className="text-xs font-medium px-2 py-1 rounded-full bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300">
|
|
Review
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex-[0.7]">
|
|
<div className="text-sm theme-text">
|
|
<span className="font-semibold">{progress}</span>
|
|
<span className="ml-2 text-gray-500">
|
|
({currentItem?.is_new ? "New" : "Old"})
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex-[0.6]">
|
|
<button
|
|
type="button"
|
|
onClick={handleReturnToDashboard}
|
|
className="px-4 py-2 rounded-full theme-button font-semibold shadow-lg"
|
|
>
|
|
Dashboard
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<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`}
|
|
>
|
|
{/* Card */}
|
|
<Card
|
|
key={cardKey}
|
|
cardInputParams={cardInputParams}
|
|
backgroundUrl={backgroundUrl}
|
|
hideText={false}
|
|
useInputHint={useInputHint}
|
|
/>
|
|
|
|
{/* Skip button */}
|
|
<div className="mt-4 flex justify-center">
|
|
<button
|
|
onClick={() => handleNextCard(false)}
|
|
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="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>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|