diff --git a/src/app/manager-dashboard/certifications/certifications-add.component.html b/src/app/manager-dashboard/certifications/certifications-add.component.html index 103cc379a5..000a612fcc 100644 --- a/src/app/manager-dashboard/certifications/certifications-add.component.html +++ b/src/app/manager-dashboard/certifications/certifications-add.component.html @@ -15,6 +15,10 @@ +
diff --git a/src/app/manager-dashboard/certifications/certifications-add.component.ts b/src/app/manager-dashboard/certifications/certifications-add.component.ts index 60b8de23c5..46f8b0c347 100644 --- a/src/app/manager-dashboard/certifications/certifications-add.component.ts +++ b/src/app/manager-dashboard/certifications/certifications-add.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, ViewChild, AfterViewChecked, ChangeDetectorRef } from '@angular/core'; +import { Component, OnInit, ViewChild, AfterViewChecked, ChangeDetectorRef, HostListener } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; import { Router, ActivatedRoute, ParamMap } from '@angular/router'; import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog'; @@ -9,6 +9,10 @@ import { CoursesComponent } from '../../courses/courses.component'; import { showFormErrors } from '../../shared/table-helpers'; import { ValidatorService } from '../../validators/validator.service'; import { PlanetMessageService } from '../../shared/planet-message.service'; +import { ImagePreviewDialogComponent } from '../../shared/dialogs/image-preview-dialog.component'; +import { environment } from '../../../environments/environment'; +import { CanComponentDeactivate } from '../../shared/unsaved-changes.guard'; +import { warningMsg } from '../../shared/unsaved-changes.component'; interface CertificationFormModel { name: string; @@ -17,7 +21,7 @@ interface CertificationFormModel { @Component({ templateUrl: './certifications-add.component.html' }) -export class CertificationsAddComponent implements OnInit, AfterViewChecked { +export class CertificationsAddComponent implements OnInit, AfterViewChecked, CanComponentDeactivate { readonly dbName = 'certifications'; certificateInfo: { _id?: string, _rev?: string } = {}; @@ -25,6 +29,14 @@ export class CertificationsAddComponent implements OnInit, AfterViewChecked { courseIds: string[] = []; pageType = 'Add'; disableRemove = true; + selectedFile: any; // Will hold base64 string for new files + previewSrc: any = 'assets/image.png'; // For image preview + urlPrefix = environment.couchAddress + '/' + this.dbName + '/'; + + initialFormValues: any; + attachmentChanged = false; + isFormInitialized = false; + @ViewChild(CoursesComponent) courseTable: CoursesComponent; constructor( @@ -54,17 +66,31 @@ export class CertificationsAddComponent implements OnInit, AfterViewChecked { this.certificateInfo._id = id; this.certificationsService.getCertification(id).subscribe(certification => { this.certificateForm.patchValue({ name: certification.name || '' } as Partial); + this.initialFormValues = { ...this.certificateForm.value }; this.certificateInfo._rev = certification._rev; this.courseIds = certification.courseIds || []; this.pageType = 'Update'; + if (certification._attachments && certification._attachments.attachment) { + // Construct direct URL for the preview + this.previewSrc = `${this.urlPrefix}${id}/attachment`; + } + this.isFormInitialized = true; + this.setupFormValueChanges(); }); } else { - this.certificateInfo._id = undefined; - this.courseIds = []; + this.initialFormValues = { ...this.certificateForm.value }; + this.isFormInitialized = true; + this.setupFormValueChanges(); } }); } + setupFormValueChanges() { + this.certificateForm.valueChanges.subscribe(() => { + // The guard will check for changes, no need to set a flag here + }); + } + ngAfterViewChecked() { const disableRemove = !this.courseTable || !this.courseTable.selection.selected.length; if (this.disableRemove !== disableRemove) { @@ -78,18 +104,67 @@ export class CertificationsAddComponent implements OnInit, AfterViewChecked { this.router.navigate([ navigation ], { relativeTo: this.route }); } + openImagePreviewDialog() { + const dialogRef = this.dialog.open(ImagePreviewDialogComponent, { + data: { file: this.previewSrc } // Pass previewSrc to the dialog + }); + + dialogRef.afterClosed().subscribe(result => { + if (result === undefined) { + return; // Dialog was closed without action + } + this.attachmentChanged = true; // Any confirmed action in the dialog is a change + if (result instanceof File) { + // New file selected, convert to base64 + const reader = new FileReader(); + reader.onload = () => { + this.selectedFile = reader.result as string; + this.previewSrc = this.selectedFile; + }; + reader.readAsDataURL(result); + } else if (result === null) { + // Image removed + this.selectedFile = null; + this.previewSrc = 'assets/image.png'; + } + }); + } + submitCertificate(reroute: boolean) { if (!this.certificateForm.valid) { showFormErrors(this.certificateForm.controls); return; } const certificateFormValue: CertificationFormModel = this.certificateForm.getRawValue(); - this.certificationsService.addCertification({ + const certData: any = { ...this.certificateInfo, ...certificateFormValue, - courseIds: this.courseIds - }).subscribe((res) => { + courseIds: this.courseIds, + }; + + if (this.selectedFile && this.selectedFile.startsWith('data:')) { + // New base64 image data is present + const imgDataArr: string[] = this.selectedFile.split(/;\w+,/); + const contentType: string = imgDataArr[0].replace(/data:/, ''); + const data: string = imgDataArr[1]; + certData._attachments = { + 'attachment': { + 'content_type': contentType, + 'data': data + } + }; + } else if (this.attachmentChanged && !this.selectedFile) { + // This means the image was removed + if (certData._attachments) { + delete certData._attachments; + } + } + + + this.certificationsService.addCertification(certData).subscribe((res) => { this.certificateInfo = { _id: res.id, _rev: res.rev }; + this.initialFormValues = { ...this.certificateForm.value }; + this.attachmentChanged = false; this.planetMessageService.showMessage( this.pageType === 'Add' ? $localize`New certification added` : $localize`Certification updated` ); @@ -119,4 +194,25 @@ export class CertificationsAddComponent implements OnInit, AfterViewChecked { this.courseIds = this.courseIds.filter(id => this.courseTable.selection.selected.indexOf(id) === -1); } + canDeactivate(): boolean { + return !this.getHasUnsavedChanges(); + } + + isFormPristine(): boolean { + return JSON.stringify(this.certificateForm.value) === JSON.stringify(this.initialFormValues); + } + + @HostListener('window:beforeunload', [ '$event' ]) + unloadNotification($event: BeforeUnloadEvent): void { + if (this.getHasUnsavedChanges()) { + $event.returnValue = warningMsg; + } + } + + private getHasUnsavedChanges(): boolean { + if (!this.isFormInitialized) { + return false; + } + return !this.isFormPristine() || this.attachmentChanged; + } } diff --git a/src/app/manager-dashboard/certifications/certifications.service.ts b/src/app/manager-dashboard/certifications/certifications.service.ts index eb0e0a7eb3..e2b4fe9f93 100644 --- a/src/app/manager-dashboard/certifications/certifications.service.ts +++ b/src/app/manager-dashboard/certifications/certifications.service.ts @@ -52,7 +52,7 @@ export class CertificationsService { } addCertification(certification) { - return this.couchService.updateDocument(this.dbName, { ...certification }); + return this.couchService.updateDocument(this.dbName, certification); } isCourseCompleted(course, user) { diff --git a/src/app/shared/couchdb.service.ts b/src/app/shared/couchdb.service.ts index 7a41bd9bfa..ee6b9c08b9 100644 --- a/src/app/shared/couchdb.service.ts +++ b/src/app/shared/couchdb.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@angular/core'; import { HttpHeaders, HttpClient, HttpRequest } from '@angular/common/http'; import { environment } from '../../environments/environment'; -import { Observable, of, empty, throwError, forkJoin } from 'rxjs'; -import { catchError, map, expand, toArray, flatMap, switchMap } from 'rxjs/operators'; +import { Observable, of, empty, throwError, forkJoin } from 'rxjs'; +import { catchError, map, expand, toArray, flatMap, switchMap } from 'rxjs/operators'; import { debug } from '../debug-operator'; import { PlanetMessageService } from './planet-message.service'; import { findDocuments } from './mangoQueries'; @@ -28,7 +28,8 @@ export class CouchService { const url = (domain ? (protocol || environment.parentProtocol) + '://' + domain : this.baseUrl) + '/' + db; let httpReq: Observable; if (type === 'post' || type === 'put') { - httpReq = this.http[type](url, data, opts); + const body = typeof data === 'object' && !(data instanceof Blob) ? JSON.stringify(data) : data; + httpReq = this.http[type](url, body, opts); } else { httpReq = this.http[type](url, opts); } @@ -68,10 +69,16 @@ export class CouchService { return this.couchDBReq('delete', db, this.setOpts(opts)); } - putAttachment(db: string, file: FormData, opts?: any) { - return this.couchDBReq('put', db, this.setOpts(opts), file); + putAttachment(db: string, file: File, opts?: any) { + const httpOptions = { + headers: new HttpHeaders({ + 'Content-Type': file.type, + }), + withCredentials: true, + ...opts, + }; + return this.couchDBReq('put', db, this.setOpts(httpOptions), file); } - updateDocument(db: string, doc: any, opts?: any) { let docWithDate: any; return this.currentTime().pipe( @@ -97,33 +104,33 @@ export class CouchService { })); } - findAll(db: string, query: any = { 'selector': { '_id': { '$gt': null } }, 'limit': 1000 }, opts?: any) { - return this.findAllRequest(db, query, opts).pipe(flatMap(({ docs }) => docs), toArray()); - } - - findAllStream(db: string, query: any = { 'selector': { '_id': { '$gt': null } }, 'limit': 1000 }, opts?: any) { - return this.findAllRequest(db, query, opts).pipe(map(({ docs }) => docs)); - } - - findAttachmentsByIds(ids: string[], opts?: any) { - const uniqueIds = Array.from(new Set((ids || []).filter(id => !!id))); - if (uniqueIds.length === 0) { - return of([]); - } - const chunkSize = 50; - const queries = []; - for (let i = 0; i < uniqueIds.length; i += chunkSize) { - const chunk = uniqueIds.slice(i, i + chunkSize); - queries.push( - this.findAll('attachments', findDocuments({ '_id': { '$in': chunk } }, 0, 0, chunk.length), opts) - ); - } - return forkJoin(queries).pipe( - map((results: any[]) => results.reduce((acc: any[], docs: any[]) => acc.concat(docs), [])) - ); - } - - private findAllRequest(db: string, query: any, opts: any) { + findAll(db: string, query: any = { 'selector': { '_id': { '$gt': null } }, 'limit': 1000 }, opts?: any) { + return this.findAllRequest(db, query, opts).pipe(flatMap(({ docs }) => docs), toArray()); + } + + findAllStream(db: string, query: any = { 'selector': { '_id': { '$gt': null } }, 'limit': 1000 }, opts?: any) { + return this.findAllRequest(db, query, opts).pipe(map(({ docs }) => docs)); + } + + findAttachmentsByIds(ids: string[], opts?: any) { + const uniqueIds = Array.from(new Set((ids || []).filter(id => !!id))); + if (uniqueIds.length === 0) { + return of([]); + } + const chunkSize = 50; + const queries = []; + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + queries.push( + this.findAll('attachments', findDocuments({ '_id': { '$in': chunk } }, 0, 0, chunk.length), opts) + ); + } + return forkJoin(queries).pipe( + map((results: any[]) => results.reduce((acc: any[], docs: any[]) => acc.concat(docs), [])) + ); + } + + private findAllRequest(db: string, query: any, opts: any) { return this.post(db + '/_find', query, opts).pipe( catchError(() => { return of({ docs: [], rows: [] }); @@ -225,4 +232,24 @@ export class CouchService { ); } + getAttachment(db: string, docId: string, attachmentId: string, opts?: any): Observable { + const url = `${this.baseUrl}/${db}/${docId}/${attachmentId}`; + const httpOptions = { + ...this.defaultOpts, + responseType: 'blob', + ...opts, + }; + // Use http.get directly instead of couchDBReq because couchDBReq is not suitable for attachment URLs + return this.http.get(url, httpOptions).pipe( + catchError(err => { + if (err.status === 403) { + this.planetMessageService.showAlert($localize`You are not authorized. Please contact administrator.`); + } else { + this.planetMessageService.showAlert($localize`Error fetching attachment: ${err.message}`); + } + return throwError(err); + }) + ); + } + } diff --git a/src/app/shared/dialogs/image-preview-dialog.component.html b/src/app/shared/dialogs/image-preview-dialog.component.html new file mode 100644 index 0000000000..38260000ac --- /dev/null +++ b/src/app/shared/dialogs/image-preview-dialog.component.html @@ -0,0 +1,17 @@ +

Image Preview

+ +
+ +
+ +

No image selected.

+
+
+ + + + + + + + diff --git a/src/app/shared/dialogs/image-preview-dialog.component.scss b/src/app/shared/dialogs/image-preview-dialog.component.scss new file mode 100644 index 0000000000..01094880fb --- /dev/null +++ b/src/app/shared/dialogs/image-preview-dialog.component.scss @@ -0,0 +1,73 @@ +/* Dialog content layout */ +.mat-dialog-content { + display: flex; + justify-content: center; + align-items: center; + padding: 24px; + max-height: 70vh; + overflow: auto; +} + +/* Dialog action buttons */ +.mat-dialog-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 12px; + padding: 16px 24px 24px; + border-top: 1px solid rgba(0, 0, 0, 0.08); +} + +/* Image preview styling */ +img { + max-width: 100%; + max-height: 60vh; + height: auto; + border-radius: 8px; + border: 1px solid #e0e0e0; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +/* Subtle hover effect for images */ +img:hover { + transform: scale(1.01); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18); +} + +/* Material buttons spacing */ +.mat-button, +.mat-raised-button { + min-width: 96px; +} + +/* Primary raised button enhancement */ +.mat-raised-button.mat-primary { + background-color: #3f51b5; /* Material Indigo */ + color: #fff; + font-weight: 500; + box-shadow: 0 3px 6px rgba(63, 81, 181, 0.3); + transition: background-color 0.2s ease, box-shadow 0.2s ease; +} + +.mat-raised-button.mat-primary:hover { + background-color: #35449a; + box-shadow: 0 6px 12px rgba(63, 81, 181, 0.4); +} + +/* Mobile responsiveness */ +@media (max-width: 600px) { + .mat-dialog-content { + padding: 16px; + } + + .mat-dialog-actions { + padding: 12px 16px 16px; + flex-direction: column; + align-items: stretch; + } + + .mat-dialog-actions button { + width: 100%; + } +} diff --git a/src/app/shared/dialogs/image-preview-dialog.component.ts b/src/app/shared/dialogs/image-preview-dialog.component.ts new file mode 100644 index 0000000000..c55cf97947 --- /dev/null +++ b/src/app/shared/dialogs/image-preview-dialog.component.ts @@ -0,0 +1,65 @@ +import { Component, Inject, OnInit, ViewChild, ElementRef } from '@angular/core'; +import { MatLegacyDialogRef as MatDialogRef, MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA } from '@angular/material/legacy-dialog'; + +@Component({ + templateUrl: './image-preview-dialog.component.html', + styleUrls: ['./image-preview-dialog.component.scss'] +}) +export class ImagePreviewDialogComponent implements OnInit { + + previewUrl: any; + selectedFile: File | null; + @ViewChild('fileInput') fileInput: ElementRef; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: any + ) {} + + ngOnInit() { + if (this.data && this.data.file) { + if (typeof this.data.file === 'string') { + this.previewUrl = this.data.file; + this.selectedFile = null; // It's just a URL, no file object yet + } else if (this.data.file instanceof File || this.data.file instanceof Blob) { + this.selectedFile = this.data.file; + this.updatePreview(); + } + } + } + + onFileSelected(event: any) { + if (event.target.files && event.target.files[0]) { + this.selectedFile = event.target.files[0]; + this.updatePreview(); + if (this.fileInput.nativeElement) { + this.fileInput.nativeElement.value = ''; + } + } + } + + updatePreview() { + if (this.selectedFile) { + const reader = new FileReader(); + reader.onload = () => { + this.previewUrl = reader.result; + }; + reader.readAsDataURL(this.selectedFile); + } + } + + confirm() { + this.dialogRef.close(this.selectedFile); + } + + remove() { + this.selectedFile = null; + this.previewUrl = null; + } + + close() { + // When closing without confirming, return undefined so the calling component knows no change was made + this.dialogRef.close(undefined); + } +} + diff --git a/src/app/shared/dialogs/planet-dialogs.module.ts b/src/app/shared/dialogs/planet-dialogs.module.ts index 89162811f4..550f63cc06 100644 --- a/src/app/shared/dialogs/planet-dialogs.module.ts +++ b/src/app/shared/dialogs/planet-dialogs.module.ts @@ -17,6 +17,7 @@ import { SyncDirective } from '../../manager-dashboard/sync.directive'; import { DialogsImagesComponent } from './dialogs-images.component'; import { DialogsAnnouncementComponent, DialogsAnnouncementSuccessComponent } from './dialogs-announcement.component'; import { DialogsRatingsComponent, DialogsRatingsDirective } from './dialogs-ratings.component'; +import { ImagePreviewDialogComponent } from './image-preview-dialog.component'; @NgModule({ imports: [ @@ -38,7 +39,8 @@ import { DialogsRatingsComponent, DialogsRatingsDirective } from './dialogs-rati DialogsRatingsComponent, DialogsRatingsDirective, ChangePasswordDirective, - SyncDirective + SyncDirective, + ImagePreviewDialogComponent ], declarations: [ DialogsFormComponent, @@ -53,7 +55,8 @@ import { DialogsRatingsComponent, DialogsRatingsDirective } from './dialogs-rati ChangePasswordDirective, SyncDirective, DialogsAnnouncementComponent, - DialogsAnnouncementSuccessComponent + DialogsAnnouncementSuccessComponent, + ImagePreviewDialogComponent ], providers: [ DialogsFormService, diff --git a/src/app/users/users-achievements/users-achievements.component.ts b/src/app/users/users-achievements/users-achievements.component.ts index 941cfec493..ee02ae0f26 100644 --- a/src/app/users/users-achievements/users-achievements.component.ts +++ b/src/app/users/users-achievements/users-achievements.component.ts @@ -5,8 +5,8 @@ import { CouchService } from '../../shared/couchdb.service'; import { UserService } from '../../shared/user.service'; import { PlanetMessageService } from '../../shared/planet-message.service'; import { UsersAchievementsService } from './users-achievements.service'; -import { catchError, auditTime } from 'rxjs/operators'; -import { throwError, combineLatest } from 'rxjs'; +import { catchError, auditTime, switchMap, map } from 'rxjs/operators'; // Added switchMap and map +import { throwError, combineLatest, forkJoin, Observable, of } from 'rxjs'; // Added forkJoin, Observable, of import { StateService } from '../../shared/state.service'; import { CoursesService } from '../../courses/courses.service'; import { environment } from '../../../environments/environment'; @@ -124,12 +124,52 @@ export class UsersAchievementsComponent implements OnInit { } setCertifications(courses = [], progress = [], certifications = []) { - this.certifications = certifications.filter(certification => { + const filteredCertifications = certifications.filter(certification => { const certificateCourses = courses .filter(course => certification.courseIds.indexOf(course._id) > -1) .map(course => ({ ...course, progress: progress.filter(p => p.courseId === course._id) })); return certificateCourses.every(course => this.certificationsService.isCourseCompleted(course, this.user)); }); + + const attachmentFetches: Observable[] = filteredCertifications.map(certification => { + // Check if _attachments exists and has an attachment named 'attachment' + if (certification._attachments && certification._attachments.attachment) { + return this.couchService.getAttachment(this.certificationsService.dbName, certification._id, 'attachment').pipe( + switchMap(blob => { + return new Observable(observer => { + const reader = new FileReader(); + reader.onloadend = () => { + // Ensure reader.result is a string (base64) before assigning + certification.templateImage = typeof reader.result === 'string' ? reader.result : null; + observer.next(certification); + observer.complete(); + }; + reader.onerror = (error) => { + console.error('Error reading blob as DataURL', error); + certification.templateImage = null; // Set to null on error + observer.next(certification); + observer.complete(); + }; + reader.readAsDataURL(blob); + }); + }), + catchError(err => { + console.error('Error fetching attachment for certification', certification._id, err); + return of(certification); // Return certification without attachment if error + }) + ); + } else { + return of(certification); // No attachment, return certification as is + } + }); + + if (attachmentFetches.length > 0) { + forkJoin(attachmentFetches).subscribe(certsWithAttachments => { + this.certifications = certsWithAttachments; + }); + } else { + this.certifications = filteredCertifications; + } } copyLink() { @@ -232,7 +272,7 @@ export class UsersAchievementsComponent implements OnInit { contentArray = contentArray.concat(optionals); - const documentDefinition = { + let documentDefinition: any = { // Changed to let to allow modification content: contentArray, styles: { header: { @@ -246,6 +286,21 @@ export class UsersAchievementsComponent implements OnInit { }, }; + // Find a certification with a template image + const certificationWithTemplate = this.certifications.find(cert => cert.templateImage); + + if (certificationWithTemplate && certificationWithTemplate.templateImage) { + documentDefinition.background = [ + { + image: certificationWithTemplate.templateImage, + width: 612, // US Letter width in points (8.5 inches * 72 points/inch) + height: 792, // US Letter height in points (11 inches * 72 points/inch) + absolutePosition: { x: 0, y: 0 }, + opacity: 0.5 // Optional: adjust opacity for readability + } + ]; + } + pdfMake .createPdf(documentDefinition) .download($localize`${this.user.name} achievements.pdf`);