Skip to content

Commit cc7bb02

Browse files
author
Mateus Medeiros
committed
Testing new image placeholder
1 parent bd05d08 commit cc7bb02

8 files changed

Lines changed: 188 additions & 3 deletions

File tree

src/webapp/src/app/listen-and-write/news-image/news-image.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
priority
1010
sizes="(max-width: 600px) 92vw, (max-width: 900px) 520px, 500px"
1111
alt="News image"
12+
[appImagePlaceholder]="imageBaseId"
1213
(error)="onOptimizedImageError()">
1314
} @else {
1415
<div class="news-image-placeholder"></div>

src/webapp/src/app/listen-and-write/news-image/news-image.component.scss

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,46 @@
2828
.news-image-placeholder {
2929
width: 100%;
3030
height: 100%;
31-
background: linear-gradient(135deg, rgba(58, 125, 255, 0.18), rgba(58, 125, 255, 0.06));
31+
position: relative;
32+
overflow: hidden;
33+
isolation: isolate;
34+
background:
35+
radial-gradient(70% 70% at 18% 18%, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0) 60%),
36+
linear-gradient(135deg, rgba(58, 125, 255, 0.2), rgba(58, 125, 255, 0.04));
37+
38+
&::before {
39+
content: '';
40+
position: absolute;
41+
inset: -45% -45%;
42+
background: linear-gradient(
43+
110deg,
44+
rgba(255, 255, 255, 0.22) 0%,
45+
rgba(255, 255, 255, 0.08) 45%,
46+
rgba(255, 255, 255, 0.22) 100%
47+
);
48+
transform: translateX(-28%);
49+
animation: wf-placeholder-sweep 2.8s ease-in-out infinite;
50+
opacity: 0.6;
51+
}
52+
53+
&::after {
54+
content: '';
55+
position: absolute;
56+
inset: 0;
57+
background-image: radial-gradient(rgba(58, 125, 255, 0.18) 1px, transparent 1px);
58+
background-size: 20px 20px;
59+
opacity: 0.28;
60+
}
61+
}
62+
63+
@keyframes wf-placeholder-sweep {
64+
0% {
65+
transform: translateX(-35%);
66+
}
67+
68+
100% {
69+
transform: translateX(35%);
70+
}
3271
}
3372

3473
.news-font {

src/webapp/src/app/listen-and-write/news-image/news-image.component.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { CommonModule, IMAGE_LOADER, NgOptimizedImage } from '@angular/common';
22
import { Component, computed, effect, input, signal } from '@angular/core';
33
import { Proposition } from 'src/api/listen-and-write';
4+
import { ImagePlaceholderDirective } from '../../shared/directives/image-placeholder.directive';
45
import { minioVariantImageLoader } from '../../shared/image-loaders/minio-variant-image.loader';
56

67
@Component({
78
selector: 'app-news-image',
8-
imports: [ NgOptimizedImage, CommonModule ],
9+
imports: [ NgOptimizedImage, CommonModule, ImagePlaceholderDirective ],
910
templateUrl: './news-image.component.html',
1011
styleUrl: './news-image.component.scss',
1112
providers: [
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { isPlatformServer } from '@angular/common';
2+
import {
3+
Directive,
4+
ElementRef,
5+
HostListener,
6+
Inject,
7+
Input,
8+
OnChanges,
9+
PLATFORM_ID,
10+
Renderer2,
11+
SimpleChanges,
12+
} from '@angular/core';
13+
14+
type ImageSrc = string | null | undefined;
15+
16+
@Directive({
17+
selector: 'img[appImagePlaceholder]',
18+
standalone: true,
19+
})
20+
export class ImagePlaceholderDirective implements OnChanges {
21+
@Input() appImagePlaceholder: ImageSrc = null;
22+
23+
private readonly skeletonClass = 'wf-image-skeleton';
24+
25+
constructor(
26+
@Inject(PLATFORM_ID) private platformId: object,
27+
private imageRef: ElementRef<HTMLImageElement>,
28+
private renderer: Renderer2
29+
) {}
30+
31+
ngOnChanges(changes: SimpleChanges): void {
32+
if (isPlatformServer(this.platformId)) {
33+
return;
34+
}
35+
36+
if (changes['appImagePlaceholder']) {
37+
this.resetSkeleton();
38+
}
39+
}
40+
41+
@HostListener('load')
42+
onLoad(): void {
43+
this.removeSkeleton();
44+
}
45+
46+
@HostListener('error')
47+
onError(): void {
48+
this.removeSkeleton();
49+
}
50+
51+
private resetSkeleton(): void {
52+
const image = this.imageRef.nativeElement;
53+
const resolvedSrc =
54+
this.appImagePlaceholder || image.currentSrc || image.getAttribute('src');
55+
56+
if (!resolvedSrc) {
57+
this.removeSkeleton();
58+
return;
59+
}
60+
61+
this.renderer.addClass(image, this.skeletonClass);
62+
63+
if (image.complete && image.naturalWidth > 0) {
64+
this.removeSkeleton();
65+
}
66+
}
67+
68+
removeSkeleton(): void {
69+
// use timeout to ensure this runs after load event
70+
setTimeout(() => {
71+
// assert the DOM element still exists before trying to remove class
72+
if (this.imageRef && this.imageRef.nativeElement) {
73+
this.renderer.removeClass(this.imageRef.nativeElement, this.skeletonClass);
74+
}
75+
}, 2000);
76+
}
77+
}

src/webapp/src/app/shared/exercise-grid/exercise-grid.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ <h3>Error</h3>
7171
[loaderParams]="imageLoaderParams"
7272
sizes="(max-width: 600px) 92vw, (max-width: 992px) 48vw, 320px"
7373
fill
74+
[appImagePlaceholder]="exercise.imageBaseId"
7475
(error)="onOptimizedImageError(exercise.id)"
7576
[alt]="'Thumbnail for ' + exercise.title + ' - ' + exercise.level + ' level ' + exercise.topic + ' exercise'"
7677
[priority]="!maxItems && i === 0"

src/webapp/src/app/shared/exercise-grid/exercise-grid.component.scss

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,21 +113,65 @@
113113
flex-direction: column;
114114
align-items: center;
115115
justify-content: center;
116-
background: linear-gradient(135deg, vars.$color-primary 0%, vars.$color-primary-dark 100%);
116+
position: relative;
117+
overflow: hidden;
118+
isolation: isolate;
119+
background:
120+
radial-gradient(80% 80% at 15% 15%, rgba(255, 255, 255, 0.25) 0%, rgba(255, 255, 255, 0) 60%),
121+
linear-gradient(135deg, vars.$color-primary 0%, vars.$color-primary-dark 100%);
117122
color: white;
118123
gap: 0.5rem;
119124

125+
&::before {
126+
content: '';
127+
position: absolute;
128+
inset: -40% -40%;
129+
background: linear-gradient(
130+
115deg,
131+
rgba(255, 255, 255, 0.22) 0%,
132+
rgba(255, 255, 255, 0.06) 45%,
133+
rgba(255, 255, 255, 0.22) 100%
134+
);
135+
transform: translateX(-30%);
136+
animation: wf-placeholder-sweep 2.6s ease-in-out infinite;
137+
opacity: 0.7;
138+
}
139+
140+
&::after {
141+
content: '';
142+
position: absolute;
143+
inset: 0;
144+
background-image: radial-gradient(rgba(255, 255, 255, 0.18) 1px, transparent 1px);
145+
background-size: 18px 18px;
146+
opacity: 0.25;
147+
}
148+
120149
mat-icon {
121150
font-size: 3rem;
122151
width: 3rem;
123152
height: 3rem;
124153
opacity: 0.9;
154+
position: relative;
155+
z-index: 1;
125156
}
126157

127158
.placeholder-text {
128159
font-size: 2rem;
129160
font-weight: 600;
130161
opacity: 0.9;
162+
position: relative;
163+
z-index: 1;
164+
text-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
165+
}
166+
}
167+
168+
@keyframes wf-placeholder-sweep {
169+
0% {
170+
transform: translateX(-35%);
171+
}
172+
173+
100% {
174+
transform: translateX(35%);
131175
}
132176
}
133177

src/webapp/src/app/shared/exercise-grid/exercise-grid.component.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { SubjectEnum } from '../../../api/listen-and-write/model/subjectEnum';
1313
import { ComplexityEnum } from '../../../api/listen-and-write/model/complexityEnum';
1414
import { PropositionsService } from '../../../api/listen-and-write/api/propositions.service';
1515
import { minioVariantImageLoader } from '../image-loaders/minio-variant-image.loader';
16+
import { ImagePlaceholderDirective } from '../directives/image-placeholder.directive';
1617

1718
export interface Exercise {
1819
id: number;
@@ -42,6 +43,7 @@ export interface Exercise {
4243
MatPaginatorModule,
4344
MatProgressSpinnerModule,
4445
FormsModule,
46+
ImagePlaceholderDirective,
4547
],
4648
templateUrl: './exercise-grid.component.html',
4749
styleUrls: ['./exercise-grid.component.scss'],

src/webapp/src/styles.scss

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,26 @@ p, h1, h2, h3, h4, h5, h6, span, label {
6262
flex-direction: column;
6363
}
6464

65+
.wf-image-skeleton {
66+
background:
67+
linear-gradient(110deg, rgba(255, 255, 255, 0) 8%, rgba(255, 255, 255, 0.35) 18%, rgba(255, 255, 255, 0) 33%),
68+
linear-gradient(135deg, rgba(58, 125, 255, 0.08), rgba(58, 125, 255, 0.02));
69+
background-size: 200% 100%, 100% 100%;
70+
animation: wf-skeleton-shimmer 1.6s ease-in-out infinite;
71+
border-radius: inherit;
72+
filter: saturate(0.95);
73+
}
74+
75+
@keyframes wf-skeleton-shimmer {
76+
0% {
77+
background-position: -160% 0, 0 0;
78+
}
79+
80+
100% {
81+
background-position: 160% 0, 0 0;
82+
}
83+
}
84+
6585
/* WriteFluency Shepherd theme */
6686
.shepherd-element.wf-tour {
6787
border-radius: 16px;

0 commit comments

Comments
 (0)