Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6b26e0d
First Iteration
PlataformasInformaticas Jan 29, 2026
0ca6d42
courses: add environment import for course cover image support (fixes…
PlataformasInformaticas Apr 10, 2026
dba7a28
Merge branch 'master' into 9467-feature-course-cover-image-support
PlataformasInformaticas Apr 10, 2026
ce09323
fix: resolve Angular build errors in courses and dashboard components
google-labs-jules[bot] Apr 10, 2026
32bde89
Update dashboard-tile.component.ts
PlataformasInformaticas Apr 10, 2026
288cc1e
Merge branch 'master' into 9467-feature-course-cover-image-support
PlataformasInformaticas Jun 11, 2026
5a380c2
Fix
PlataformasInformaticas Jun 11, 2026
9c4ec1b
Merge branch 'master' into 9467-feature-course-cover-image-support
PlataformasInformaticas Jun 12, 2026
e4012d6
Merge branch 'master' into 9467-feature-course-cover-image-support
Mutugiii Jun 17, 2026
2f995b5
revert manual translation fixes
Mutugiii Jun 17, 2026
ed6cea3
Fix course cover storage and display
Mutugiii Jun 18, 2026
d0e2812
Normalize course cover images before upload
Mutugiii Jun 18, 2026
cae8650
remove hanging i18n ids && centralize environment handling && fix cou…
Mutugiii Jun 18, 2026
0232a6e
Harden course cover upload state handling
Mutugiii Jun 18, 2026
d82d14d
pending cover state behaviour fix
Mutugiii Jun 18, 2026
3ad9218
Reset course cover continuation guard after use
Mutugiii Jun 18, 2026
538fae1
harden saves
Mutugiii Jun 18, 2026
d63424c
Merge branch 'master' into 9467-feature-course-cover-image-support
Mutugiii Jun 18, 2026
b0ea926
Merge branch 'master' into 9467-feature-course-cover-image-support
Mutugiii Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/app/courses/add-courses/courses-add.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@
<mat-label i18n>Labels</mat-label>
<planet-tag-input [formControl]="tags" [db]="dbName" mode="add"></planet-tag-input>
</mat-form-field>
<div class="cover-field">
<label class="cover-label" i18n>Cover image</label>
<planet-file-upload
accept="image/*"
[imagePreview]="true"
[maxFiles]="1"
[typePills]="['IMG']"
hint="Recommended: a square image. Covers are cropped to fit." i18n-hint
[existingAttachments]="existingCoverAttachments"
(stateChange)="onCoverStateChange($event)">
</planet-file-upload>
</div>
</form>
</div>
<div [ngClass]="{'steps-container': steps.length > 0 }">
Expand Down
113 changes: 94 additions & 19 deletions src/app/courses/add-courses/courses-add.component.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core';
import { AbstractControl, FormControl, FormGroup, NonNullableFormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { Subject, forkJoin, of, combineLatest, race, interval } from 'rxjs';
import { Subject, forkJoin, of, combineLatest, race, interval, from } from 'rxjs';
import { takeWhile, debounce, catchError, switchMap } from 'rxjs/operators';

import { environment } from '../../../environments/environment';
import { CouchService } from '../../shared/couchdb.service';
import { CustomValidators } from '../../validators/custom-validators';
import { ValidatorService } from '../../validators/validator.service';
Expand All @@ -30,6 +31,8 @@ import { MatAutocompleteTrigger, MatAutocomplete, MatOption } from '@angular/mat
import { MatSelect } from '@angular/material/select';
import { PlanetTagInputComponent } from '../../shared/forms/planet-tag-input.component';
import { SubmitDirective } from '../../shared/submit.directive';
import { FileUploadComponent, AttachmentInputState, ExistingAttachment } from '../../shared/forms/file-upload.component';
import { couchAttachmentUrl, normalizeImage } from '../../shared/utils';

interface CourseFormModel {
courseTitle: FormControl<string>;
Expand Down Expand Up @@ -69,6 +72,7 @@ type DateValue = number | string | CouchService['datePlaceholder'];
NgClass,
CoursesStepComponent,
MatButton,
FileUploadComponent,
SubmitDirective
]
})
Expand All @@ -81,6 +85,9 @@ export class CoursesAddComponent implements OnInit, OnDestroy {
private stepsChange$ = new Subject<any[]>();
private initialState = '';
private _steps = [];
private preserveCoverStateUntilSubmit = false;
existingCoverAttachments: ExistingAttachment[] = [];
private coverState: AttachmentInputState = { retained: [], removed: [], added: [] };
savedCourse: any = null;
draftExists: boolean;
courseForm: FormGroup<CourseFormModel>;
Expand All @@ -96,6 +103,7 @@ export class CoursesAddComponent implements OnInit, OnDestroy {
languageNames = languages.map(list => list.name);
mockStep = { stepTitle: $localize`Add title`, description: '!!!' };
@ViewChild(CoursesStepComponent) coursesStepComponent: CoursesStepComponent;
@ViewChild(FileUploadComponent) coverUploadComponent?: FileUploadComponent;
get steps() {
return this._steps;
}
Expand Down Expand Up @@ -129,6 +137,8 @@ export class CoursesAddComponent implements OnInit, OnDestroy {

ngOnInit() {
const continued = this.route.snapshot.params.continue === 'true' && Object.keys(this.coursesService.course).length;
const continuedCourse = continued ? { ...this.coursesService.course } : null;
const continuedCoverState = continuedCourse?.coverState;
forkJoin([
this.pouchService.getDocEditing(this.dbName, this.courseId),
this.couchService.get('courses/' + this.courseId).pipe(catchError((err) => of(err.error))),
Expand All @@ -137,6 +147,9 @@ export class CoursesAddComponent implements OnInit, OnDestroy {
if (saved.error !== 'not_found') {
this.setDocumentInfo(saved);
this.savedCourse = saved;
if (!continuedCoverState) {
this.setExistingCover(saved);
}
this.pageType = 'Edit';
} else {
this.pageType = 'Add';
Expand All @@ -145,18 +158,21 @@ export class CoursesAddComponent implements OnInit, OnDestroy {
this.draftExists = draft !== undefined;
const doc = draft === undefined ? saved : draft;
this.setInitialTags(tags, this.documentInfo, draft);
if (!continued) {
if (continued) {
this.preserveCoverStateUntilSubmit = !!continuedCoverState;
this.setFormAndSteps(continuedCourse);
this.setCoverState(continuedCoverState || this.coverState);
this.submitAddedExam();
} else {
this.setFormAndSteps({ form: doc, steps: doc.steps, tags: doc.tags, initialTags: this.coursesService.course.initialTags });
this.setInitialState();
}
});
if (continued) {
this.setFormAndSteps(this.coursesService.course);
this.submitAddedExam();
}
const returnRoute = this.router.createUrlTree([ '.', { continue: true } ], { relativeTo: this.route });
this.coursesService.returnUrl = this.router.serializeUrl(returnRoute);
this.coursesService.course = { form: this.courseForm.value, steps: this.steps };
if (!continued) {
this.coursesService.course = { form: this.courseForm.value, steps: this.steps };
}
this.coursesService.stepIndex = undefined;
}

Expand Down Expand Up @@ -259,25 +275,80 @@ export class CoursesAddComponent implements OnInit, OnDestroy {
});
}

onCoverStateChange(state: AttachmentInputState) {
if (this.preserveCoverStateUntilSubmit) {
return;
Comment thread
Mutugiii marked this conversation as resolved.
}
this.setCoverState(state);
}

setCoverState(state: AttachmentInputState) {
this.coverState = state;
Comment thread
Mutugiii marked this conversation as resolved.
this.coursesService.course = { coverState: state };
}

setExistingCover(course: any) {
const fileName = course.coverFileName;
const attachment = course._attachments?.[fileName];
this.existingCoverAttachments = fileName && attachment ? [ {
name: fileName,
contentType: attachment.content_type,
url: couchAttachmentUrl(environment.couchAddress, this.dbName, course._id, fileName)
} ] : [];
// Seed cover state directly so a save can't drop the cover if it fires before the upload child emits.
this.setCoverState({ retained: [ ...this.existingCoverAttachments ], removed: [], added: [] });
}

updateCourse(courseInfo: FormGroup<CourseFormModel>['value'], shouldNavigate: boolean) {
if (courseInfo.createdDate.constructor === Object) {
courseInfo.createdDate = this.couchService.datePlaceholder;
}
const newCourse = { ...this.convertMarkdownImagesText({ ...courseInfo, images: this.images }, this.steps), ...this.documentInfo };
this.couchService.updateDocument(
this.dbName, { ...newCourse, updatedDate: this.couchService.datePlaceholder }
).pipe(switchMap((res: any) =>
forkJoin([
of(res),
this.couchService.bulkDocs(
'tags',
this.tagsService.tagBulkDocs(res.id, this.dbName, this.tags.value, this.coursesService.course.initialTags)
const newCourse: any = {
...this.convertMarkdownImagesText({ ...courseInfo, images: this.images }, this.steps),
...this.documentInfo
};
const addedCover = this.coverState?.added[0];
const retainedCover = this.coverState?.retained[0];
(addedCover ? from(normalizeImage(addedCover.file)) : of(null)).pipe(
switchMap(normalizedCover => {
if (normalizedCover) {
Comment thread
Mutugiii marked this conversation as resolved.
newCourse.coverFileName = normalizedCover.fileName;
Comment thread
Mutugiii marked this conversation as resolved.
Outdated
} else if (retainedCover && this.savedCourse?._attachments?.[retainedCover.name]) {
// Preserve the existing attachment by sending back its stub on update.
newCourse.coverFileName = retainedCover.name;
Comment thread
Mutugiii marked this conversation as resolved.
newCourse._attachments = { [retainedCover.name]: this.savedCourse._attachments[retainedCover.name] };
} else {
delete newCourse.coverFileName;
}
return this.couchService.updateDocument(
this.dbName, { ...newCourse, updatedDate: this.couchService.datePlaceholder }
).pipe(switchMap((res: any) =>
forkJoin([
of(res),
normalizedCover ?
this.couchService.putAttachment(
`${this.dbName}/${res.id}/${normalizedCover.fileName}?rev=${res.rev}`,
normalizedCover.file, { headers: { 'Content-Type': normalizedCover.contentType } }
) :
of(null),
this.couchService.bulkDocs(
'tags',
this.tagsService.tagBulkDocs(res.id, this.dbName, this.tags.value, this.coursesService.course.initialTags)
)
])
)
])
)).subscribe(([ courseRes, tagsRes ]) => {
);
})
).subscribe(([ courseRes, attachmentRes ]) => {
// putAttachment bumps the revision, so carry its rev forward to avoid a stale _rev on the next save.
const savedRes = attachmentRes?.rev ?
{ ...courseRes, rev: attachmentRes.rev, doc: { ...courseRes.doc, _rev: attachmentRes.rev } } :
courseRes;
const message = (this.pageType === 'Edit' ? $localize`Edited course: ` : $localize`Added course: `) + courseInfo.courseTitle;
this.courseChangeComplete(message, courseRes, shouldNavigate);
this.courseChangeComplete(message, savedRes, shouldNavigate);
this.preserveCoverStateUntilSubmit = false;
}, (err) => {
this.preserveCoverStateUntilSubmit = false;
this.planetMessageService.showAlert($localize`There was an error saving this course`);
});
}
Expand Down Expand Up @@ -329,18 +400,22 @@ export class CoursesAddComponent implements OnInit, OnDestroy {
if (!this.draftExists) {
return;
}
this.coverUploadComponent?.clear();
if (this.savedCourse) {
this.setFormAndSteps({
form: this.savedCourse,
steps: this.savedCourse.steps || [],
tags: this.savedCourse.tags || []
});
this.setExistingCover(this.savedCourse);
Comment thread
Mutugiii marked this conversation as resolved.
} else {
this.setFormAndSteps({
form: { courseTitle: '', description: '', languageOfInstruction: '', gradeLevel: '', subjectLevel: '' },
steps: [],
tags: []
});
this.existingCoverAttachments = [];
this.setCoverState({ retained: [], removed: [], added: [] });
}
this.coursesStepComponent.toList();
this.setInitialState();
Expand Down
12 changes: 12 additions & 0 deletions src/app/courses/add-courses/courses-add.scss
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@
background: v.$grey;
}

.cover-field {
display: block;
width: 100%;
margin-bottom: 1rem;

.cover-label {
display: block;
margin-bottom: 0.5rem;
color: v.$grey-text;
}
}

@media (max-width: v.$screen-md) {
.form-spacing {
justify-content: center;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
@if (courseDetail?.coverFileName) {
<img class="course-cover" [src]="coverImageUrl()" alt="Course cover" i18n-alt>
}
Comment thread
Mutugiii marked this conversation as resolved.
<planet-rating [rating]="courseDetail?.rating" [item]="courseDetail" [parent]="false" [ratingType]="'course'"></planet-rating>
<p><b i18n>Subject Level:</b> <planet-language-label [options]="subjectOptions" [label]="courseDetail?.subjectLevel"></planet-language-label></p>
<p><b i18n>Grade Level:</b> <planet-language-label [options]="gradeOptions" [label]="courseDetail?.gradeLevel"></planet-language-label></p>
Expand Down
17 changes: 17 additions & 0 deletions src/app/courses/view-courses/courses-view-detail.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,20 @@ import { DatePipe } from '@angular/common';
import { PlanetMarkdownComponent } from '../../shared/planet-markdown.component';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { MatButton } from '@angular/material/button';
import { environment } from '../../../environments/environment';
import { couchAttachmentUrl } from '../../shared/utils';

@Component({
selector: 'planet-courses-detail',
templateUrl: './courses-view-detail.component.html',
styles: [ `.course-cover {
display: block;
width: 180px;
height: 180px;
object-fit: cover;
border-radius: 4px;
margin-bottom: 1rem;
}` ],
imports: [PlanetRatingComponent, LanguageLabelComponent, PlanetMarkdownComponent, DatePipe]
})
export class CoursesViewDetailComponent implements OnChanges {
Expand All @@ -36,6 +46,13 @@ export class CoursesViewDetailComponent implements OnChanges {
ngOnChanges() {
this.imageSource = this.parent === true ? 'parent' : 'local';
}

coverImageUrl() {
const base = this.imageSource === 'parent' ?
`${environment.parentProtocol}://${this.planetConfiguration.parentDomain}` :
environment.couchAddress;
return couchAttachmentUrl(base, 'courses', this.courseDetail._id, this.courseDetail.coverFileName);
}
}

@Component({
Expand Down
14 changes: 12 additions & 2 deletions src/app/dashboard/dashboard-tile.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,27 @@
<ng-container *planetAuthorizedRoles="item.authorization || '_any'">
<div
class="dashboard-item"
[ngClass]="{'bg-grey': even, 'cursor-pointer': item.link && !recentlyDragged, 'accordion-item': isAccordionMode}"
[ngClass]="{
'bg-grey': even,
'cursor-pointer': item.link && !recentlyDragged,
'accordion-item': isAccordionMode,
'has-course-cover': cardType === 'myCourses' && item.coverFileName
}"
[routerLink]="recentlyDragged ? null : item.link"
[matTooltip]="item.tooltip"
cdkDrag
[cdkDragDisabled]="cardType==='myLife' || isAccordionMode"
#dashboardTile
>
@if (cardType === 'myCourses' && item.coverFileName) {
Comment thread
Mutugiii marked this conversation as resolved.
<div class="dashboard-course-cover">
<img [src]="coverImageUrl(item)" alt="Course cover" i18n-alt>
</div>
}
Comment thread
Mutugiii marked this conversation as resolved.
<p [matBadge]="item.badge" [matBadgeHidden]="item.badge===0" matBadgeOverlap="false" matBadgePosition="before">
{{item.firstLine}}
</p>
<p class="dashboard-text" [ngStyle]="{ '-webkit-line-clamp': isAccordionMode ? 'none' : tileLines,'word-wrap': 'break-word' }">
<p class="dashboard-text" [ngStyle]="{ '-webkit-line-clamp': dashboardTextLines(item),'word-wrap': 'break-word' }">
{{(cardType === 'myLife' && isAccordionMode) ? '' : (item.title | truncateText:50)}}
</p>
@if (cardType!=='myLife' && !item?.canRemove) {
Expand Down
25 changes: 19 additions & 6 deletions src/app/dashboard/dashboard-tile.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ import { AuthorizedRolesDirective } from '../shared/authorized-roles.directive';
import { MatTooltip } from '@angular/material/tooltip';
import { MatBadge } from '@angular/material/badge';
import { MatIconButton } from '@angular/material/button';
import { PlanetLoadingSpinnerComponent } from '../shared/planet-loading-spinner.component';
import { TruncateTextPipe } from '../shared/truncate-text.pipe';
import { PlanetLoadingSpinnerComponent } from '../shared/planet-loading-spinner.component';
import { TruncateTextPipe } from '../shared/truncate-text.pipe';
import { environment } from '../../environments/environment';
import { couchAttachmentUrl } from '../shared/utils';

@Component({
selector: 'planet-dashboard-tile-title',
Expand Down Expand Up @@ -213,7 +215,18 @@ export class DashboardTileComponent implements AfterViewChecked, OnInit {
}, 300);
}

getRemoveTooltip(cardTitle: string): string {
return $localize`Remove from ${cardTitle}`;
}
}
getRemoveTooltip(cardTitle: string): string {
return $localize`Remove from ${cardTitle}`;
}

dashboardTextLines(item: any): number | 'none' {
if (this.isAccordionMode) {
return 'none';
}
return this.cardType === 'myCourses' && item.coverFileName ? 2 : this.tileLines;
}

coverImageUrl(item: any): string {
return couchAttachmentUrl(environment.couchAddress, 'courses', item._id, item.coverFileName);
}
}
33 changes: 26 additions & 7 deletions src/app/dashboard/dashboard-tile.scss
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,32 @@
margin: 0;
}

.dashboard-text {
display: -webkit-box;
width: calc(125px - 1rem);
-webkit-box-orient: vertical;
overflow: hidden;
}

.dashboard-text {
display: -webkit-box;
width: calc(125px - 1rem);
-webkit-box-orient: vertical;
overflow: hidden;
}

&.has-course-cover {
padding: 0.5rem;
}

.dashboard-course-cover {
display: flex;
justify-content: center;
width: 100%;
margin-bottom: 0.25rem;

img {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 4px;
border: 1px solid v.$grey;
}
}

}
}
}
Expand Down
Loading
Loading