Skip to content

Commit 0953083

Browse files
committed
Performance: Lazy images loading
1 parent 28a486a commit 0953083

14 files changed

+227
-21
lines changed

assets/styles/_components.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
@import 'components/type';
66
@import 'components/images';
7+
@import 'components/lazy-images';
78
@import 'components/icons';
89
@import 'components/code';
910
@import 'components/tables';
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//
2+
// Lazy Loading Images
3+
// --------------------------------------------------
4+
5+
// Wrapper with fixed dimensions - contains the image
6+
.lazy-img-wrapper {
7+
display: inline-flex;
8+
align-items: flex-start; // Align to top
9+
justify-content: center; // Center horizontally
10+
background-color: #e9ecef; // Placeholder background
11+
12+
// Size variants
13+
&.lazy-img-40 { max-width: 40px; max-height: 40px; }
14+
&.lazy-img-50 { max-width: 50px; max-height: 50px; }
15+
&.lazy-img-60 { max-width: 60px; max-height: 60px; }
16+
&.lazy-img-80 { max-width: 80px; max-height: 80px; }
17+
&.lazy-img-90 { max-width: 90px; max-height: 90px; }
18+
&.lazy-img-100 { max-width: 100px; max-height: 100px; }
19+
}
20+
21+
// Image inside wrapper
22+
.lazy-img {
23+
max-width: 100%;
24+
max-height: 100%;
25+
object-fit: contain; // Preserve aspect ratio
26+
opacity: 0;
27+
transition: opacity 0.25s ease-in-out;
28+
29+
&.loaded {
30+
opacity: 1;
31+
}
32+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SpeedPuzzling\Web\Twig;
6+
7+
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
8+
use Twig\Extension\AbstractExtension;
9+
use Twig\TwigFunction;
10+
11+
final class LazyImageTwigExtension extends AbstractExtension
12+
{
13+
private const string PLACEHOLDER_IMAGE = '/img/placeholder-puzzle.png';
14+
15+
public function __construct(
16+
readonly private CacheManager $cacheManager,
17+
) {
18+
}
19+
20+
/**
21+
* @return array<TwigFunction>
22+
*/
23+
public function getFunctions(): array
24+
{
25+
return [
26+
new TwigFunction('lazy_puzzle_image', $this->lazyPuzzleImage(...), ['is_safe' => ['html']]),
27+
];
28+
}
29+
30+
/**
31+
* Generates a lazy-loaded puzzle image with wrapper and proper attributes.
32+
*
33+
* @param string|null $path Image path in S3
34+
* @param string $filter LiipImagine filter (puzzle_small, puzzle_medium)
35+
* @param string $alt Alt text for the image
36+
* @param int $position Position in list (1-based). First 4 positions are eager-loaded.
37+
* @param int $size Display size in pixels (60, 80, or 90)
38+
* @param string $class Additional CSS classes for the wrapper
39+
*/
40+
public function lazyPuzzleImage(
41+
null|string $path,
42+
string $filter,
43+
string $alt,
44+
int $position = 999,
45+
int $size = 80,
46+
string $class = '',
47+
): string {
48+
$src = $this->getImageSrc($path, $filter);
49+
$sizeClass = $this->getSizeClass($size);
50+
51+
// First 4 images are above-the-fold (eager loading)
52+
$isEager = $position <= 4;
53+
$loading = $isEager ? 'eager' : 'lazy';
54+
55+
// Build wrapper classes
56+
$wrapperClasses = trim(sprintf('lazy-img-wrapper %s %s', $sizeClass, $class));
57+
58+
// Build img classes and onload handler
59+
if ($isEager) {
60+
$imgClasses = 'lazy-img loaded';
61+
$onload = '';
62+
} else {
63+
$imgClasses = 'lazy-img';
64+
$onload = ' onload="this.classList.add(\'loaded\')"';
65+
}
66+
67+
return sprintf(
68+
'<span class="%s"><img src="%s" alt="%s" loading="%s" class="%s"%s></span>',
69+
htmlspecialchars($wrapperClasses, ENT_QUOTES, 'UTF-8'),
70+
htmlspecialchars($src, ENT_QUOTES, 'UTF-8'),
71+
htmlspecialchars($alt, ENT_QUOTES, 'UTF-8'),
72+
$loading,
73+
$imgClasses,
74+
$onload,
75+
);
76+
}
77+
78+
private function getImageSrc(null|string $path, string $filter): string
79+
{
80+
if ($path === null) {
81+
return self::PLACEHOLDER_IMAGE;
82+
}
83+
84+
return $this->cacheManager->getBrowserPath($path, $filter);
85+
}
86+
87+
private function getSizeClass(int $size): string
88+
{
89+
return match (true) {
90+
$size <= 40 => 'lazy-img-40',
91+
$size <= 50 => 'lazy-img-50',
92+
$size <= 60 => 'lazy-img-60',
93+
$size <= 80 => 'lazy-img-80',
94+
$size <= 90 => 'lazy-img-90',
95+
default => 'lazy-img-100',
96+
};
97+
}
98+
}

templates/_player_solvings.html.twig

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@
66
<td class="text-center" style="width: 90px;">
77
{% if solved_puzzle[0].puzzleImage is not null%}
88
<a href="{{ path('puzzle_detail', {puzzleId: solved_puzzle[0].puzzleId}) }}">
9-
<img alt="{{ 'puzzle_img_alt'|trans({'%puzzle%': solved_puzzle[0].manufacturerName ~ ' ' ~ solved_puzzle[0].puzzleName}) }}" class="rounded-2" style="max-width: 80px;max-height: 80px;" src="{{ solved_puzzle[0].puzzleImage|imagine_filter('puzzle_small') }}">
9+
{{ lazy_puzzle_image(
10+
solved_puzzle[0].puzzleImage,
11+
'puzzle_small',
12+
'puzzle_img_alt'|trans({'%puzzle%': solved_puzzle[0].manufacturerName ~ ' ' ~ solved_puzzle[0].puzzleName}),
13+
loop.index,
14+
80,
15+
'rounded-2'
16+
) }}
1017
</a>
1118
{% endif %}
1219

templates/_puzzle_item.html.twig

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@
1515
<div class="d-flex">
1616
<div style="width: 90px;">
1717
<a data-turbo-frame="_top" href="{{ path('puzzle_detail', {puzzleId: puzzle.puzzleId}) }}">
18-
<img class="rounded-2" style="max-width: 90px;max-height: 90px;" alt="{{ 'puzzle_img_alt'|trans({'%puzzle%': puzzle.manufacturerName ~ ' ' ~ puzzle.puzzleName}) }}" src="{{ puzzle.puzzleImage|puzzle_image('puzzle_small') }}">
18+
{{ lazy_puzzle_image(
19+
puzzle.puzzleImage,
20+
'puzzle_small',
21+
'puzzle_img_alt'|trans({'%puzzle%': puzzle.manufacturerName ~ ' ' ~ puzzle.puzzleName}),
22+
999,
23+
90,
24+
'rounded-2'
25+
) }}
1926
</a>
2027
</div>
2128

templates/_puzzle_library_item.html.twig

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,14 @@
7878
{# Puzzle image #}
7979
<div style="width: 60px;" class="flex-shrink-0">
8080
<a href="{{ path('puzzle_detail', {puzzleId: item.puzzleId}) }}">
81-
<img src="{{ item.image|puzzle_image('puzzle_small') }}"
82-
alt="{{ item.puzzleName }}"
83-
class="rounded"
84-
style="width: 60px; height: 60px; object-fit: cover;">
81+
{{ lazy_puzzle_image(
82+
item.image,
83+
'puzzle_small',
84+
item.puzzleName,
85+
999,
86+
60,
87+
'rounded'
88+
) }}
8589
</a>
8690
</div>
8791

templates/compare_players.html.twig

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,14 @@
6060
<td>
6161
{% if comparison.puzzleImage is not null%}
6262
<a href="{{ path('puzzle_detail', {puzzleId: comparison.puzzleId}) }}">
63-
<img class="rounded-2" style="max-width: 100px;max-height: 100px;" alt="{{ 'puzzle_img_alt'|trans({'%puzzle%': comparison.manufacturerName ~ ' ' ~ comparison.puzzleName}) }}" src="{{ comparison.puzzleImage|imagine_filter('puzzle_small') }}">
63+
{{ lazy_puzzle_image(
64+
comparison.puzzleImage,
65+
'puzzle_small',
66+
'puzzle_img_alt'|trans({'%puzzle%': comparison.manufacturerName ~ ' ' ~ comparison.puzzleName}),
67+
loop.index,
68+
100,
69+
'rounded-2'
70+
) }}
6471
</a>
6572
{% endif %}
6673
</td>

templates/components/GlobalSearch.html.twig

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,14 @@
6767
<div style="width: 90px;" class="text-center">
6868
{% if puzzle.puzzleImage is not null%}
6969
<a data-turbo-frame="_top" href="{{ path('puzzle_detail', {puzzleId: puzzle.puzzleId}) }}">
70-
<img class="rounded-2" style="max-width: 90px;max-height: 80px;" alt="{{ 'puzzle_img_alt'|trans({'%puzzle%': puzzle.manufacturerName ~ ' ' ~ puzzle.puzzleName}) }}" src="{{ puzzle.puzzleImage|imagine_filter('puzzle_small') }}">
70+
{{ lazy_puzzle_image(
71+
puzzle.puzzleImage,
72+
'puzzle_small',
73+
'puzzle_img_alt'|trans({'%puzzle%': puzzle.manufacturerName ~ ' ' ~ puzzle.puzzleName}),
74+
loop.index,
75+
90,
76+
'rounded-2'
77+
) }}
7178
</a>
7279
{% endif %}
7380
</div>

templates/components/LadderTable.html.twig

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@
99
</td>
1010
<td class="text-center low-line-height" style="width: 90px;">
1111
<a href="{{ path('puzzle_detail', {puzzleId: solving_time.puzzleId}) }}">
12-
<img alt="{{ 'puzzle_img_alt'|trans({'%puzzle%': solving_time.manufacturerName ~ ' ' ~ solving_time.puzzleName}) }}" class="rounded-2 mt-1" style="max-width: 80px;max-height: 80px;" src="{{ solving_time.puzzleImage|puzzle_image('puzzle_small') }}">
12+
{{ lazy_puzzle_image(
13+
solving_time.puzzleImage,
14+
'puzzle_small',
15+
'puzzle_img_alt'|trans({'%puzzle%': solving_time.manufacturerName ~ ' ' ~ solving_time.puzzleName}),
16+
loop.index,
17+
80,
18+
'rounded-2 mt-1'
19+
) }}
1320
</a>
1421
</td>
1522

templates/components/MostSolvedPuzzles.html.twig

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,14 @@
5151
<tr class="{{ this.showLimit > 0 and loop.index > this.showLimit ? 'd-none' }}">
5252
<td class="text-center">
5353
<a href="{{ path('puzzle_detail', {puzzleId: most_solved_puzzle.puzzleId}) }}">
54-
<img class="rounded-2" style="max-width: 80px;max-height: 80px;" alt="{{ 'puzzle_img_alt'|trans({'%puzzle%': most_solved_puzzle.manufacturerName ~ ' ' ~ most_solved_puzzle.puzzleName}) }}" src="{{ most_solved_puzzle.puzzleImage|puzzle_image('puzzle_small') }}">
54+
{{ lazy_puzzle_image(
55+
most_solved_puzzle.puzzleImage,
56+
'puzzle_small',
57+
'puzzle_img_alt'|trans({'%puzzle%': most_solved_puzzle.manufacturerName ~ ' ' ~ most_solved_puzzle.puzzleName}),
58+
loop.index,
59+
80,
60+
'rounded-2'
61+
) }}
5562
</a>
5663
</td>
5764

0 commit comments

Comments
 (0)