py-learn / src /app /reading /reading.component.ts
Anupriya
Added Logo text and CSS
6f70a09
raw
history blame
19.8 kB
import { Component, ElementRef, ViewChild } from '@angular/core';
import { ReadingService } from './reading.service';
import { Router } from '@angular/router';
import confetti from 'canvas-confetti';
@Component({
selector: 'app-reading',
templateUrl: './reading.component.html',
styleUrl: './reading.component.css'
})
export class ReadingComponent {
// basic state
loadingQuestions = false;
isGeneratingContent = false;
isGenerateDisabled = false;
isGenerateQuestionDisabled = false;
showPopup = false;
showSuggestions = false;
// quiz state
hasStarted = false;
currentQuestionIndex = 0;
questions: { question: string; options: string[]; correct_answer: string; isChecked?: boolean }[] = [];
selectedAnswers: { [key: string]: string } = {};
// content state
topic: string = '';
difficulty: 'easy' | 'medium' | 'hard' = 'easy';
content: string = '';
errorMessage: string = '';
normalizedTopic: string = '';
// suggestions state
filteredSuggestions: string[] = [];
activeIndex = -1;
// read-aloud + font
fontPx = parseInt(localStorage.getItem('passageFontPx') || '18', 10);
isReading = false;
ttsPaused = false;
private ttsUtterance?: SpeechSynthesisUtterance;
// topic lists
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?.();
// Show Intro by clearing passage, but DO NOT touch topic/difficulty
this.content = '';
this.hasStarted = false;
// On Intro, allow user to click "Generate Passage" again
this.isGenerateDisabled = false;
// No passage now → disable "Generate Questions"
this.refreshGenerateQuestionsState();
// Make sure any congrats overlay is closed
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();
// ✅ Do not check for exact topic substring
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;
// Support both shapes: { normalized_topic } or { topic } or fallback to input
this.normalizedTopic = (response?.normalized_topic || response?.topic || this.topic || '').trim();
this.isGeneratingContent = false;
// Show passage screen; enable "Generate Questions"
this.hasStarted = false;
this.refreshGenerateQuestionsState();
},
(error) => {
console.error(error);
// ✅ Show backend message if present (your Flask returns {"error": "..."} on 400)
const msg = error?.error?.error || 'Invalid topic. Please enter a meaningful topic.';
this.errorMessage = msg;
this.showPopup = true;
this.isGeneratingContent = false;
this.isGenerateDisabled = false;
}
);
}
/** Generate MCQs from current passage. */
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;
// IMPORTANT: allow user to try again on failure
this.refreshGenerateQuestionsState(); // passage still exists → re-enable button
}
);
}
/** Parse questions from either JSON (preferred) or old plain-text format. */
parseQuestions(
raw: any
): { question: string; options: string[]; correct_answer: string; isChecked?: boolean }[] {
// 1) If backend returns an array of question objects (JSON)
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);
}
// 2) Fallback: parse the old plain-text format
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()) : [];
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;
}
/** Record the selected answer for current question. */
setSelectedAnswer(value: string): void {
const curr = this.questions?.[this.currentQuestionIndex];
if (curr) this.selectedAnswers[curr.question] = value;
}
/** Get the selected answer for current question. */
getSelectedAnswer(): string {
const curr = this.questions?.[this.currentQuestionIndex];
return (curr && this.selectedAnswers[curr.question]) || '';
}
/** Move to next question. */
nextQuestion(): void {
if (this.currentQuestionIndex < this.questions.length - 1) this.currentQuestionIndex++;
}
/** Instantly marks current question as checked if an answer is chosen. */
private markCurrentAsChecked(): void {
const curr = this.questions[this.currentQuestionIndex];
if (curr && this.selectedAnswers[curr.question]) {
curr.isChecked = true;
}
}
/** Close the error popup. */
closeErrorPopup(): void { this.showPopup = false; }
/** Navigate to home route. */
goToHome(): void { this.router.navigate(['/home']); }
/** Return to content stage and re-enable input. */
goToContentBlock(): void {
this.hasStarted = false;
this.content = '';
this.isGenerateDisabled = false;
}
/** Reset everything to initial state. */
resetAll(): void {
this.initState();
this.stopReadAloud?.();
// Inputs / choices
this.topic = '';
this.difficulty = 'easy';
this.normalizedTopic = '';
// Generated passage and questions
this.content = '';
this.questions = [];
this.currentQuestionIndex = 0;
this.selectedAnswers = {};
// View flags
this.hasStarted = false;
this.showCongrats = false;
// UX flags
this.isGenerateDisabled = false; // allow new passage generation
this.isGenerateQuestionDisabled = true; // disabled until a passage exists
this.isGeneratingContent = false;
this.loadingQuestions = false;
this.showPopup = false;
this.errorMessage = '';
// Score
this.scoreCorrect = 0;
this.scoreTotal = 0;
}
/** Return from MCQ to passage view. */
goBack(): void {
this.hasStarted = false; // return to passage view
this.refreshGenerateQuestionsState(); // passage exists → enable button
}
/** Open suggestions for current difficulty. */
openSuggestions(): void {
this.filterSuggestions();
this.showSuggestions = true;
this.activeIndex = -1;
}
/** Update suggestions on typing. */
onTyping(): void {
this.filterSuggestions();
this.showSuggestions = true;
this.activeIndex = -1;
}
/** Handle suggestion keyboard navigation. */
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;
}
}
/** Choose a suggestion and close list. */
selectSuggestion(val: string): void {
this.topic = val || '';
this.showSuggestions = false;
this.isGenerateDisabled = false;
}
/** Hide suggestions after blur. */
hideSuggestionsWithDelay(): void { setTimeout(() => (this.showSuggestions = false), 120); }
/** Internal: filter topics based on query+level. */
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);
}
/** Clear topic and reopen suggestions. */
onClearTopic(): void {
this.topic = '';
this.errorMessage = '';
this.activeIndex = -1;
this.showSuggestions = true;
this.isGenerateDisabled = false;
}
/** Change difficulty and refresh suggestions. */
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;
}
/** Apply font size CSS variable. */
applyFont(): void {
document.documentElement.style.setProperty('--passage-font', `${this.fontPx}px`);
localStorage.setItem('passageFontPx', String(this.fontPx));
}
/** Decrease font size (min 14px). */
decreaseFont(): void { this.fontPx = Math.max(14, this.fontPx - 2); this.applyFont(); }
/** Increase font size (max 30px). */
increaseFont(): void { this.fontPx = Math.min(30, this.fontPx + 2); this.applyFont(); }
/** Init defaults for the screen. */
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();
}
/** Lifecycle hook to set initial UI. */
ngOnInit(): void { this.initState(); }
/** Build safe HTML for passage body. */
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 || '');
}
}
/** Extract text between "Passage:" and "Summary:". */
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();
}
/** Minimal HTML escape. */
private escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** Toggle speech synthesis for the passage. */
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(); // fresh start
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;
}
/** Stop speech and clear flags. */
stopReadAloud(): void {
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
window.speechSynthesis.cancel();
}
this.isReading = false;
this.ttsPaused = false;
this.ttsUtterance = undefined;
}
// add inside the component class
showCongrats = false;
scoreCorrect = 0;
private confettiInterval: any = null;
// scoreCorrect: number = 0; // Correct answers
scoreTotal: number = 3; // Total number of questions
congratsTitle: string = '';
congratsMessage: string = '';
scheduleCongratsIfLast(): void {
// must be last question AND already marked as checked
const isLast = this.currentQuestionIndex === this.questions.length - 1;
const curr = this.questions[this.currentQuestionIndex];
if (!isLast || !curr?.isChecked) return;
// small delay so learners can see the final green/red states first
setTimeout(() => {
const { correct, total } = this.computeScore();
this.scoreCorrect = correct;
this.scoreTotal = total;
// Determine message based on score
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(); // Assuming this is a method that triggers confetti animation.
}, 800);
}
// Helper function to show the appropriate congratulatory message
showCongratsMessage(title: string, message: string) {
this.congratsTitle = title;
this.congratsMessage = message;
}
/** Compute score without altering validation code. */
private computeScore(): { correct: number; total: number } {
let correct = 0;
const total = this.questions.length;
// Uses your existing structures: selectedAnswers[...] and question.correct_answer
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(); // use your current reset method
}
ngOnDestroy(): void {
this.stopConfetti();
}
// optional: if you want a short “Validating…” state on the button
isValidating = false;
/** Called by the template. Keeps existing validation, then schedules the final modal if last. */
validateAnswer(): void {
// OPTIONAL: short “validating” hold on the button (remove if not needed)
this.isValidating = true;
setTimeout(() => (this.isValidating = false), 300);
// 👉 IMPORTANT:
// If you already have a function that validates/marks the current question, call it here.
// Example:
// this.checkAnswer(); // or
// this.markCurrentAsChecked(); // or your own method
// If you do not have one, the fallback below will simply mark the current question as checked.
const curr = this.questions?.[this.currentQuestionIndex];
if (curr && this.selectedAnswers?.[curr.question]) {
curr.isChecked = true; // fallback mark; harmless if your own method did it already
}
// After the question has been marked, if it is the last, open the modal
this.scheduleCongratsIfLast();
}
/** Enable/disable "Generate Questions" based on whether a passage exists */
private refreshGenerateQuestionsState(): void {
this.isGenerateQuestionDisabled = !(this.content && this.content.trim().length > 0);
}
}