|
|
import { Component, OnDestroy } from '@angular/core'; |
|
|
import { FormsModule } from '@angular/forms'; |
|
|
import { CommonModule } from '@angular/common'; |
|
|
import { GenerateQuestionsService } from './generate-questions.service'; |
|
|
import { Router } from '@angular/router'; |
|
|
import confetti from 'canvas-confetti'; |
|
|
import { HeaderComponent } from '../shared/header/header.component'; |
|
|
import { ButtonComponent } from '../shared/button/button.component'; |
|
|
|
|
|
interface QuestionWithAnswer { |
|
|
question: string; |
|
|
correctAnswer: string; |
|
|
} |
|
|
|
|
|
interface Question { |
|
|
parts: string[]; |
|
|
index: number; |
|
|
} |
|
|
|
|
|
const COUNTDOWN_SECONDS = 10; |
|
|
const HINT_AUTO_HIDE_MS = 5000; |
|
|
const NEXT_LEVEL_DELAY_MS = 2000; |
|
|
const COMPLETION_DELAY_MS = 3000; |
|
|
const CONFETTI_INTERVAL_MS = 250; |
|
|
const RING_RADIUS = 90; |
|
|
|
|
|
const INCORRECT_INDICATORS = [ |
|
|
'incorrect', 'not correct', 'wrong', 'not right', |
|
|
'is not', 'are not', 'isn\'t correct', 'aren\'t correct' |
|
|
]; |
|
|
|
|
|
const CORRECT_INDICATORS = [ |
|
|
'correct', 'right', 'yes', 'accurate', |
|
|
'well done', 'good job', 'perfect' |
|
|
]; |
|
|
|
|
|
@Component({ |
|
|
selector: 'app-generate-questions', |
|
|
standalone: true, |
|
|
imports: [FormsModule, CommonModule, HeaderComponent, ButtonComponent], |
|
|
templateUrl: './generate-questions.component.html', |
|
|
styleUrls: ['./generate-questions.component.css'] |
|
|
}) |
|
|
export class GenerateQuestionsComponent implements OnDestroy { |
|
|
topic = ''; |
|
|
readonly hardcodedTopics = ['Noun', 'Verb', 'Past Tense', 'Adjective', 'Present Continuous']; |
|
|
questions: Question[] = []; |
|
|
questionsWithAnswers: QuestionWithAnswer[] = []; |
|
|
|
|
|
userAnswers: string[] = []; |
|
|
answerStatuses: ('correct' | 'incorrect' | 'pending')[] = []; |
|
|
feedback: string[] = []; |
|
|
hints: string[] = []; |
|
|
readonlyAnswers: boolean[] = []; |
|
|
attemptCounts: Record<number, number> = {}; |
|
|
|
|
|
currentDifficulty = 'basic'; |
|
|
readonly difficultyLevels = ['basic', 'intermediate', 'expert']; |
|
|
|
|
|
showSuggestions = false; |
|
|
isQuestionsGenerated = false; |
|
|
isValidationInProgress = false; |
|
|
isFirstAttemptDone = false; |
|
|
isAllLevelsCompleted = false; |
|
|
isHintMenuVisible = false; |
|
|
isLoading = false; |
|
|
isChecked = false; |
|
|
error = ''; |
|
|
|
|
|
get isGenerateDisabled() { return !this.topic.trim() || this.isQuestionsGenerated; } |
|
|
get isResetDisabled() { return !this.topic && !this.isQuestionsGenerated; } |
|
|
get isTopicLocked() { return this.isQuestionsGenerated; } |
|
|
get isDropdownDisabled() { return this.isQuestionsGenerated; } |
|
|
get showLevelTooltip() { return this.isQuestionsGenerated; } |
|
|
get activateLevelDot() { return this.isQuestionsGenerated; } |
|
|
|
|
|
private hasShownHintIcon = false; |
|
|
showHintIcon = false; |
|
|
hasNewHints = false; |
|
|
|
|
|
showGlobalCountdown = false; |
|
|
globalCountdown = 0; |
|
|
overlayCaption = ''; |
|
|
ringCircumference = 2 * Math.PI * RING_RADIUS; |
|
|
ringDashoffset = this.ringCircumference; |
|
|
|
|
|
private globalTimer: any; |
|
|
private overlayTicker: any; |
|
|
private overlayEndTs = 0; |
|
|
private globalCountdownActive = false; |
|
|
private confettiInterval: any; |
|
|
|
|
|
constructor( |
|
|
private service: GenerateQuestionsService, |
|
|
private router: Router |
|
|
) {} |
|
|
|
|
|
ngOnDestroy(): void { |
|
|
this.clearTimers(); |
|
|
this.stopConfettiAnimation(); |
|
|
} |
|
|
|
|
|
private clearTimers(): void { |
|
|
if (this.globalTimer) clearInterval(this.globalTimer); |
|
|
if (this.overlayTicker) clearInterval(this.overlayTicker); |
|
|
this.globalTimer = null; |
|
|
this.overlayTicker = null; |
|
|
this.showGlobalCountdown = false; |
|
|
this.globalCountdownActive = false; |
|
|
} |
|
|
|
|
|
onTopicChange(): void { |
|
|
this.showSuggestions = !this.topic.trim(); |
|
|
} |
|
|
|
|
|
getProgressWidth(): string { |
|
|
const index = this.difficultyLevels.indexOf(this.currentDifficulty); |
|
|
if (index === -1 || this.difficultyLevels.length <= 1) return '0%'; |
|
|
return `${(index / (this.difficultyLevels.length - 1)) * 100}%`; |
|
|
} |
|
|
|
|
|
generateQuestions(): void { |
|
|
if (this.isAllLevelsCompleted) { |
|
|
this.error = 'Please reset to start over.'; |
|
|
return; |
|
|
} |
|
|
|
|
|
this.resetQuestionState(); |
|
|
|
|
|
this.service.generateQuestions(this.topic, this.currentDifficulty) |
|
|
.subscribe({ |
|
|
next: (response) => this.handleQuestionsResponse(response), |
|
|
error: (error) => this.handleQuestionsError(error) |
|
|
}); |
|
|
} |
|
|
|
|
|
private handleQuestionsResponse(response: any): void { |
|
|
const rawQuestions = (response?.text ?? response?.generations?.[0]?.text ?? '').trim(); |
|
|
|
|
|
if (!rawQuestions) { |
|
|
this.error = 'No questions generated. Please try again.'; |
|
|
return; |
|
|
} |
|
|
|
|
|
this.isQuestionsGenerated = true; |
|
|
this.parseAndInitializeQuestions(rawQuestions); |
|
|
} |
|
|
|
|
|
private handleQuestionsError(error: any): void { |
|
|
this.error = error.status === 400 && error.error.message |
|
|
? error.error.message |
|
|
: 'Failed to fetch questions. Please try again later.'; |
|
|
} |
|
|
|
|
|
private parseAndInitializeQuestions(rawQuestions: string): void { |
|
|
this.questionsWithAnswers = this.parseQuestions(rawQuestions); |
|
|
this.questions = this.splitQuestionsIntoParts(this.questionsWithAnswers); |
|
|
this.initializeAnswerArrays(this.questions.length); |
|
|
} |
|
|
|
|
|
private parseQuestions(text: string): QuestionWithAnswer[] { |
|
|
const regex = /(\d+\.\s*.+?_______)\s*(.*?)\s*\(([^)]+)\)\s*$/gm; |
|
|
const questions: QuestionWithAnswer[] = []; |
|
|
let match; |
|
|
|
|
|
while ((match = regex.exec(text)) !== null) { |
|
|
const questionText = match[1]?.trim(); |
|
|
const afterBlank = match[2]?.replace(/^[_-]+/, '').replace(/^\s+/, ' ').replace(/\s{2,}/g, ' '); |
|
|
const correctAnswer = match[3]?.trim(); |
|
|
|
|
|
if (questionText && correctAnswer) { |
|
|
questions.push({ |
|
|
question: `${questionText}${afterBlank}`, |
|
|
correctAnswer |
|
|
}); |
|
|
} |
|
|
} |
|
|
return questions; |
|
|
} |
|
|
|
|
|
private splitQuestionsIntoParts(questions: QuestionWithAnswer[]): Question[] { |
|
|
return questions.map((q, index) => { |
|
|
const normalized = q.question.replace(/\s*_{3,}\s*/g, '_______'); |
|
|
const parts = normalized.split('_______').map(p => p.replace(/[_]+/g, '').trim()); |
|
|
return { parts, index }; |
|
|
}); |
|
|
} |
|
|
|
|
|
private initializeAnswerArrays(length: number): void { |
|
|
this.userAnswers = Array(length).fill(''); |
|
|
this.answerStatuses = Array(length).fill('pending'); |
|
|
this.feedback = Array(length).fill(''); |
|
|
this.readonlyAnswers = Array(length).fill(false); |
|
|
this.hints = []; |
|
|
this.attemptCounts = {}; |
|
|
for (let i = 0; i < length; i++) { |
|
|
this.attemptCounts[i] = 0; |
|
|
} |
|
|
} |
|
|
|
|
|
private resetQuestionState(): void { |
|
|
this.hints = []; |
|
|
this.showSuggestions = false; |
|
|
this.readonlyAnswers = []; |
|
|
} |
|
|
|
|
|
resetTopic(): void { |
|
|
this.topic = ''; |
|
|
this.questions = []; |
|
|
this.questionsWithAnswers = []; |
|
|
this.userAnswers = []; |
|
|
this.answerStatuses = []; |
|
|
this.readonlyAnswers = []; |
|
|
this.feedback = []; |
|
|
this.hints = []; |
|
|
this.attemptCounts = {}; |
|
|
|
|
|
this.isQuestionsGenerated = false; |
|
|
this.isValidationInProgress = false; |
|
|
this.isFirstAttemptDone = false; |
|
|
this.isAllLevelsCompleted = false; |
|
|
this.hasNewHints = false; |
|
|
this.showSuggestions = false; |
|
|
this.isChecked = false; |
|
|
this.currentDifficulty = 'basic'; |
|
|
this.error = ''; |
|
|
|
|
|
this.clearTimers(); |
|
|
this.stopConfetti(); |
|
|
} |
|
|
|
|
|
areAllAnswersFilled(): boolean { |
|
|
return this.userAnswers.every(answer => answer?.trim()); |
|
|
} |
|
|
|
|
|
closeErrorPopup(): void { |
|
|
this.error = ''; |
|
|
} |
|
|
|
|
|
private parseAIValidation(response: string): boolean { |
|
|
if (!response) return false; |
|
|
|
|
|
const lower = response.toLowerCase(); |
|
|
|
|
|
if (INCORRECT_INDICATORS.some(ind => lower.includes(ind))) return false; |
|
|
if (CORRECT_INDICATORS.some(ind => lower.includes(ind))) return true; |
|
|
|
|
|
return false; |
|
|
} |
|
|
|
|
|
checkAllAnswers(): void { |
|
|
this.isValidationInProgress = true; |
|
|
this.isLoading = true; |
|
|
this.isFirstAttemptDone = true; |
|
|
|
|
|
const payload = this.questions.map((q, i) => ({ |
|
|
topic: this.topic, |
|
|
question: q.parts.join('_______'), |
|
|
user_answer: this.userAnswers[i] |
|
|
})); |
|
|
|
|
|
this.service.validateAllAnswers(payload).subscribe({ |
|
|
next: (response) => this.handleValidationResponse(response), |
|
|
error: (error) => this.handleValidationError(error) |
|
|
}); |
|
|
} |
|
|
|
|
|
private handleValidationResponse(response: any): void { |
|
|
if (!response.results?.length) { |
|
|
this.error = 'Failed to validate answers. Please try again.'; |
|
|
this.resetValidationState(); |
|
|
return; |
|
|
} |
|
|
|
|
|
let hadFirstWrong = false; |
|
|
let hadSecondWrong = false; |
|
|
|
|
|
response.results.forEach((result: any, index: number) => { |
|
|
this.attemptCounts[index]++; |
|
|
const isCorrect = this.parseAIValidation(result.validation_response); |
|
|
|
|
|
this.feedback[index] = result.validation_response || 'No feedback provided.'; |
|
|
|
|
|
if (isCorrect) { |
|
|
this.markAsCorrect(index); |
|
|
} else { |
|
|
const attemptResult = this.handleIncorrectAttempt(index, result.hint); |
|
|
hadFirstWrong = hadFirstWrong || attemptResult.first; |
|
|
hadSecondWrong = hadSecondWrong || attemptResult.second; |
|
|
} |
|
|
}); |
|
|
|
|
|
this.handleHintsDisplay(hadFirstWrong || hadSecondWrong); |
|
|
|
|
|
if (!hadSecondWrong) { |
|
|
this.checkLevelCompletion(); |
|
|
} |
|
|
|
|
|
this.resetValidationState(); |
|
|
} |
|
|
|
|
|
private markAsCorrect(index: number): void { |
|
|
this.readonlyAnswers[index] = true; |
|
|
this.hints[index] = ''; |
|
|
this.answerStatuses[index] = 'correct'; |
|
|
this.userAnswers[index] = this.userAnswers[index].trim(); |
|
|
} |
|
|
|
|
|
private handleIncorrectAttempt(index: number, hint: string): { first: boolean; second: boolean } { |
|
|
this.answerStatuses[index] = 'incorrect'; |
|
|
this.hints[index] = hint?.trim() || ''; |
|
|
|
|
|
if (this.attemptCounts[index] === 1) { |
|
|
this.scheduleAnswerClear(index); |
|
|
return { first: true, second: false }; |
|
|
} else if (this.attemptCounts[index] >= 2) { |
|
|
this.scheduleAnswerReveal(index); |
|
|
return { first: false, second: true }; |
|
|
} |
|
|
|
|
|
return { first: false, second: false }; |
|
|
} |
|
|
|
|
|
private scheduleAnswerClear(index: number): void { |
|
|
if (!this.globalCountdownActive) this.startOverlay('Try again in'); |
|
|
|
|
|
setTimeout(() => { |
|
|
this.userAnswers[index] = ''; |
|
|
this.answerStatuses[index] = 'pending'; |
|
|
this.clearTimers(); |
|
|
}, COUNTDOWN_SECONDS * 1000); |
|
|
} |
|
|
|
|
|
private scheduleAnswerReveal(index: number): void { |
|
|
if (!this.globalCountdownActive) this.startOverlay('Showing correct answer in'); |
|
|
|
|
|
setTimeout(() => { |
|
|
this.userAnswers[index] = this.questionsWithAnswers[index].correctAnswer; |
|
|
this.answerStatuses[index] = 'correct'; |
|
|
this.readonlyAnswers[index] = true; |
|
|
this.clearTimers(); |
|
|
|
|
|
if (this.areAllCorrectAnswersDisplayed()) { |
|
|
if (this.isLastLevel()) { |
|
|
this.scheduleCompletion(); |
|
|
} else { |
|
|
this.scheduleNextLevel(); |
|
|
} |
|
|
} |
|
|
}, COUNTDOWN_SECONDS * 1000); |
|
|
} |
|
|
|
|
|
private scheduleCompletion(): void { |
|
|
this.startOverlay('Finishing in'); |
|
|
setTimeout(() => { |
|
|
this.clearTimers(); |
|
|
this.isAllLevelsCompleted = true; |
|
|
this.triggerConfetti(); |
|
|
}, COUNTDOWN_SECONDS * 1000); |
|
|
} |
|
|
|
|
|
private handleHintsDisplay(hasWrongAnswers: boolean): void { |
|
|
this.hasNewHints = this.hints.some(h => h?.trim()); |
|
|
|
|
|
if (this.hasNewHints) { |
|
|
setTimeout(() => { this.hasNewHints = false; }, HINT_AUTO_HIDE_MS); |
|
|
} |
|
|
|
|
|
if (!this.hasShownHintIcon && (this.hasNewHints || hasWrongAnswers)) { |
|
|
this.showHintIcon = true; |
|
|
this.hasShownHintIcon = true; |
|
|
} |
|
|
} |
|
|
|
|
|
private checkLevelCompletion(): void { |
|
|
if (this.areAllCorrectAnswersDisplayed()) { |
|
|
if (this.isLastLevel()) { |
|
|
this.scheduleCompletion(); |
|
|
} else { |
|
|
this.scheduleNextLevel(); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
private scheduleNextLevel(): void { |
|
|
this.clearTimers(); |
|
|
this.startOverlay('Moving to next level in'); |
|
|
setTimeout(() => { |
|
|
this.clearTimers(); |
|
|
setTimeout(() => this.transitionDifficulty(), NEXT_LEVEL_DELAY_MS); |
|
|
}, COUNTDOWN_SECONDS * 1000); |
|
|
} |
|
|
|
|
|
private handleValidationError(error: any): void { |
|
|
this.error = 'Error validating answers. Please try again later.'; |
|
|
this.resetValidationState(); |
|
|
} |
|
|
|
|
|
private resetValidationState(): void { |
|
|
this.isValidationInProgress = false; |
|
|
this.isLoading = false; |
|
|
} |
|
|
|
|
|
isLastLevel(): boolean { |
|
|
return this.currentDifficulty === this.difficultyLevels[this.difficultyLevels.length - 1]; |
|
|
} |
|
|
|
|
|
areAllCorrectAnswersDisplayed(): boolean { |
|
|
return this.readonlyAnswers.every(readonly => readonly); |
|
|
} |
|
|
|
|
|
transitionDifficulty(): void { |
|
|
const currentIndex = this.difficultyLevels.indexOf(this.currentDifficulty); |
|
|
|
|
|
if (currentIndex < this.difficultyLevels.length - 1) { |
|
|
this.currentDifficulty = this.difficultyLevels[currentIndex + 1]; |
|
|
this.generateQuestions(); |
|
|
} else { |
|
|
setTimeout(() => { |
|
|
this.questions = []; |
|
|
this.isAllLevelsCompleted = true; |
|
|
this.triggerConfetti(); |
|
|
}, COMPLETION_DELAY_MS); |
|
|
} |
|
|
} |
|
|
|
|
|
onAnswerChange(index: number): void { |
|
|
this.userAnswers[index] = this.userAnswers[index]?.trim() || ''; |
|
|
this.userAnswers = [...this.userAnswers]; |
|
|
} |
|
|
|
|
|
goToHome(): void { |
|
|
this.router.navigate(['/home']); |
|
|
} |
|
|
|
|
|
triggerConfetti(): void { |
|
|
if (this.confettiInterval) clearInterval(this.confettiInterval); |
|
|
|
|
|
this.confettiInterval = setInterval(() => { |
|
|
confetti({ |
|
|
startVelocity: 30, |
|
|
spread: 360, |
|
|
ticks: 60, |
|
|
origin: { x: Math.random(), y: Math.random() - 0.2 } |
|
|
}); |
|
|
}, CONFETTI_INTERVAL_MS); |
|
|
} |
|
|
|
|
|
stopConfetti(): void { |
|
|
this.isAllLevelsCompleted = false; |
|
|
this.stopConfettiAnimation(); |
|
|
} |
|
|
|
|
|
private stopConfettiAnimation(): void { |
|
|
if (this.confettiInterval) { |
|
|
clearInterval(this.confettiInterval); |
|
|
this.confettiInterval = null; |
|
|
} |
|
|
document.querySelector('canvas.confetti-canvas')?.remove(); |
|
|
document.querySelectorAll('.confetti, .ts-confetti').forEach(el => el.remove()); |
|
|
} |
|
|
|
|
|
stopConfettiAndReset(): void { |
|
|
this.stopConfetti(); |
|
|
this.resetTopic(); |
|
|
} |
|
|
|
|
|
selectTopic(suggestion: string): void { |
|
|
this.topic = suggestion; |
|
|
this.showSuggestions = false; |
|
|
} |
|
|
|
|
|
hideSuggestions(): void { |
|
|
setTimeout(() => { this.showSuggestions = false; }, 150); |
|
|
} |
|
|
|
|
|
closeHints(): void { |
|
|
this.isHintMenuVisible = false; |
|
|
this.hasNewHints = false; |
|
|
} |
|
|
|
|
|
toggleHintMenu(): void { |
|
|
this.isHintMenuVisible = !this.isHintMenuVisible; |
|
|
if (this.isHintMenuVisible) this.showHintIcon = false; |
|
|
} |
|
|
|
|
|
onInput(event: any, index: number): void { |
|
|
const filtered = event.target.value.replace(/[^a-zA-Z]/g, ''); |
|
|
this.userAnswers[index] = filtered; |
|
|
event.target.value = filtered; |
|
|
} |
|
|
|
|
|
private startOverlay(caption: string, seconds = COUNTDOWN_SECONDS): void { |
|
|
this.clearTimers(); |
|
|
|
|
|
this.globalCountdownActive = true; |
|
|
this.showGlobalCountdown = true; |
|
|
this.overlayCaption = caption; |
|
|
this.globalCountdown = seconds; |
|
|
this.overlayEndTs = Date.now() + seconds * 1000; |
|
|
this.ringDashoffset = this.ringCircumference; |
|
|
|
|
|
this.overlayTicker = setInterval(() => { |
|
|
const now = Date.now(); |
|
|
const remainingMs = Math.max(0, this.overlayEndTs - now); |
|
|
const elapsedMs = seconds * 1000 - remainingMs; |
|
|
|
|
|
this.globalCountdown = Math.ceil(remainingMs / 1000); |
|
|
this.ringDashoffset = this.ringCircumference * (1 - Math.min(1, elapsedMs / (seconds * 1000))); |
|
|
|
|
|
if (remainingMs <= 0) this.clearTimers(); |
|
|
}, 50); |
|
|
|
|
|
this.globalTimer = setTimeout(() => this.clearTimers(), seconds * 1000); |
|
|
} |
|
|
} |
|
|
|