@simple-photo-gallery/theme-modern 2.0.16 → 2.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simple-photo-gallery/theme-modern",
3
- "version": "2.0.16",
3
+ "version": "2.0.18",
4
4
  "description": "Modern theme for Simple Photo Gallery",
5
5
  "license": "MIT",
6
6
  "author": "Vladimir Haltakov, Tomasz Rusin",
@@ -36,7 +36,7 @@
36
36
  "devDependencies": {
37
37
  "@eslint/eslintrc": "^3.3.1",
38
38
  "@eslint/js": "^9.30.1",
39
- "@simple-photo-gallery/common": "1.0.5",
39
+ "@simple-photo-gallery/common": "1.0.6",
40
40
  "@types/photoswipe": "^4.1.6",
41
41
  "@typescript-eslint/eslint-plugin": "^8.35.1",
42
42
  "@typescript-eslint/parser": "^8.35.1",
@@ -14,7 +14,7 @@
14
14
  .footer {
15
15
  padding: 2rem 1.5rem 1.5rem;
16
16
  text-align: center;
17
- background-color: transparent;
17
+ background-color: var(--section-bg-color-odd, var(--section-bg-color, #ffffff));
18
18
  }
19
19
 
20
20
  .footer__text {
@@ -40,7 +40,7 @@ const validImages = section.images.filter(
40
40
  <style>
41
41
  .gallery-section {
42
42
  padding: 1rem 1rem;
43
- background-color: var(--section-bg-color-odd, var(--section-bg-color, transparent));
43
+ background-color: var(--section-bg-color-odd, var(--section-bg-color, #ffffff));
44
44
  }
45
45
 
46
46
  .gallery-section:nth-child(even) {
@@ -1,21 +1,25 @@
1
1
  ---
2
2
  import path from 'node:path';
3
3
 
4
- import { getPhotoPath } from '../../utils';
4
+ import { buildHeroSrcset, getPhotoPath, LANDSCAPE_SIZES, PORTRAIT_SIZES } from '../../utils';
5
5
 
6
6
  import HeroScrollToGalleryBtn from '@/features/themes/base-theme/components/hero/HeroScrollToGalleryBtn.astro';
7
7
  import { renderMarkdown } from '@/lib/markdown';
8
8
 
9
+ import type { HeaderImageVariants } from '@simple-photo-gallery/common';
10
+
9
11
  interface Props {
10
12
  title: string;
11
13
  description?: string;
12
14
  thumbsBaseUrl?: string;
13
15
  headerImage?: string;
14
16
  headerImageBlurHash?: string;
17
+ headerImageVariants?: HeaderImageVariants;
15
18
  mediaBaseUrl?: string;
16
19
  }
17
20
 
18
- const { title, description, thumbsBaseUrl, headerImage, headerImageBlurHash, mediaBaseUrl } = Astro.props;
21
+ const { title, description, thumbsBaseUrl, headerImage, headerImageBlurHash, headerImageVariants, mediaBaseUrl } =
22
+ Astro.props;
19
23
 
20
24
  // Parse description as Markdown if it exists
21
25
  const parsedDescription: string = description ? await renderMarkdown(description) : '';
@@ -28,59 +32,47 @@ const thumbnailBasePath = thumbsBaseUrl || 'gallery/images';
28
32
 
29
33
  // Original header photo
30
34
  const headerPhotoPath = getPhotoPath(headerImage || '', mediaBaseUrl);
35
+
36
+ // Determine which sources to show based on headerImageVariants
37
+ // If headerImageVariants is not set, use all generated paths (default behavior)
38
+ // If headerImageVariants is set, only show sources for formats that are explicitly provided
39
+ const useDefaultPaths = !headerImageVariants;
40
+
41
+ // Pre-compute all srcsets - only render sources when srcset is non-empty
42
+ // This handles edge cases like empty format objects: { avif: {} }
43
+ const portraitAvifSrcset = buildHeroSrcset(headerImageVariants?.portrait?.avif, PORTRAIT_SIZES, thumbnailBasePath, imgBasename, 'portrait', 'avif', useDefaultPaths);
44
+ const portraitJpgSrcset = buildHeroSrcset(headerImageVariants?.portrait?.jpg, PORTRAIT_SIZES, thumbnailBasePath, imgBasename, 'portrait', 'jpg', useDefaultPaths);
45
+ const landscapeAvifSrcset = buildHeroSrcset(headerImageVariants?.landscape?.avif, LANDSCAPE_SIZES, thumbnailBasePath, imgBasename, 'landscape', 'avif', useDefaultPaths);
46
+ const landscapeJpgSrcset = buildHeroSrcset(headerImageVariants?.landscape?.jpg, LANDSCAPE_SIZES, thumbnailBasePath, imgBasename, 'landscape', 'jpg', useDefaultPaths);
31
47
  ---
32
48
 
33
49
  <section class="hero">
34
50
  <div class="hero__bg-wrapper">
35
51
  {headerImageBlurHash && <canvas data-blur-hash={headerImageBlurHash} width={32} height={32} />}
36
52
  <picture class="hero__bg" id="hero-bg-picture">
37
- <!-- Portrait -->
38
- <source
39
- type="image/avif"
40
- media="(max-aspect-ratio: 3/4)"
41
- srcset={`
42
- ${thumbnailBasePath}/${imgBasename}_portrait_360.avif 360w,
43
- ${thumbnailBasePath}/${imgBasename}_portrait_480.avif 480w,
44
- ${thumbnailBasePath}/${imgBasename}_portrait_720.avif 720w,
45
- ${thumbnailBasePath}/${imgBasename}_portrait_1080.avif 1080w`}
46
- sizes="(max-aspect-ratio: 3/4) 160vw, 100vw"
47
- />
48
- <source
49
- type="image/jpeg"
50
- media="(max-aspect-ratio: 3/4)"
51
- srcset={`
52
- ${thumbnailBasePath}/${imgBasename}_portrait_360.jpg 360w,
53
- ${thumbnailBasePath}/${imgBasename}_portrait_480.jpg 480w,
54
- ${thumbnailBasePath}/${imgBasename}_portrait_720.jpg 720w,
55
- ${thumbnailBasePath}/${imgBasename}_portrait_1080.jpg 1080w`}
56
- sizes="(max-aspect-ratio: 3/4) 160vw, 100vw"
57
- />
58
-
59
- <!-- Landscape -->
60
- <source
61
- type="image/avif"
62
- srcset={`
63
- ${thumbnailBasePath}/${imgBasename}_landscape_640.avif 640w,
64
- ${thumbnailBasePath}/${imgBasename}_landscape_960.avif 960w,
65
- ${thumbnailBasePath}/${imgBasename}_landscape_1280.avif 1280w,
66
- ${thumbnailBasePath}/${imgBasename}_landscape_1920.avif 1920w,
67
- ${thumbnailBasePath}/${imgBasename}_landscape_2560.avif 2560w,
68
- ${thumbnailBasePath}/${imgBasename}_landscape_3840.avif 3840w`}
69
- sizes="100vw"
70
- />
71
- <source
72
- type="image/jpg"
73
- srcset={`
74
- ${thumbnailBasePath}/${imgBasename}_landscape_640.jpg 640w,
75
- ${thumbnailBasePath}/${imgBasename}_landscape_960.jpg 960w,
76
- ${thumbnailBasePath}/${imgBasename}_landscape_1280.jpg 1280w,
77
- ${thumbnailBasePath}/${imgBasename}_landscape_1920.jpg 1920w,
78
- ${thumbnailBasePath}/${imgBasename}_landscape_2560.jpg 2560w,
79
- ${thumbnailBasePath}/${imgBasename}_landscape_3840.jpg 3840w`}
80
- sizes="100vw"
81
- />
82
-
83
- <!-- Fallback -->
53
+ {/* Portrait */}
54
+ {portraitAvifSrcset && (
55
+ <source
56
+ type="image/avif"
57
+ media="(max-aspect-ratio: 3/4)"
58
+ srcset={portraitAvifSrcset}
59
+ sizes="(max-aspect-ratio: 3/4) 160vw, 100vw"
60
+ />
61
+ )}
62
+ {portraitJpgSrcset && (
63
+ <source
64
+ type="image/jpeg"
65
+ media="(max-aspect-ratio: 3/4)"
66
+ srcset={portraitJpgSrcset}
67
+ sizes="(max-aspect-ratio: 3/4) 160vw, 100vw"
68
+ />
69
+ )}
70
+
71
+ {/* Landscape */}
72
+ {landscapeAvifSrcset && <source type="image/avif" srcset={landscapeAvifSrcset} sizes="100vw" />}
73
+ {landscapeJpgSrcset && <source type="image/jpeg" srcset={landscapeJpgSrcset} sizes="100vw" />}
74
+
75
+ {/* Fallback */}
84
76
  <img src={headerPhotoPath} class="hero__bg-img" />
85
77
  </picture>
86
78
  <script is:inline>
@@ -145,13 +137,13 @@ const headerPhotoPath = getPhotoPath(headerImage || '', mediaBaseUrl);
145
137
  () => {
146
138
  // Final fallback failed, blurhash stays visible
147
139
  },
148
- { once: true }
140
+ { once: true },
149
141
  );
150
142
 
151
143
  // If fallback succeeds, hide blurhash
152
144
  img.addEventListener('load', hideBlurhash, { once: true });
153
145
  },
154
- { once: true }
146
+ { once: true },
155
147
  );
156
148
  })();
157
149
  </script>
@@ -168,7 +160,7 @@ const headerPhotoPath = getPhotoPath(headerImage || '', mediaBaseUrl);
168
160
  .hero {
169
161
  position: relative;
170
162
  min-height: 450px;
171
- height: 100vh;
163
+ height: var(--hero-height, 100vh);
172
164
  display: flex;
173
165
  align-items: center;
174
166
  justify-content: center;
@@ -1,5 +1,7 @@
1
1
  ---
2
- import type { GalleryMetadata } from '@simple-photo-gallery/common/src/gallery';
2
+ import { buildHeroSrcset, LANDSCAPE_SIZES, PORTRAIT_SIZES } from '../utils';
3
+
4
+ import type { GalleryMetadata, HeaderImageVariants } from '@simple-photo-gallery/common/src/gallery';
3
5
 
4
6
  interface Props {
5
7
  title: string;
@@ -8,15 +10,45 @@ interface Props {
8
10
  thumbsBaseUrl?: string;
9
11
  metadata?: GalleryMetadata;
10
12
  headerImageBasename?: string;
13
+ headerImageVariants?: HeaderImageVariants;
11
14
  }
12
15
 
13
- const { title, description, url, thumbsBaseUrl, metadata, headerImageBasename } = Astro.props;
16
+ const { title, description, url, thumbsBaseUrl, metadata, headerImageBasename, headerImageVariants } = Astro.props;
14
17
 
15
18
  // Use headerImageBasename for dynamic image paths, fallback to generic name
16
19
  const imgBasename = headerImageBasename || 'header';
17
20
 
18
21
  // Get the base path for the thumbnails
19
22
  const thumbnailBasePath = thumbsBaseUrl || 'gallery/images';
23
+
24
+ // Determine which sources to show based on headerImageVariants
25
+ // If headerImageVariants is not set, use all generated paths (default behavior)
26
+ // If headerImageVariants is set, only show sources for formats that are explicitly provided
27
+ const useDefaultPaths = !headerImageVariants;
28
+
29
+ // Pre-compute srcsets - only non-empty srcsets will be rendered
30
+ // This handles edge cases like empty format objects: { avif: {} }
31
+ const portraitAvifSrcset = buildHeroSrcset(headerImageVariants?.portrait?.avif, PORTRAIT_SIZES, thumbnailBasePath, imgBasename, 'portrait', 'avif', useDefaultPaths);
32
+ const portraitJpgSrcset = buildHeroSrcset(headerImageVariants?.portrait?.jpg, PORTRAIT_SIZES, thumbnailBasePath, imgBasename, 'portrait', 'jpg', useDefaultPaths);
33
+ const landscapeAvifSrcset = buildHeroSrcset(headerImageVariants?.landscape?.avif, LANDSCAPE_SIZES, thumbnailBasePath, imgBasename, 'landscape', 'avif', useDefaultPaths);
34
+ const landscapeJpgSrcset = buildHeroSrcset(headerImageVariants?.landscape?.jpg, LANDSCAPE_SIZES, thumbnailBasePath, imgBasename, 'landscape', 'jpg', useDefaultPaths);
35
+
36
+ // Determine media queries for preloads to match actual <source> media attributes in Hero.astro
37
+ // Portrait <source> always has media="(max-aspect-ratio: 3/4)", so preload must use the same restriction
38
+ // Otherwise landscape devices would preload portrait images they'll never use (wasting bandwidth)
39
+ // Landscape sources have no media restriction in Hero.astro, but we add one here when ANY portrait source exists
40
+ // to prevent landscape images from being preloaded on portrait devices (which will use portrait sources)
41
+ // We check both AVIF and JPG because portrait devices will use whichever portrait format is available
42
+ const hasAnyPortraitSource = portraitAvifSrcset || portraitJpgSrcset;
43
+ const portraitPreloadMedia = '(max-aspect-ratio: 3/4)';
44
+ const landscapePreloadMedia = hasAnyPortraitSource ? 'not (max-aspect-ratio: 3/4)' : undefined;
45
+
46
+ // Determine which format to preload for each orientation
47
+ // Prefer AVIF when available (better compression), fall back to JPG for JPG-only configurations
48
+ const portraitPreloadSrcset = portraitAvifSrcset || portraitJpgSrcset;
49
+ const portraitPreloadType = portraitAvifSrcset ? 'image/avif' : 'image/jpeg';
50
+ const landscapePreloadSrcset = landscapeAvifSrcset || landscapeJpgSrcset;
51
+ const landscapePreloadType = landscapeAvifSrcset ? 'image/avif' : 'image/jpeg';
20
52
  ---
21
53
 
22
54
  <head>
@@ -77,22 +109,26 @@ const thumbnailBasePath = thumbsBaseUrl || 'gallery/images';
77
109
  href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap"
78
110
  rel="stylesheet"
79
111
  />
80
- <link
81
- rel="preload"
82
- as="image"
83
- type="image/avif"
84
- media="(max-aspect-ratio: 3/4)"
85
- imagesrcset={`${thumbnailBasePath}/${imgBasename}_portrait_360.avif 360w, ${thumbnailBasePath}/${imgBasename}_portrait_480.avif 480w, ${thumbnailBasePath}/${imgBasename}_portrait_720.avif 720w, ${thumbnailBasePath}/${imgBasename}_portrait_1080.avif 1080w`}
86
- imagesizes="(max-aspect-ratio: 3/4) 160vw, 100vw"
87
- fetchpriority="high"
88
- />
89
- <link
90
- rel="preload"
91
- as="image"
92
- type="image/avif"
93
- media="(min-aspect-ratio: 3/4)"
94
- imagesrcset={`${thumbnailBasePath}/${imgBasename}_landscape_640.avif 640w, ${thumbnailBasePath}/${imgBasename}_landscape_960.avif 960w, ${thumbnailBasePath}/${imgBasename}_landscape_1280.avif 1280w, ${thumbnailBasePath}/${imgBasename}_landscape_1920.avif 1920w, ${thumbnailBasePath}/${imgBasename}_landscape_2560.avif 2560w, ${thumbnailBasePath}/${imgBasename}_landscape_3840.avif 3840w`}
95
- imagesizes="100vw"
96
- fetchpriority="high"
97
- />
112
+ {portraitPreloadSrcset && (
113
+ <link
114
+ rel="preload"
115
+ as="image"
116
+ type={portraitPreloadType}
117
+ media={portraitPreloadMedia}
118
+ imagesrcset={portraitPreloadSrcset}
119
+ imagesizes="(max-aspect-ratio: 3/4) 160vw, 100vw"
120
+ fetchpriority="high"
121
+ />
122
+ )}
123
+ {landscapePreloadSrcset && (
124
+ <link
125
+ rel="preload"
126
+ as="image"
127
+ type={landscapePreloadType}
128
+ media={landscapePreloadMedia}
129
+ imagesrcset={landscapePreloadSrcset}
130
+ imagesizes="100vw"
131
+ fetchpriority="high"
132
+ />
133
+ )}
98
134
  </head>
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
 
4
4
  import MainHead from '@/features/themes/base-theme/layouts/MainHead.astro';
5
5
 
6
- import type { GalleryMetadata } from '@simple-photo-gallery/common/src/gallery';
6
+ import type { GalleryMetadata, HeaderImageVariants } from '@simple-photo-gallery/common/src/gallery';
7
7
 
8
8
  interface Props {
9
9
  title: string;
@@ -13,9 +13,11 @@ interface Props {
13
13
  metadata?: GalleryMetadata;
14
14
  analyticsScript?: string;
15
15
  headerImage?: string;
16
+ headerImageVariants?: HeaderImageVariants;
16
17
  }
17
18
 
18
- const { title, description, metadata, url, thumbsBaseUrl, analyticsScript, headerImage } = Astro.props;
19
+ const { title, description, metadata, url, thumbsBaseUrl, analyticsScript, headerImage, headerImageVariants } =
20
+ Astro.props;
19
21
 
20
22
  // Extract basename from headerImage filename
21
23
  const headerImageBasename = headerImage ? path.basename(headerImage, path.extname(headerImage)) : undefined;
@@ -30,6 +32,7 @@ const headerImageBasename = headerImage ? path.basename(headerImage, path.extnam
30
32
  url={url}
31
33
  thumbsBaseUrl={thumbsBaseUrl}
32
34
  headerImageBasename={headerImageBasename}
35
+ headerImageVariants={headerImageVariants}
33
36
  />
34
37
  <body>
35
38
  <slot />
@@ -29,6 +29,7 @@ const {
29
29
  analyticsScript,
30
30
  headerImage,
31
31
  headerImageBlurHash,
32
+ headerImageVariants,
32
33
  ctaBanner,
33
34
  } = gallery;
34
35
 
@@ -66,12 +67,14 @@ const showCtaBanner = ctaBanner === true;
66
67
  url={url}
67
68
  thumbsBaseUrl={thumbsBaseUrl}
68
69
  analyticsScript={analyticsScript}
69
- headerImage={headerImage}>
70
+ headerImage={headerImage}
71
+ headerImageVariants={headerImageVariants}>
70
72
  <Hero
71
73
  title={title}
72
74
  description={description}
73
75
  headerImage={headerImage}
74
76
  headerImageBlurHash={headerImageBlurHash}
77
+ headerImageVariants={headerImageVariants}
75
78
  thumbsBaseUrl={thumbsBaseUrl}
76
79
  mediaBaseUrl={mediaBaseUrl}
77
80
  />
@@ -62,3 +62,46 @@ export const getSubgalleryThumbnailPath = (subgalleryHeaderImagePath: string) =>
62
62
 
63
63
  return path.join(subgalleryFolderName, 'gallery', 'thumbnails', photoBasename);
64
64
  };
65
+
66
+ /** Portrait image sizes for responsive hero images */
67
+ export const PORTRAIT_SIZES = [360, 480, 720, 1080] as const;
68
+
69
+ /** Landscape image sizes for responsive hero images */
70
+ export const LANDSCAPE_SIZES = [640, 960, 1280, 1920, 2560, 3840] as const;
71
+
72
+ /**
73
+ * Build a srcset string for responsive images.
74
+ * Uses custom paths from variants when provided, otherwise generates default paths.
75
+ *
76
+ * @param variants - Optional record mapping sizes to custom URLs
77
+ * @param sizes - Array of image widths to include
78
+ * @param thumbnailBasePath - Base path for generated thumbnails
79
+ * @param imgBasename - Image basename for generated paths
80
+ * @param orientation - 'portrait' or 'landscape'
81
+ * @param format - Image format ('avif' or 'jpg')
82
+ * @param useDefaultPaths - Whether to use generated paths when no custom variant exists
83
+ * @returns Comma-separated srcset string
84
+ */
85
+ export const buildHeroSrcset = (
86
+ variants: Record<number, string | undefined> | undefined,
87
+ sizes: readonly number[],
88
+ thumbnailBasePath: string,
89
+ imgBasename: string,
90
+ orientation: 'portrait' | 'landscape',
91
+ format: 'avif' | 'jpg',
92
+ useDefaultPaths: boolean,
93
+ ): string => {
94
+ return sizes
95
+ .map((size) => {
96
+ const customPath = variants?.[size];
97
+ if (customPath) {
98
+ return `${customPath} ${size}w`;
99
+ }
100
+ if (useDefaultPaths) {
101
+ return `${thumbnailBasePath}/${imgBasename}_${orientation}_${size}.${format} ${size}w`;
102
+ }
103
+ return null;
104
+ })
105
+ .filter(Boolean)
106
+ .join(', ');
107
+ };
@@ -134,6 +134,16 @@ const applySectionBackgroundColors = (params: URLSearchParams): void => {
134
134
  setCSSVar(root, '--section-bg-color-odd', parseColor(params.get('sectionBgColorOdd')));
135
135
  };
136
136
 
137
+ /**
138
+ * Applies hero height from the 'heroHeight' query parameter.
139
+ * Accepts a number (e.g., '100' for 100vh, '50' for 50vh).
140
+ */
141
+ const applyHeroHeight = (params: URLSearchParams): void => {
142
+ const value = params.get('heroHeight')?.trim();
143
+ const height = value && /^\d+(\.\d+)?$/.test(value) ? `${value}vh` : null;
144
+ setCSSVar(document.documentElement, '--hero-height', height);
145
+ };
146
+
137
147
  /**
138
148
  * Main function that applies all query parameter configurations to the page.
139
149
  * Reads URL search params and applies header visibility, background, typography, and section colors.
@@ -145,4 +155,5 @@ export const applyQueryParams = (): void => {
145
155
  applyTransparentBackground(params);
146
156
  applyTypographyColors(params);
147
157
  applySectionBackgroundColors(params);
158
+ applyHeroHeight(params);
148
159
  };