UNPKG

answer-perfect-form-component

Version:

A simple standalone Angular form question component that makes API calls on blur with AI-powered text cleanup - Updated for Angular 18.2.x compatibility

547 lines (539 loc) 29.7 kB
import * as i0 from '@angular/core'; import { inject, Component, Input, ViewChild } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import * as i1 from '@angular/common'; import { CommonModule } from '@angular/common'; import * as i2 from '@angular/forms'; import { FormsModule } from '@angular/forms'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; /** * Rating enum matching the backend */ var Rating; (function (Rating) { Rating["NoRating"] = "No rating"; Rating["Weak"] = "Weak"; Rating["Sufficient"] = "Sufficient"; Rating["Strong"] = "Strong"; })(Rating || (Rating = {})); /** * A reusable Angular component that provides a textarea input for answering questions. * Automatically submits answers to an AI evaluation API when the user stops typing. * Displays AI feedback in a tooltip on hover over the rating bar. * Streams audio of the feedback text when hovering over the rating bar (configurable). */ class FormQuestionComponent { constructor() { this.http = inject(HttpClient); /** Whether to enable audio feedback on hover (default: true) */ this.enableAudio = true; this.answerText = ''; this.isSubmitting = false; this.errorMessage = ''; this.feedbackText = ''; this.rating = null; this.isTyping = false; this.isCleaning = false; this.debounceTimeout = null; this.cancelRequest$ = new Subject(); this.currentAudioUrl = null; this.currentAudioRequest$ = new Subject(); } ngOnInit() { // Validate required inputs if (!this.apiKey || !this.endpoint || !this.questionId) { console.error('FormQuestionComponent: Missing required inputs'); } console.log('FormQuestionComponent initialized'); console.log('enableAudio:', this.enableAudio); } ngAfterViewInit() { console.log('View initialized, hoverAudio element:', this.hoverAudio); if (this.hoverAudio && this.hoverAudio.nativeElement) { console.log('Audio element is available and ready'); } else { console.warn('Audio element not found or not ready'); } } ngOnDestroy() { this.clearDebounceTimeout(); this.cancelRequest$.next(); this.cancelRequest$.complete(); this.currentAudioRequest$.next(); this.currentAudioRequest$.complete(); // Clean up any existing audio blob URL if (this.currentAudioUrl) { URL.revokeObjectURL(this.currentAudioUrl); this.currentAudioUrl = null; } } /** * Play streaming audio when mouse enters the rating bar */ onRatingBarHover() { console.log('Rating bar hover detected'); console.log('enableAudio:', this.enableAudio); console.log('feedbackText:', this.feedbackText); console.log('feedbackText.trim():', this.feedbackText?.trim()); console.log('hoverAudio:', this.hoverAudio); console.log('hoverAudio.nativeElement:', this.hoverAudio?.nativeElement); // Wait for audio element to be available if it's not ready yet if (!this.hoverAudio || !this.hoverAudio.nativeElement) { console.log('Audio element not ready, waiting...'); setTimeout(() => { this.onRatingBarHover(); }, 100); return; } if (this.enableAudio && this.feedbackText && this.feedbackText.trim() && this.hoverAudio && this.hoverAudio.nativeElement) { console.log('All conditions met, calling playFeedbackAudio'); this.playFeedbackAudio(this.feedbackText); } else { console.log('Conditions not met for audio playback'); if (!this.enableAudio) console.log('Audio disabled'); if (!this.feedbackText) console.log('No feedback text'); if (!this.feedbackText?.trim()) console.log('Feedback text is empty'); if (!this.hoverAudio) console.log('No hover audio element'); if (!this.hoverAudio?.nativeElement) console.log('No hover audio native element'); } } /** * Stop audio when mouse leaves the rating bar */ onRatingBarLeave() { console.log('Rating bar leave detected'); if (this.enableAudio && this.hoverAudio && this.hoverAudio.nativeElement) { // Cancel any ongoing audio request this.currentAudioRequest$.next(); // Pause and reset audio this.hoverAudio.nativeElement.pause(); this.hoverAudio.nativeElement.currentTime = 0; // Clean up the current audio blob URL if (this.currentAudioUrl) { URL.revokeObjectURL(this.currentAudioUrl); this.currentAudioUrl = null; } } else { console.log('Cannot stop audio - conditions not met'); } } /** * Play streaming audio for the given feedback text */ playFeedbackAudio(feedbackText) { console.log('playFeedbackAudio called with:', feedbackText); console.log('endpoint:', this.endpoint); console.log('apiKey:', this.apiKey ? 'Present' : 'Missing'); if (!this.hoverAudio || !this.hoverAudio.nativeElement) { console.log('Audio element not available'); return; } const headers = new HttpHeaders({ 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}`, }); const payload = { textToSpeak: feedbackText, }; console.log('Making API call to:', `${this.endpoint}/audio/stream`); console.log('Payload:', payload); // Create a blob URL from the streaming audio response this.http .post(`${this.endpoint}/audio/stream`, payload, { headers, responseType: 'blob', }) .pipe(takeUntil(this.currentAudioRequest$)) .subscribe({ next: (blob) => { console.log('Audio stream received, blob size:', blob.size); // Clean up previous audio URL if it exists if (this.currentAudioUrl) { URL.revokeObjectURL(this.currentAudioUrl); } const audioUrl = URL.createObjectURL(blob); this.currentAudioUrl = audioUrl; this.hoverAudio.nativeElement.src = audioUrl; this.hoverAudio.nativeElement.play().catch((error) => { console.warn('Could not play feedback audio:', error); }); }, error: (error) => { console.warn('Could not stream feedback audio:', error); }, }); } onBlur() { // Just clear typing state when focus is lost, no immediate submission this.isTyping = false; } onInput() { // Clear any existing timer and cancel ongoing API requests this.clearDebounceTimeout(); this.cancelRequest$.next(); // Set typing state this.isTyping = true; // Clear feedback when user deletes all text if (!this.answerText.trim()) { this.feedbackText = ''; this.rating = null; this.isTyping = false; return; } // Single timer: 2 seconds after user stops typing this.debounceTimeout = setTimeout(() => { this.isTyping = false; if (this.answerText.trim() && !this.isSubmitting) { // Clear old rating before submitting for new one this.feedbackText = ''; this.rating = null; this.submitAnswer(); } }, 2000); } clearDebounceTimeout() { if (this.debounceTimeout) { clearTimeout(this.debounceTimeout); this.debounceTimeout = null; } } submitAnswer() { // Clear timer when submitting this.clearDebounceTimeout(); this.isSubmitting = true; this.errorMessage = ''; const headers = new HttpHeaders({ 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}`, }); const payload = { questionId: this.questionId, answerText: this.answerText.trim(), }; this.http .post(`${this.endpoint}/submitAnswer`, payload, { headers }) .pipe(takeUntil(this.cancelRequest$)) .subscribe({ next: (response) => { this.isSubmitting = false; // Display feedback and rating from AI if (response.feedbackText) { this.feedbackText = response.feedbackText; console.log('Feedback text received:', this.feedbackText); } if (response.rating) { this.rating = response.rating; console.log('Rating received:', this.rating); } }, error: (error) => { this.isSubmitting = false; // Clear any existing rating/feedback and show error in rating bar this.feedbackText = ''; this.rating = null; this.errorMessage = 'Connection error'; // Clear error message after 3 seconds and clear text to show branding setTimeout(() => { this.errorMessage = ''; this.answerText = ''; // Clear text to revert to AnswerPerfect AI branding }, 3000); }, }); } cleanupText() { if (!this.answerText.trim() || this.isCleaning) { return; } this.isCleaning = true; const headers = new HttpHeaders({ 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}`, }); const payload = { text: this.answerText.trim(), }; this.http .post(`${this.endpoint}/cleanupText`, payload, { headers }) .pipe(takeUntil(this.cancelRequest$)) .subscribe({ next: (response) => { this.isCleaning = false; if (response.success && response.cleanedText) { this.answerText = response.cleanedText; // The text change will automatically trigger onInput() via ngModel } }, error: (error) => { this.isCleaning = false; console.error('Text cleanup failed:', error); }, }); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: FormQuestionComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.13", type: FormQuestionComponent, isStandalone: true, selector: "app-form-question", inputs: { apiKey: "apiKey", endpoint: "endpoint", questionId: "questionId", enableAudio: "enableAudio" }, viewQueries: [{ propertyName: "hoverAudio", first: true, predicate: ["hoverAudio"], descendants: true }], ngImport: i0, template: ` <div class="form-question-container"> <!-- Hidden audio element for streaming audio --> <audio #hoverAudio preload="none"></audio> <div class="form-field"> <div class="textarea-container"> <textarea id="answer-textarea" [(ngModel)]="answerText" (blur)="onBlur()" (input)="onInput()" rows="3" placeholder="Type your answer here..." class="answer-textarea" > </textarea> <!-- Rating bar - always visible (shows branding when empty, rating when has content) --> <div class="rating-container"> <!-- Edit icon for text cleanup --> <button *ngIf="!isCleaning" class="edit-icon" [class.disabled]="!answerText.trim()" (click)="cleanupText()" [title]=" answerText.trim() ? 'Fix spelling and grammar with AI' : 'Add text to enable AI editing' " > ✏️ </button> <div *ngIf="isCleaning" class="cleaning-spinner" title="AI is fixing your text..." > ✨ </div> <div class="feedback-bar" (mouseenter)="onRatingBarHover()" (mouseleave)="onRatingBarLeave()" [ngClass]="{ outline: !rating || rating === 'No rating' || !answerText.trim(), weak: rating === 'Weak' && answerText.trim() && !isTyping && !isSubmitting, sufficient: rating === 'Sufficient' && answerText.trim() && !isTyping && !isSubmitting, strong: rating === 'Strong' && answerText.trim() && !isTyping && !isSubmitting, noRating: rating === 'No rating' && answerText.trim() && !isTyping && !isSubmitting, branding: !answerText.trim(), typing: (isTyping || isSubmitting) && answerText.trim(), error: errorMessage && answerText.trim() }" > <span class="rating-label" *ngIf=" answerText.trim() && rating && !isTyping && !isSubmitting && !errorMessage " >{{ rating }}</span > <span class="typing-label" *ngIf=" answerText.trim() && (isTyping || isSubmitting) && !errorMessage " ><span class="dot1">.</span><span class="dot2">.</span ><span class="dot3">.</span></span > <span class="error-label" *ngIf="answerText.trim() && errorMessage" >Error</span > <span class="branding-label" *ngIf="!answerText.trim()" >AnswerPerfect AI <!-- Branding tooltip --> <div class="branding-tooltip"> AI-powered feedback to help you write better answers </div> </span> <!-- Tooltip feedback text on hover (only show if feedback exists) --> <div class="feedback-tooltip" *ngIf="feedbackText && answerText.trim()" > {{ feedbackText }} </div> </div> </div> </div> </div> </div> `, isInline: true, styles: [".form-question-container{margin-bottom:1rem}.form-field{display:flex;flex-direction:column;margin-bottom:.5rem}.textarea-container{position:relative;display:block}.answer-textarea{width:100%;padding:.75rem;border:1px solid #ccc;border-radius:4px;font-family:inherit;font-size:1rem;resize:vertical;min-height:80px}.answer-textarea:focus{outline:none;border-color:#1976d2;box-shadow:0 0 0 2px #1976d233}.rating-container{display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-top:8px;position:relative}.edit-icon{background:none;border:none;cursor:pointer;font-size:16px;padding:4px;border-radius:4px;transition:background-color .2s ease;display:flex;align-items:center;justify-content:center;min-width:24px;height:24px}.edit-icon:hover{background-color:#0000001a}.edit-icon.disabled{opacity:.3;cursor:not-allowed;pointer-events:none}.edit-icon.disabled:hover{background-color:transparent}.cleaning-spinner{font-size:16px;animation:spin 1s linear infinite;display:flex;align-items:center;justify-content:center;min-width:24px;height:24px}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.feedback-bar{height:20px;width:120px;background:#e0e0e0;border-radius:9px;position:relative;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:transform .2s ease}.feedback-bar:hover{transform:scale(1.05)}.feedback-bar:before{content:\"\";position:absolute;left:0;top:0;height:100%;transition:width .6s ease-in-out;border-radius:9px}.feedback-bar.weak:before{background:linear-gradient(to right,#ff6b6b,#ff8e8e);width:33%}.feedback-bar.sufficient:before{background:linear-gradient(to right,#ffd93d,#ffe066);width:66%}.feedback-bar.strong:before{background:linear-gradient(to right,#6bcf7f,#8ed99e);width:100%}.feedback-bar.outline:before{background:transparent;width:0%}.feedback-bar.noRating:before{background:linear-gradient(to right,#9e9e9e,#bdbdbd);width:0%}.feedback-bar.error:before{background:linear-gradient(to right,#f44336,#ef5350);width:100%}.rating-label{font-weight:600;font-size:11px;text-align:center;color:#333;text-shadow:0 1px 2px rgba(255,255,255,.8);z-index:1;position:relative}.branding-label{font-weight:600;font-size:10px;text-align:center;color:#666;z-index:1;position:relative;letter-spacing:.5px;cursor:pointer}.branding-tooltip{position:absolute;bottom:100%;right:0;background:#333;color:#fff;padding:12px 16px;border-radius:8px;font-size:12px;opacity:0;visibility:hidden;transition:opacity .3s ease,visibility .3s ease;z-index:1000;margin-bottom:8px;width:280px;white-space:normal;line-height:1.4;box-shadow:0 4px 12px #0003;font-weight:500;letter-spacing:normal}.branding-tooltip:after{content:\"\";position:absolute;top:100%;right:20px;border:5px solid transparent;border-top-color:#333}.branding-label:hover .branding-tooltip{opacity:1;visibility:visible}.feedback-tooltip{position:absolute;bottom:100%;right:0;transform:translate(0);background:#333;color:#fff;padding:18px 24px;border-radius:10px;font-size:14px;opacity:0;visibility:hidden;transition:opacity .3s ease,visibility .3s ease;z-index:1000;margin-bottom:12px;width:450px;white-space:normal;line-height:1.6;box-shadow:0 6px 20px #0003}.feedback-tooltip:after{content:\"\";position:absolute;top:100%;right:20px;border:6px solid transparent;border-top-color:#333}.typing-label{font-weight:700;font-size:14px;text-align:center;color:#222;z-index:1;position:relative;display:flex;justify-content:center;gap:2px}.typing-label .dot1,.typing-label .dot2,.typing-label .dot3{animation:typing-pulse 1.2s infinite}.typing-label .dot1{animation-delay:0s}.typing-label .dot2{animation-delay:.15s}.typing-label .dot3{animation-delay:.3s}.feedback-bar.typing:before{background:#e0e0e0;width:0%}@keyframes typing-pulse{0%,70%,to{opacity:.2;transform:scale(.8)}35%{opacity:1;transform:scale(1.4)}}.error-label{font-weight:600;font-size:11px;text-align:center;color:#333;text-shadow:0 1px 2px rgba(255,255,255,.8);z-index:1;position:relative}.feedback-bar:hover .feedback-tooltip{opacity:1;visibility:visible}.error-message{color:#f44336;font-size:.875rem;margin-top:.25rem}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: FormQuestionComponent, decorators: [{ type: Component, args: [{ selector: 'app-form-question', template: ` <div class="form-question-container"> <!-- Hidden audio element for streaming audio --> <audio #hoverAudio preload="none"></audio> <div class="form-field"> <div class="textarea-container"> <textarea id="answer-textarea" [(ngModel)]="answerText" (blur)="onBlur()" (input)="onInput()" rows="3" placeholder="Type your answer here..." class="answer-textarea" > </textarea> <!-- Rating bar - always visible (shows branding when empty, rating when has content) --> <div class="rating-container"> <!-- Edit icon for text cleanup --> <button *ngIf="!isCleaning" class="edit-icon" [class.disabled]="!answerText.trim()" (click)="cleanupText()" [title]=" answerText.trim() ? 'Fix spelling and grammar with AI' : 'Add text to enable AI editing' " > ✏️ </button> <div *ngIf="isCleaning" class="cleaning-spinner" title="AI is fixing your text..." > ✨ </div> <div class="feedback-bar" (mouseenter)="onRatingBarHover()" (mouseleave)="onRatingBarLeave()" [ngClass]="{ outline: !rating || rating === 'No rating' || !answerText.trim(), weak: rating === 'Weak' && answerText.trim() && !isTyping && !isSubmitting, sufficient: rating === 'Sufficient' && answerText.trim() && !isTyping && !isSubmitting, strong: rating === 'Strong' && answerText.trim() && !isTyping && !isSubmitting, noRating: rating === 'No rating' && answerText.trim() && !isTyping && !isSubmitting, branding: !answerText.trim(), typing: (isTyping || isSubmitting) && answerText.trim(), error: errorMessage && answerText.trim() }" > <span class="rating-label" *ngIf=" answerText.trim() && rating && !isTyping && !isSubmitting && !errorMessage " >{{ rating }}</span > <span class="typing-label" *ngIf=" answerText.trim() && (isTyping || isSubmitting) && !errorMessage " ><span class="dot1">.</span><span class="dot2">.</span ><span class="dot3">.</span></span > <span class="error-label" *ngIf="answerText.trim() && errorMessage" >Error</span > <span class="branding-label" *ngIf="!answerText.trim()" >AnswerPerfect AI <!-- Branding tooltip --> <div class="branding-tooltip"> AI-powered feedback to help you write better answers </div> </span> <!-- Tooltip feedback text on hover (only show if feedback exists) --> <div class="feedback-tooltip" *ngIf="feedbackText && answerText.trim()" > {{ feedbackText }} </div> </div> </div> </div> </div> </div> `, standalone: true, imports: [CommonModule, FormsModule], styles: [".form-question-container{margin-bottom:1rem}.form-field{display:flex;flex-direction:column;margin-bottom:.5rem}.textarea-container{position:relative;display:block}.answer-textarea{width:100%;padding:.75rem;border:1px solid #ccc;border-radius:4px;font-family:inherit;font-size:1rem;resize:vertical;min-height:80px}.answer-textarea:focus{outline:none;border-color:#1976d2;box-shadow:0 0 0 2px #1976d233}.rating-container{display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-top:8px;position:relative}.edit-icon{background:none;border:none;cursor:pointer;font-size:16px;padding:4px;border-radius:4px;transition:background-color .2s ease;display:flex;align-items:center;justify-content:center;min-width:24px;height:24px}.edit-icon:hover{background-color:#0000001a}.edit-icon.disabled{opacity:.3;cursor:not-allowed;pointer-events:none}.edit-icon.disabled:hover{background-color:transparent}.cleaning-spinner{font-size:16px;animation:spin 1s linear infinite;display:flex;align-items:center;justify-content:center;min-width:24px;height:24px}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.feedback-bar{height:20px;width:120px;background:#e0e0e0;border-radius:9px;position:relative;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:transform .2s ease}.feedback-bar:hover{transform:scale(1.05)}.feedback-bar:before{content:\"\";position:absolute;left:0;top:0;height:100%;transition:width .6s ease-in-out;border-radius:9px}.feedback-bar.weak:before{background:linear-gradient(to right,#ff6b6b,#ff8e8e);width:33%}.feedback-bar.sufficient:before{background:linear-gradient(to right,#ffd93d,#ffe066);width:66%}.feedback-bar.strong:before{background:linear-gradient(to right,#6bcf7f,#8ed99e);width:100%}.feedback-bar.outline:before{background:transparent;width:0%}.feedback-bar.noRating:before{background:linear-gradient(to right,#9e9e9e,#bdbdbd);width:0%}.feedback-bar.error:before{background:linear-gradient(to right,#f44336,#ef5350);width:100%}.rating-label{font-weight:600;font-size:11px;text-align:center;color:#333;text-shadow:0 1px 2px rgba(255,255,255,.8);z-index:1;position:relative}.branding-label{font-weight:600;font-size:10px;text-align:center;color:#666;z-index:1;position:relative;letter-spacing:.5px;cursor:pointer}.branding-tooltip{position:absolute;bottom:100%;right:0;background:#333;color:#fff;padding:12px 16px;border-radius:8px;font-size:12px;opacity:0;visibility:hidden;transition:opacity .3s ease,visibility .3s ease;z-index:1000;margin-bottom:8px;width:280px;white-space:normal;line-height:1.4;box-shadow:0 4px 12px #0003;font-weight:500;letter-spacing:normal}.branding-tooltip:after{content:\"\";position:absolute;top:100%;right:20px;border:5px solid transparent;border-top-color:#333}.branding-label:hover .branding-tooltip{opacity:1;visibility:visible}.feedback-tooltip{position:absolute;bottom:100%;right:0;transform:translate(0);background:#333;color:#fff;padding:18px 24px;border-radius:10px;font-size:14px;opacity:0;visibility:hidden;transition:opacity .3s ease,visibility .3s ease;z-index:1000;margin-bottom:12px;width:450px;white-space:normal;line-height:1.6;box-shadow:0 6px 20px #0003}.feedback-tooltip:after{content:\"\";position:absolute;top:100%;right:20px;border:6px solid transparent;border-top-color:#333}.typing-label{font-weight:700;font-size:14px;text-align:center;color:#222;z-index:1;position:relative;display:flex;justify-content:center;gap:2px}.typing-label .dot1,.typing-label .dot2,.typing-label .dot3{animation:typing-pulse 1.2s infinite}.typing-label .dot1{animation-delay:0s}.typing-label .dot2{animation-delay:.15s}.typing-label .dot3{animation-delay:.3s}.feedback-bar.typing:before{background:#e0e0e0;width:0%}@keyframes typing-pulse{0%,70%,to{opacity:.2;transform:scale(.8)}35%{opacity:1;transform:scale(1.4)}}.error-label{font-weight:600;font-size:11px;text-align:center;color:#333;text-shadow:0 1px 2px rgba(255,255,255,.8);z-index:1;position:relative}.feedback-bar:hover .feedback-tooltip{opacity:1;visibility:visible}.error-message{color:#f44336;font-size:.875rem;margin-top:.25rem}\n"] }] }], propDecorators: { apiKey: [{ type: Input }], endpoint: [{ type: Input }], questionId: [{ type: Input }], enableAudio: [{ type: Input }], hoverAudio: [{ type: ViewChild, args: ['hoverAudio', { static: false }] }] } }); /** * Generated bundle index. Do not edit. */ export { FormQuestionComponent }; //# sourceMappingURL=answer-perfect-form-component.mjs.map