|
|
import { Component, ElementRef, ViewChild } from '@angular/core'; |
|
|
import { ReadingService } from './reading.service'; |
|
|
import { Router } from '@angular/router'; |
|
|
import confetti from 'canvas-confetti'; |
|
|
import { ButtonComponent } from '../shared/button/button.component'; |
|
|
import { CommonModule } from '@angular/common'; |
|
|
import { FormsModule } from '@angular/forms'; |
|
|
import { HeaderComponent } from '../shared/header/header.component'; |
|
|
|
|
|
@Component({ |
|
|
selector: 'app-reading', |
|
|
templateUrl: './reading.component.html', |
|
|
styleUrl: './reading.component.css', |
|
|
standalone: true, |
|
|
imports: [ButtonComponent, CommonModule, FormsModule, HeaderComponent] |
|
|
}) |
|
|
export class ReadingComponent { |
|
|
loadingQuestions = false; |
|
|
isGeneratingContent = false; |
|
|
isGenerateDisabled = false; |
|
|
isGenerateQuestionDisabled = false; |
|
|
showPopup = false; |
|
|
showSuggestions = false; |
|
|
|
|
|
hasStarted = false; |
|
|
currentQuestionIndex = 0; |
|
|
questions: { question: string; options: string[]; correct_answer: string; isChecked?: boolean }[] = []; |
|
|
selectedAnswers: { [key: string]: string } = {}; |
|
|
|
|
|
topic: string = ''; |
|
|
difficulty: 'easy' | 'medium' | 'hard' = 'easy'; |
|
|
content: string = ''; |
|
|
errorMessage: string = ''; |
|
|
normalizedTopic: string = ''; |
|
|
|
|
|
filteredSuggestions: string[] = []; |
|
|
activeIndex = -1; |
|
|
|
|
|
fontPx = parseInt(localStorage.getItem('passageFontPx') || '18', 10); |
|
|
isReading = false; |
|
|
ttsPaused = false; |
|
|
private ttsUtterance?: SpeechSynthesisUtterance; |
|
|
|
|
|
topicsByDifficulty: Record<'easy' | 'medium' | 'hard', string[]> = { |
|
|
easy: [ |
|
|
'My Family', 'My School', 'My Neighborhood', 'Community Helpers', |
|
|
'Good Manners', 'Healthy Eating', 'Personal Hygiene', 'Seasons of the Year', |
|
|
'Weather Today', 'Animals on the Farm', 'Wild Animals', 'Pets and Care', |
|
|
'Parts of a Plant', 'Uses of Water', 'Saving Water', 'The Sun and the Moon', |
|
|
'Our Five Senses', 'Road Safety', 'Recycling Basics', 'Teamwork in Class' |
|
|
], |
|
|
medium: [ |
|
|
'Water Cycle', 'Photosynthesis', 'Solar System', 'States of Matter', |
|
|
'Simple Machines', 'Electricity Basics', 'Magnetism Basics', |
|
|
'Human Digestive System', 'Circulatory System Basics', |
|
|
'Food Chain and Web', 'Ecosystems', 'Renewable Energy', |
|
|
'Weather and Climate', 'Volcanoes and Earthquakes', |
|
|
'Map Skills and Symbols', 'Ancient Egypt', 'Indus Valley Civilization', |
|
|
'Indian Constitution (Basics)', 'Cyber Safety for Kids', |
|
|
'Time Management for Students' |
|
|
], |
|
|
hard: [ |
|
|
'Global Warming and Climate Change', 'Greenhouse Effect', 'Plate Tectonics', |
|
|
'Genetic Inheritance (Basics)', 'Natural Selection (Basics)', |
|
|
'Nervous System Overview', 'Robotics in Daily Life', |
|
|
'Artificial Intelligence (Basics)', 'Computer Networks (Basics)', |
|
|
'Data Privacy and Security', 'Internet and Web Architecture (Basics)', |
|
|
'World War II (Overview)', 'Industrial Revolution', |
|
|
'Democracy and Rights (India)', 'Financial Literacy: Budgeting', |
|
|
'Statistics in Daily Life (Mean, Median)', |
|
|
'Renewable vs Non-renewable Energy', 'Entrepreneurship Basics', |
|
|
'Ethics in Technology', 'Career Planning and Growth Mindset' |
|
|
] |
|
|
}; |
|
|
|
|
|
constructor( |
|
|
private readingService: ReadingService, |
|
|
private router: Router, |
|
|
private el: ElementRef |
|
|
) { } |
|
|
|
|
|
goToIntroSection(): void { |
|
|
this.stopReadAloud?.(); |
|
|
this.content = ''; |
|
|
this.hasStarted = false; |
|
|
this.isGenerateDisabled = false; |
|
|
this.refreshGenerateQuestionsState(); |
|
|
this.showCongrats = false; |
|
|
} |
|
|
|
|
|
generateContent(): void { |
|
|
this.showPopup = false; |
|
|
this.errorMessage = ''; |
|
|
if (!this.topic.trim() || !this.difficulty.trim()) { |
|
|
this.errorMessage = 'Please enter a topic and select a difficulty level.'; |
|
|
this.showPopup = true; |
|
|
return; |
|
|
} |
|
|
this.isGenerateDisabled = true; |
|
|
this.isGeneratingContent = true; |
|
|
this.readingService.generateContent(this.topic, this.difficulty).subscribe( |
|
|
(response) => { |
|
|
const text = (response?.content || '').trim(); |
|
|
if (!text) { |
|
|
this.errorMessage = 'The server did not return any content.'; |
|
|
this.showPopup = true; |
|
|
this.isGeneratingContent = false; |
|
|
this.isGenerateDisabled = false; |
|
|
this.content = ''; |
|
|
this.normalizedTopic = ''; |
|
|
return; |
|
|
} |
|
|
this.content = text; |
|
|
this.normalizedTopic = (response?.normalized_topic || response?.topic || this.topic || '').trim(); |
|
|
this.isGeneratingContent = false; |
|
|
this.hasStarted = false; |
|
|
this.refreshGenerateQuestionsState(); |
|
|
}, |
|
|
(error) => { |
|
|
console.error(error); |
|
|
const msg = error?.error?.error || 'Invalid topic. Please enter a meaningful topic.'; |
|
|
this.errorMessage = msg; |
|
|
this.showPopup = true; |
|
|
this.isGeneratingContent = false; |
|
|
this.isGenerateDisabled = false; |
|
|
} |
|
|
); |
|
|
} |
|
|
|
|
|
generateQuestions(): void { |
|
|
if (!this.content.trim()) return; |
|
|
this.loadingQuestions = true; |
|
|
this.isGenerateQuestionDisabled = true; |
|
|
this.readingService.generateQuestions(this.content, this.difficulty).subscribe( |
|
|
(response) => { |
|
|
this.questions = this.parseQuestions(response.questions); |
|
|
this.selectedAnswers = {}; |
|
|
this.currentQuestionIndex = 0; |
|
|
this.questions.forEach(q => (q.isChecked = false)); |
|
|
this.hasStarted = true; |
|
|
this.loadingQuestions = false; |
|
|
}, |
|
|
(error) => { |
|
|
console.error(error); |
|
|
this.loadingQuestions = false; |
|
|
this.refreshGenerateQuestionsState(); |
|
|
} |
|
|
); |
|
|
} |
|
|
|
|
|
parseQuestions( |
|
|
raw: any |
|
|
): { question: string; options: string[]; correct_answer: string; isChecked?: boolean }[] { |
|
|
if (Array.isArray(raw)) { |
|
|
return raw |
|
|
.map((q: any) => ({ |
|
|
question: String(q?.question || '').trim(), |
|
|
options: Array.isArray(q?.options) ? q.options.map((o: any) => String(o)) : [], |
|
|
correct_answer: String(q?.correct_answer || '').trim(), |
|
|
isChecked: false |
|
|
})) |
|
|
.filter(q => q.question && q.options.length === 4); |
|
|
} |
|
|
const out: { question: string; options: string[]; correct_answer: string; isChecked?: boolean }[] = []; |
|
|
const text = String(raw || ''); |
|
|
const blocks = text.split('\n\n'); |
|
|
blocks.forEach(block => { |
|
|
const obj: any = {}; |
|
|
const lines = block.split('\n'); |
|
|
obj.question = (lines[0] || '').replace(/^(\d+\.\s*)?Question:\s*/i, '').trim(); |
|
|
const optionsText = (lines[1] || '') |
|
|
.replace(/^Options:\s*\[/i, '') |
|
|
.replace(/\]\s*$/, '') |
|
|
.trim(); |
|
|
obj.options = optionsText |
|
|
? optionsText.split(', ').map(o => o.trim()).slice(0, 4) |
|
|
: []; |
|
|
obj.correct_answer = (lines[2] || '').replace(/^Correct Answer:\s*/i, '').trim(); |
|
|
obj.isChecked = false; |
|
|
if (obj.question && obj.options?.length) out.push(obj); |
|
|
}); |
|
|
return out; |
|
|
} |
|
|
|
|
|
setSelectedAnswer(value: string): void { |
|
|
const curr = this.questions?.[this.currentQuestionIndex]; |
|
|
if (curr) this.selectedAnswers[curr.question] = value; |
|
|
} |
|
|
|
|
|
getSelectedAnswer(): string { |
|
|
const curr = this.questions?.[this.currentQuestionIndex]; |
|
|
return (curr && this.selectedAnswers[curr.question]) || ''; |
|
|
} |
|
|
|
|
|
nextQuestion(): void { |
|
|
if (this.currentQuestionIndex < this.questions.length - 1) this.currentQuestionIndex++; |
|
|
} |
|
|
|
|
|
previousQuestion(): void { |
|
|
if (this.currentQuestionIndex > 0) { |
|
|
this.currentQuestionIndex--; |
|
|
} |
|
|
} |
|
|
|
|
|
private markCurrentAsChecked(): void { |
|
|
const curr = this.questions[this.currentQuestionIndex]; |
|
|
if (curr && this.selectedAnswers[curr.question]) { |
|
|
curr.isChecked = true; |
|
|
} |
|
|
} |
|
|
|
|
|
closeErrorPopup(): void { this.showPopup = false; } |
|
|
|
|
|
goToHome(): void { this.router.navigate(['/home']); } |
|
|
|
|
|
goToContentBlock(): void { |
|
|
this.hasStarted = false; |
|
|
this.content = ''; |
|
|
this.isGenerateDisabled = false; |
|
|
} |
|
|
|
|
|
resetAll(): void { |
|
|
this.initState(); |
|
|
this.stopReadAloud?.(); |
|
|
this.topic = ''; |
|
|
this.difficulty = 'easy'; |
|
|
this.normalizedTopic = ''; |
|
|
this.content = ''; |
|
|
this.questions = []; |
|
|
this.currentQuestionIndex = 0; |
|
|
this.selectedAnswers = {}; |
|
|
this.hasStarted = false; |
|
|
this.showCongrats = false; |
|
|
this.isGenerateDisabled = false; |
|
|
this.isGenerateQuestionDisabled = true; |
|
|
this.isGeneratingContent = false; |
|
|
this.loadingQuestions = false; |
|
|
this.showPopup = false; |
|
|
this.errorMessage = ''; |
|
|
this.scoreCorrect = 0; |
|
|
this.scoreTotal = 0; |
|
|
} |
|
|
|
|
|
goBack(): void { |
|
|
this.hasStarted = false; |
|
|
this.refreshGenerateQuestionsState(); |
|
|
} |
|
|
|
|
|
openSuggestions(): void { |
|
|
this.filterSuggestions(); |
|
|
this.showSuggestions = true; |
|
|
this.activeIndex = -1; |
|
|
} |
|
|
|
|
|
onTyping(): void { |
|
|
this.filterSuggestions(); |
|
|
this.showSuggestions = true; |
|
|
this.activeIndex = -1; |
|
|
} |
|
|
|
|
|
onKeydown(event: KeyboardEvent): void { |
|
|
if (!this.showSuggestions || !this.filteredSuggestions.length) return; |
|
|
if (event.key === 'ArrowDown') { |
|
|
event.preventDefault(); |
|
|
this.activeIndex = Math.min(this.activeIndex + 1, this.filteredSuggestions.length - 1); |
|
|
} else if (event.key === 'ArrowUp') { |
|
|
event.preventDefault(); |
|
|
this.activeIndex = Math.max(this.activeIndex - 1, 0); |
|
|
} else if (event.key === 'Enter') { |
|
|
event.preventDefault(); |
|
|
const pick = this.filteredSuggestions[this.activeIndex] || this.topic; |
|
|
this.selectSuggestion(pick); |
|
|
} else if (event.key === 'Escape') { |
|
|
this.showSuggestions = false; |
|
|
} |
|
|
} |
|
|
|
|
|
selectSuggestion(val: string): void { |
|
|
this.topic = val || ''; |
|
|
this.showSuggestions = false; |
|
|
this.isGenerateDisabled = false; |
|
|
} |
|
|
|
|
|
hideSuggestionsWithDelay(): void { setTimeout(() => (this.showSuggestions = false), 120); } |
|
|
|
|
|
private filterSuggestions(): void { |
|
|
const q = (this.topic || '').toLowerCase().trim(); |
|
|
const pool = this.topicsByDifficulty[this.difficulty || 'medium'] || []; |
|
|
this.filteredSuggestions = q ? pool.filter(t => t.toLowerCase().includes(q)).slice(0, 12) |
|
|
: pool.slice(0, 12); |
|
|
} |
|
|
|
|
|
onClearTopic(): void { |
|
|
this.topic = ''; |
|
|
this.errorMessage = ''; |
|
|
this.activeIndex = -1; |
|
|
this.showSuggestions = true; |
|
|
this.isGenerateDisabled = false; |
|
|
} |
|
|
|
|
|
onDifficultyChange(val: string): void { |
|
|
this.difficulty = (val || '') as any; |
|
|
if (!this.difficulty) { |
|
|
this.filteredSuggestions = []; |
|
|
this.showSuggestions = false; |
|
|
this.activeIndex = -1; |
|
|
return; |
|
|
} |
|
|
this.filterSuggestions(); |
|
|
this.showSuggestions = true; |
|
|
this.activeIndex = -1; |
|
|
} |
|
|
|
|
|
applyFont(): void { |
|
|
document.documentElement.style.setProperty('--passage-font', `${this.fontPx}px`); |
|
|
localStorage.setItem('passageFontPx', String(this.fontPx)); |
|
|
} |
|
|
|
|
|
decreaseFont(): void { this.fontPx = Math.max(14, this.fontPx - 2); this.applyFont(); } |
|
|
|
|
|
increaseFont(): void { this.fontPx = Math.min(30, this.fontPx + 2); this.applyFont(); } |
|
|
|
|
|
private initState(): void { |
|
|
this.hasStarted = false; |
|
|
this.isGeneratingContent = false; |
|
|
this.isGenerateDisabled = false; |
|
|
this.isGenerateQuestionDisabled = false; |
|
|
this.showPopup = false; |
|
|
this.errorMessage = ''; |
|
|
this.topic = ''; |
|
|
this.normalizedTopic = ''; |
|
|
this.difficulty = 'easy'; |
|
|
this.showSuggestions = false; |
|
|
this.activeIndex = -1; |
|
|
this.filteredSuggestions = []; |
|
|
this.content = ''; |
|
|
this.questions = []; |
|
|
this.currentQuestionIndex = 0; |
|
|
this.selectedAnswers = {}; |
|
|
this.fontPx = parseInt(localStorage.getItem('passageFontPx') || '18', 10); |
|
|
this.applyFont(); |
|
|
} |
|
|
|
|
|
ngOnInit(): void { this.initState(); } |
|
|
|
|
|
transformContent(raw: string): string { |
|
|
try { |
|
|
const passage = this.extractPassage(raw); |
|
|
const safe = this.escapeHtml(passage).replace(/\n+/g, ' ').trim(); |
|
|
return `<p>${safe}</p>`; |
|
|
} catch { |
|
|
return this.escapeHtml(raw || ''); |
|
|
} |
|
|
} |
|
|
|
|
|
private extractPassage(raw: string): string { |
|
|
if (!raw) return ''; |
|
|
const p = raw.indexOf('Passage:'); |
|
|
if (p === -1) return raw; |
|
|
const s = raw.indexOf('Summary:', p + 8); |
|
|
const slice = s === -1 ? raw.slice(p + 8) : raw.slice(p + 8, s); |
|
|
return slice.trim(); |
|
|
} |
|
|
|
|
|
private escapeHtml(s: string): string { |
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); |
|
|
} |
|
|
|
|
|
toggleReadAloud(): void { |
|
|
if (!this.content || typeof window === 'undefined' || !('speechSynthesis' in window)) { |
|
|
this.errorMessage = 'Read Aloud is not supported in this browser.'; |
|
|
this.showPopup = true; |
|
|
return; |
|
|
} |
|
|
const synth = window.speechSynthesis; |
|
|
if (this.isReading && !synth.paused) { synth.pause(); this.isReading = false; this.ttsPaused = true; return; } |
|
|
if (this.ttsPaused) { synth.resume(); this.isReading = true; this.ttsPaused = false; return; } |
|
|
this.stopReadAloud(); |
|
|
const text = this.extractPassage(this.content) || this.content; |
|
|
const u = new SpeechSynthesisUtterance(text); |
|
|
u.rate = 1.0; u.pitch = 1.0; u.lang = 'en-US'; |
|
|
u.onend = () => { this.isReading = false; this.ttsPaused = false; this.ttsUtterance = undefined; }; |
|
|
u.onerror = () => { this.isReading = false; this.ttsPaused = false; this.ttsUtterance = undefined; }; |
|
|
this.ttsUtterance = u; |
|
|
synth.speak(u); |
|
|
this.isReading = true; |
|
|
} |
|
|
|
|
|
stopReadAloud(): void { |
|
|
if (typeof window !== 'undefined' && 'speechSynthesis' in window) { |
|
|
window.speechSynthesis.cancel(); |
|
|
} |
|
|
this.isReading = false; |
|
|
this.ttsPaused = false; |
|
|
this.ttsUtterance = undefined; |
|
|
} |
|
|
|
|
|
showCongrats = false; |
|
|
scoreCorrect = 0; |
|
|
private confettiInterval: any = null; |
|
|
scoreTotal: number = 3; |
|
|
congratsTitle: string = ''; |
|
|
congratsMessage: string = ''; |
|
|
|
|
|
scheduleCongratsIfLast(): void { |
|
|
const isLast = this.currentQuestionIndex === this.questions.length - 1; |
|
|
const curr = this.questions[this.currentQuestionIndex]; |
|
|
if (!isLast || !curr?.isChecked) return; |
|
|
setTimeout(() => { |
|
|
const { correct, total } = this.computeScore(); |
|
|
this.scoreCorrect = correct; |
|
|
this.scoreTotal = total; |
|
|
if (this.scoreCorrect === this.scoreTotal) { |
|
|
this.showCongratsMessage('🎉 Congratulations 🎉', 'You have completed all questions with a perfect score!'); |
|
|
} else if (this.scoreCorrect > 0) { |
|
|
this.showCongratsMessage('Good Job!', 'You did well, but there is room for improvement.'); |
|
|
} else { |
|
|
this.showCongratsMessage('Keep Trying!', "Don't give up, try again!"); |
|
|
} |
|
|
this.showCongrats = true; |
|
|
this.triggerConfetti(); |
|
|
}, 800); |
|
|
} |
|
|
|
|
|
showCongratsMessage(title: string, message: string) { |
|
|
this.congratsTitle = title; |
|
|
this.congratsMessage = message; |
|
|
} |
|
|
|
|
|
private computeScore(): { correct: number; total: number } { |
|
|
let correct = 0; |
|
|
const total = this.questions.length; |
|
|
for (const q of this.questions) { |
|
|
const chosen = this.selectedAnswers[q.question]; |
|
|
if (chosen && chosen === q.correct_answer) correct++; |
|
|
} |
|
|
return { correct, total }; |
|
|
} |
|
|
|
|
|
private 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 }, |
|
|
}); |
|
|
}, 250); |
|
|
} |
|
|
|
|
|
private stopConfetti(): void { |
|
|
if (this.confettiInterval) { |
|
|
clearInterval(this.confettiInterval); |
|
|
this.confettiInterval = null; |
|
|
} |
|
|
const canvas = document.querySelector('canvas.confetti-canvas'); |
|
|
if (canvas) canvas.remove(); |
|
|
} |
|
|
|
|
|
startOver(): void { |
|
|
this.stopConfetti(); |
|
|
this.showCongrats = false; |
|
|
this.resetAll(); |
|
|
} |
|
|
|
|
|
ngOnDestroy(): void { |
|
|
this.stopConfetti(); |
|
|
} |
|
|
|
|
|
isValidating = false; |
|
|
validateAnswer(): void { |
|
|
this.isValidating = true; |
|
|
setTimeout(() => (this.isValidating = false), 300); |
|
|
const curr = this.questions?.[this.currentQuestionIndex]; |
|
|
if (curr && this.selectedAnswers?.[curr.question]) { |
|
|
curr.isChecked = true; |
|
|
} |
|
|
this.scheduleCongratsIfLast(); |
|
|
} |
|
|
|
|
|
private refreshGenerateQuestionsState(): void { |
|
|
this.isGenerateQuestionDisabled = !(this.content && this.content.trim().length > 0); |
|
|
} |
|
|
|
|
|
goToReadingPassage(): void { |
|
|
this.hasStarted = false; |
|
|
this.refreshGenerateQuestionsState(); |
|
|
} |
|
|
} |
|
|
|