@bygghammar/cms-platform 0.61.1 → 0.62.0
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 +5 -2
- package/src/blocks/components/HeroFullBlock.tsx +23 -18
- package/src/blocks/components/InstagramBlock.tsx +47 -84
- package/src/blocks/components/InstagramTiles.tsx +138 -0
- package/src/blocks/components/MediaSplitBlock.tsx +11 -1
- package/src/host/index.tsx +10 -0
- package/src/media/FocalImage.tsx +79 -5
- package/src/media/preload.ts +87 -0
- package/src/media/priority.tsx +69 -0
- package/src/site/routes/public-page.tsx +6 -1
- package/src/ui/NearViewport.tsx +83 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.0",
|
|
4
4
|
"description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -219,7 +219,10 @@
|
|
|
219
219
|
"./server/page-seo-save-core": "./src/server/page-seo-save-core.ts",
|
|
220
220
|
"./site/routes/public-page": "./src/site/routes/public-page.tsx",
|
|
221
221
|
"./site/routes/preview-page": "./src/site/routes/preview-page.tsx",
|
|
222
|
-
"./site/routes/section-preview": "./src/site/routes/section-preview.tsx"
|
|
222
|
+
"./site/routes/section-preview": "./src/site/routes/section-preview.tsx",
|
|
223
|
+
"./media/priority": "./src/media/priority.tsx",
|
|
224
|
+
"./media/preload": "./src/media/preload.ts",
|
|
225
|
+
"./ui/NearViewport": "./src/ui/NearViewport.tsx"
|
|
223
226
|
},
|
|
224
227
|
"dependencies": {
|
|
225
228
|
"fflate": "^0.8.3"
|
|
@@ -2,6 +2,7 @@ import { ArrowRight, ChevronDown } from "lucide-react";
|
|
|
2
2
|
import { useT } from "../../i18n";
|
|
3
3
|
import { FocalImage } from "../../host";
|
|
4
4
|
import { LazyVideo } from "../../host";
|
|
5
|
+
import { useClaimPriority } from "../../media/priority";
|
|
5
6
|
import type { HeroFullProps } from "../types";
|
|
6
7
|
|
|
7
8
|
const HEIGHTS: Record<NonNullable<HeroFullProps["height"]>, string> = {
|
|
@@ -56,6 +57,12 @@ export function HeroFullBlock({ props: p }: { props: HeroFullProps }) {
|
|
|
56
57
|
const poster = p.posterSrc;
|
|
57
58
|
const posterMobile = p.posterSrcMobile || poster;
|
|
58
59
|
|
|
60
|
+
// A hero image is a candidate for the page's one priority image, but only the
|
|
61
|
+
// first block to ask wins: an editor can place a hero halfway down a page.
|
|
62
|
+
const heroIsPriority = useClaimPriority(
|
|
63
|
+
mediaType === "image" ? image || imageMobile : null,
|
|
64
|
+
);
|
|
65
|
+
|
|
59
66
|
const alignClass =
|
|
60
67
|
align === "center" ? "items-center text-center" : align === "right" ? "items-end text-right" : "items-start text-left";
|
|
61
68
|
const rowClass =
|
|
@@ -128,25 +135,23 @@ export function HeroFullBlock({ props: p }: { props: HeroFullProps }) {
|
|
|
128
135
|
/>
|
|
129
136
|
</>
|
|
130
137
|
) : image || imageMobile ? (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
/>
|
|
147
|
-
) : null}
|
|
148
|
-
</>
|
|
138
|
+
// One <picture> with one <img>: two CSS-hidden images can both be
|
|
139
|
+
// fetched, so the hidden twin would double the hero's cost. The
|
|
140
|
+
// mobile artwork becomes a media-scoped <source> instead.
|
|
141
|
+
<FocalImage
|
|
142
|
+
src={image || imageMobile}
|
|
143
|
+
artDirected={
|
|
144
|
+
imageMobile && image && imageMobile !== image
|
|
145
|
+
? [{ media: "(max-width: 767px)", src: imageMobile }]
|
|
146
|
+
: undefined
|
|
147
|
+
}
|
|
148
|
+
alt={t(p.imageAlt) || ""}
|
|
149
|
+
sizes="100vw"
|
|
150
|
+
priority={heroIsPriority}
|
|
151
|
+
className="size-full object-cover"
|
|
152
|
+
/>
|
|
149
153
|
) : null}
|
|
154
|
+
|
|
150
155
|
<div className={`absolute inset-0 ${OVERLAYS[p.overlay ?? "medium"]}`} />
|
|
151
156
|
</div>
|
|
152
157
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { lazy, Suspense } from "react";
|
|
1
2
|
import { Instagram as InstagramIcon, ExternalLink } from "lucide-react";
|
|
2
3
|
import { useT } from "../../i18n";
|
|
3
|
-
import type { InstagramProps
|
|
4
|
-
import {
|
|
4
|
+
import type { InstagramProps } from "../types";
|
|
5
|
+
import { NearViewport, ChunkErrorBoundary } from "../../ui/NearViewport";
|
|
6
|
+
import { ASPECT_CLASS } from "./InstagramTiles";
|
|
5
7
|
|
|
6
8
|
const GAP_CLASS: Record<NonNullable<InstagramProps["gap"]>, string> = {
|
|
7
9
|
none: "gap-0",
|
|
@@ -10,61 +12,16 @@ const GAP_CLASS: Record<NonNullable<InstagramProps["gap"]>, string> = {
|
|
|
10
12
|
lg: "gap-5 md:gap-8",
|
|
11
13
|
};
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
};
|
|
15
|
+
// Dynamic import + a near-viewport gate. `lazy` alone would fetch the chunk on
|
|
16
|
+
// first render, which is exactly what we are trying to avoid for a wall of
|
|
17
|
+
// photos that usually sits at the bottom of the page.
|
|
18
|
+
const InstagramTiles = lazy(() => import("./InstagramTiles"));
|
|
18
19
|
|
|
19
20
|
function clamp(n: number | undefined, min: number, max: number, fallback: number) {
|
|
20
21
|
const v = typeof n === "number" && Number.isFinite(n) ? n : fallback;
|
|
21
22
|
return Math.min(max, Math.max(min, v));
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
function Tile({
|
|
25
|
-
item,
|
|
26
|
-
aspect,
|
|
27
|
-
showOverlay,
|
|
28
|
-
}: {
|
|
29
|
-
item: InstagramItem;
|
|
30
|
-
aspect: NonNullable<InstagramProps["aspect"]>;
|
|
31
|
-
showOverlay: boolean;
|
|
32
|
-
}) {
|
|
33
|
-
const { t } = useT();
|
|
34
|
-
const alt = item.alt ? t(item.alt as Parameters<typeof t>[0]) : "";
|
|
35
|
-
const inner = (
|
|
36
|
-
<div className={`relative ${ASPECT_CLASS[aspect]} bg-ink/5 overflow-hidden group`}>
|
|
37
|
-
{item.image ? (
|
|
38
|
-
<FocalImage
|
|
39
|
-
src={item.image}
|
|
40
|
-
responsive={false}
|
|
41
|
-
alt={alt}
|
|
42
|
-
loading="lazy"
|
|
43
|
-
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
44
|
-
/>
|
|
45
|
-
) : (
|
|
46
|
-
<div className="absolute inset-0 grid place-items-center text-ink/25">
|
|
47
|
-
<InstagramIcon className="size-8" />
|
|
48
|
-
</div>
|
|
49
|
-
)}
|
|
50
|
-
{showOverlay && item.image ? (
|
|
51
|
-
<div className="absolute inset-0 grid place-items-center bg-ink/0 group-hover:bg-ink/40 transition-colors">
|
|
52
|
-
<InstagramIcon className="size-6 text-paper opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
53
|
-
</div>
|
|
54
|
-
) : null}
|
|
55
|
-
</div>
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
if (item.href) {
|
|
59
|
-
return (
|
|
60
|
-
<a href={item.href} target="_blank" rel="noreferrer noopener" className="block">
|
|
61
|
-
{inner}
|
|
62
|
-
</a>
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
return inner;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
25
|
export function InstagramBlock({ props }: { props: InstagramProps }) {
|
|
69
26
|
const { t } = useT();
|
|
70
27
|
const layout = props.layout ?? "grid";
|
|
@@ -85,11 +42,27 @@ export function InstagramBlock({ props }: { props: InstagramProps }) {
|
|
|
85
42
|
const profileHref =
|
|
86
43
|
props.profileHref ?? (handle ? `https://instagram.com/${handle}` : undefined);
|
|
87
44
|
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
45
|
+
// Reserve the tile wall's height so nothing moves when the chunk arrives:
|
|
46
|
+
// the same aspect ratio, the same number of rows, the same gaps.
|
|
47
|
+
const reservedRows = layout === "grid" ? rows : 1;
|
|
48
|
+
const placeholder = (
|
|
49
|
+
<div className={`ig-reserve ${gap}`} style={{ display: "grid", gridTemplateColumns: "1fr" }}>
|
|
50
|
+
{Array.from({ length: reservedRows }).map((_, r) => (
|
|
51
|
+
<div key={r} className={`${ASPECT_CLASS[aspect]} w-full bg-ink/5`} />
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const staticFallback = profileHref ? (
|
|
57
|
+
<a
|
|
58
|
+
href={profileHref}
|
|
59
|
+
target="_blank"
|
|
60
|
+
rel="noreferrer noopener"
|
|
61
|
+
className="inline-flex items-center gap-2 text-sm text-ink underline"
|
|
62
|
+
>
|
|
63
|
+
View the photos on Instagram <ExternalLink className="size-3.5" />
|
|
64
|
+
</a>
|
|
65
|
+
) : null;
|
|
93
66
|
|
|
94
67
|
return (
|
|
95
68
|
<section className="bg-white py-20 md:py-28">
|
|
@@ -122,38 +95,28 @@ export function InstagramBlock({ props }: { props: InstagramProps }) {
|
|
|
122
95
|
</div>
|
|
123
96
|
) : null}
|
|
124
97
|
|
|
125
|
-
{
|
|
126
|
-
<
|
|
127
|
-
{
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
98
|
+
{items.length > 0 ? (
|
|
99
|
+
<NearViewport placeholder={placeholder}>
|
|
100
|
+
<ChunkErrorBoundary fallback={staticFallback}>
|
|
101
|
+
<Suspense fallback={placeholder}>
|
|
102
|
+
<InstagramTiles
|
|
103
|
+
items={items}
|
|
104
|
+
layout={layout}
|
|
105
|
+
aspect={aspect}
|
|
106
|
+
showOverlay={showOverlay}
|
|
107
|
+
gap={gap}
|
|
108
|
+
columnsMobile={cM}
|
|
109
|
+
columnsTablet={cT}
|
|
110
|
+
columnsDesktop={cD}
|
|
111
|
+
/>
|
|
112
|
+
</Suspense>
|
|
113
|
+
</ChunkErrorBoundary>
|
|
114
|
+
</NearViewport>
|
|
137
115
|
) : (
|
|
138
|
-
<>
|
|
139
|
-
<style>{`
|
|
140
|
-
.ig-grid { --ig-cols: ${cM}; }
|
|
141
|
-
@media (min-width: 768px) { .ig-grid { --ig-cols: ${cT}; } }
|
|
142
|
-
@media (min-width: 1024px) { .ig-grid { --ig-cols: ${cD}; } }
|
|
143
|
-
`}</style>
|
|
144
|
-
<div className={`ig-grid ${gap}`} style={gridStyle}>
|
|
145
|
-
{items.map((item) => (
|
|
146
|
-
<Tile key={item.id} item={item} aspect={aspect} showOverlay={showOverlay} />
|
|
147
|
-
))}
|
|
148
|
-
</div>
|
|
149
|
-
</>
|
|
150
|
-
)}
|
|
151
|
-
|
|
152
|
-
{items.length === 0 ? (
|
|
153
116
|
<div className="mt-8 border border-dashed border-ink/15 p-10 text-center text-ink/70 text-sm">
|
|
154
117
|
No Instagram posts yet. Add images in the block inspector.
|
|
155
118
|
</div>
|
|
156
|
-
)
|
|
119
|
+
)}
|
|
157
120
|
</div>
|
|
158
121
|
</section>
|
|
159
122
|
);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Instagram grid/carousel itself, in its own chunk.
|
|
3
|
+
*
|
|
4
|
+
* The block's heading, handle and profile link stay in the server-rendered
|
|
5
|
+
* HTML (they are content, and crawlers should see them); the tile wall — dozens
|
|
6
|
+
* of images plus the scroll/grid machinery — is only imported once the reader
|
|
7
|
+
* approaches it. See `InstagramBlock`.
|
|
8
|
+
*/
|
|
9
|
+
import { Instagram as InstagramIcon } from "lucide-react";
|
|
10
|
+
import { useT } from "../../i18n";
|
|
11
|
+
import { FocalImage } from "../../host";
|
|
12
|
+
import type { InstagramProps, InstagramItem } from "../types";
|
|
13
|
+
|
|
14
|
+
export const ASPECT_CLASS: Record<NonNullable<InstagramProps["aspect"]>, string> = {
|
|
15
|
+
square: "aspect-square",
|
|
16
|
+
portrait: "aspect-[4/5]",
|
|
17
|
+
landscape: "aspect-[4/3]",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type InstagramTilesProps = {
|
|
21
|
+
items: InstagramItem[];
|
|
22
|
+
layout: NonNullable<InstagramProps["layout"]>;
|
|
23
|
+
aspect: NonNullable<InstagramProps["aspect"]>;
|
|
24
|
+
showOverlay: boolean;
|
|
25
|
+
gap: string;
|
|
26
|
+
columnsMobile: number;
|
|
27
|
+
columnsTablet: number;
|
|
28
|
+
columnsDesktop: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function Tile({
|
|
32
|
+
item,
|
|
33
|
+
aspect,
|
|
34
|
+
showOverlay,
|
|
35
|
+
sizes,
|
|
36
|
+
}: {
|
|
37
|
+
item: InstagramItem;
|
|
38
|
+
aspect: NonNullable<InstagramProps["aspect"]>;
|
|
39
|
+
showOverlay: boolean;
|
|
40
|
+
sizes: string;
|
|
41
|
+
}) {
|
|
42
|
+
const { t } = useT();
|
|
43
|
+
const alt = item.alt ? t(item.alt as Parameters<typeof t>[0]) : "";
|
|
44
|
+
const inner = (
|
|
45
|
+
<div className={`relative ${ASPECT_CLASS[aspect]} bg-ink/5 overflow-hidden group`}>
|
|
46
|
+
{item.image ? (
|
|
47
|
+
<FocalImage
|
|
48
|
+
src={item.image}
|
|
49
|
+
sizes={sizes}
|
|
50
|
+
alt={alt}
|
|
51
|
+
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
52
|
+
/>
|
|
53
|
+
) : (
|
|
54
|
+
<div className="absolute inset-0 grid place-items-center text-ink/25">
|
|
55
|
+
<InstagramIcon className="size-8" />
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
{showOverlay && item.image ? (
|
|
59
|
+
<div className="absolute inset-0 grid place-items-center bg-ink/0 group-hover:bg-ink/40 transition-colors">
|
|
60
|
+
<InstagramIcon className="size-6 text-paper opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
61
|
+
</div>
|
|
62
|
+
) : null}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
if (item.href) {
|
|
67
|
+
return (
|
|
68
|
+
<a href={item.href} target="_blank" rel="noreferrer noopener" className="block">
|
|
69
|
+
{inner}
|
|
70
|
+
</a>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return inner;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `sizes` for one tile in a column grid: the tile is roughly the container width
|
|
78
|
+
* divided by the column count at that breakpoint, so the browser never fetches
|
|
79
|
+
* a full-width variant for a quarter-width tile.
|
|
80
|
+
*/
|
|
81
|
+
export function tileSizes(cM: number, cT: number, cD: number): string {
|
|
82
|
+
const pct = (cols: number) => `${Math.round(100 / Math.max(1, cols))}vw`;
|
|
83
|
+
return [
|
|
84
|
+
`(max-width: 767px) ${pct(cM)}`,
|
|
85
|
+
`(max-width: 1023px) ${pct(cT)}`,
|
|
86
|
+
`${Math.round(1280 / Math.max(1, cD))}px`,
|
|
87
|
+
].join(", ");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function InstagramTiles({
|
|
91
|
+
items,
|
|
92
|
+
layout,
|
|
93
|
+
aspect,
|
|
94
|
+
showOverlay,
|
|
95
|
+
gap,
|
|
96
|
+
columnsMobile: cM,
|
|
97
|
+
columnsTablet: cT,
|
|
98
|
+
columnsDesktop: cD,
|
|
99
|
+
}: InstagramTilesProps) {
|
|
100
|
+
const sizes = tileSizes(cM, cT, cD);
|
|
101
|
+
const gridStyle = {
|
|
102
|
+
display: "grid",
|
|
103
|
+
gridTemplateColumns: `repeat(var(--ig-cols, ${cM}), minmax(0, 1fr))`,
|
|
104
|
+
} as React.CSSProperties;
|
|
105
|
+
|
|
106
|
+
if (layout === "carousel") {
|
|
107
|
+
return (
|
|
108
|
+
<div className={`flex overflow-x-auto snap-x snap-mandatory ${gap} -mx-6 px-6 pb-2`}>
|
|
109
|
+
{items.map((item) => (
|
|
110
|
+
<div
|
|
111
|
+
key={item.id}
|
|
112
|
+
className="snap-start shrink-0"
|
|
113
|
+
style={{ width: `min(80vw, ${100 / Math.max(1, cD)}%)` }}
|
|
114
|
+
>
|
|
115
|
+
<Tile item={item} aspect={aspect} showOverlay={showOverlay} sizes={sizes} />
|
|
116
|
+
</div>
|
|
117
|
+
))}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<>
|
|
124
|
+
<style>{`
|
|
125
|
+
.ig-grid { --ig-cols: ${cM}; }
|
|
126
|
+
@media (min-width: 768px) { .ig-grid { --ig-cols: ${cT}; } }
|
|
127
|
+
@media (min-width: 1024px) { .ig-grid { --ig-cols: ${cD}; } }
|
|
128
|
+
`}</style>
|
|
129
|
+
<div className={`ig-grid ${gap}`} style={gridStyle}>
|
|
130
|
+
{items.map((item) => (
|
|
131
|
+
<Tile key={item.id} item={item} aspect={aspect} showOverlay={showOverlay} sizes={sizes} />
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
</>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export default InstagramTiles;
|
|
@@ -7,6 +7,7 @@ import { Reveal } from "../../ui/Reveal";
|
|
|
7
7
|
import { Lightbox } from "../../ui/Lightbox";
|
|
8
8
|
import { FocalImage } from "../../host";
|
|
9
9
|
import { LazyVideo } from "../../host";
|
|
10
|
+
import { useClaimPriority } from "../../media/priority";
|
|
10
11
|
import type { MediaSplitProps, MediaSplitSlide } from "../types";
|
|
11
12
|
|
|
12
13
|
|
|
@@ -48,6 +49,9 @@ function Slideshow({
|
|
|
48
49
|
radius: string;
|
|
49
50
|
}) {
|
|
50
51
|
const { t } = useT();
|
|
52
|
+
// The first slide is the only slide visible on load, so it is this block's
|
|
53
|
+
// priority candidate — but only if no earlier block already claimed it.
|
|
54
|
+
const slidePriority = useClaimPriority(slides[0]?.image ?? null);
|
|
51
55
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
52
56
|
const [index, setIndex] = useState(0);
|
|
53
57
|
const indexRef = useRef(0);
|
|
@@ -129,7 +133,7 @@ function Slideshow({
|
|
|
129
133
|
src={s.image}
|
|
130
134
|
sizes="(min-width: 768px) 50vw, 100vw"
|
|
131
135
|
alt={alt}
|
|
132
|
-
|
|
136
|
+
priority={i === 0 && slidePriority}
|
|
133
137
|
className={`absolute inset-0 h-full w-full ${props.mediaFit === "contain" ? "object-contain" : "object-cover"}`}
|
|
134
138
|
/>
|
|
135
139
|
) : (
|
|
@@ -202,6 +206,11 @@ function Slideshow({
|
|
|
202
206
|
* then the rest of the text.
|
|
203
207
|
*/
|
|
204
208
|
export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
|
|
209
|
+
// Claimed before rendering the media so a media-split placed first on a page
|
|
210
|
+
// gets the priority image when there is no hero above it.
|
|
211
|
+
const imagePriority = useClaimPriority(
|
|
212
|
+
props.mediaType === "slideshow" || props.mediaType === "video" ? null : props.image ?? null,
|
|
213
|
+
);
|
|
205
214
|
const { t } = useT();
|
|
206
215
|
const side = props.mediaSide ?? "left";
|
|
207
216
|
const tone = props.tone ?? "white";
|
|
@@ -300,6 +309,7 @@ export function MediaSplitBlock({ props }: { props: MediaSplitProps }) {
|
|
|
300
309
|
src={props.image}
|
|
301
310
|
sizes="(min-width: 768px) 50vw, 100vw"
|
|
302
311
|
alt={props.imageAlt ? t(props.imageAlt as Parameters<typeof t>[0]) : ""}
|
|
312
|
+
priority={imagePriority}
|
|
303
313
|
className={`absolute inset-0 h-full w-full ${fitClass}`}
|
|
304
314
|
/>
|
|
305
315
|
</div>
|
package/src/host/index.tsx
CHANGED
|
@@ -12,11 +12,21 @@ import type { LocalizedString } from "../i18n/types";
|
|
|
12
12
|
* server functions per-site while block markup and inspector UI stay shared.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
export type HostArtDirectedSource = {
|
|
16
|
+
media: string;
|
|
17
|
+
src: string;
|
|
18
|
+
sizes?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
15
21
|
export type HostFocalImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
|
|
16
22
|
src: string | undefined;
|
|
17
23
|
/** When set, the host may build a responsive srcSet. */
|
|
18
24
|
sizes?: string;
|
|
19
25
|
responsive?: boolean;
|
|
26
|
+
/** Breakpoint-specific artwork, so only one file downloads per viewport. */
|
|
27
|
+
artDirected?: HostArtDirectedSource[];
|
|
28
|
+
/** The page's single first-screen image: eager, high fetch priority. */
|
|
29
|
+
priority?: boolean;
|
|
20
30
|
};
|
|
21
31
|
|
|
22
32
|
export type HostLazyVideoProps = {
|
package/src/media/FocalImage.tsx
CHANGED
|
@@ -67,17 +67,42 @@ export function useFocalStyle(src: string | undefined): {
|
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
export type ArtDirectedSource = {
|
|
71
|
+
/** Media query this artwork applies to, e.g. `(max-width: 767px)`. */
|
|
72
|
+
media: string;
|
|
73
|
+
/** The image URL for that breakpoint. */
|
|
74
|
+
src: string;
|
|
75
|
+
/** `sizes` for that breakpoint; defaults to the element's `sizes`. */
|
|
76
|
+
sizes?: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
70
79
|
export type FocalImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
|
|
71
80
|
src: string | undefined;
|
|
72
81
|
/** When set, builds a responsive srcSet from the media library variants. */
|
|
73
82
|
sizes?: string;
|
|
74
83
|
responsive?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Breakpoint-specific artwork. Rendered as `<source media=…>` before the
|
|
86
|
+
* default candidates, so exactly ONE file downloads per viewport — unlike two
|
|
87
|
+
* CSS-hidden `<img>` elements, which can both be fetched.
|
|
88
|
+
*/
|
|
89
|
+
artDirected?: ArtDirectedSource[];
|
|
90
|
+
/**
|
|
91
|
+
* Marks this as the page's largest first-screen image: eager, high fetch
|
|
92
|
+
* priority. At most one image per page should set it — `HeroFullBlock` and
|
|
93
|
+
* other first-screen blocks claim it through `useClaimPriority`.
|
|
94
|
+
*/
|
|
95
|
+
priority?: boolean;
|
|
75
96
|
};
|
|
76
97
|
|
|
77
98
|
/**
|
|
78
99
|
* `<img>` that always crops around the image's editor-chosen focus point.
|
|
79
100
|
* Falls back to a centred crop when no focus point is stored.
|
|
80
101
|
*
|
|
102
|
+
* Loading defaults are safe rather than fast: `loading="lazy"` and
|
|
103
|
+
* `decoding="async"`, so an image nobody asked to prioritize can never compete
|
|
104
|
+
* with the first screen. The one image that should compete says `priority`.
|
|
105
|
+
*
|
|
81
106
|
* When the source is a responsive WebP variant with a `-fallback.png` sibling,
|
|
82
107
|
* renders a `<picture>` so browsers that do not support WebP (or need explicit
|
|
83
108
|
* alpha handling) still receive a transparent PNG.
|
|
@@ -86,8 +111,13 @@ export function FocalImage({
|
|
|
86
111
|
src,
|
|
87
112
|
sizes,
|
|
88
113
|
responsive = true,
|
|
114
|
+
artDirected,
|
|
115
|
+
priority = false,
|
|
89
116
|
style,
|
|
90
117
|
onError,
|
|
118
|
+
loading,
|
|
119
|
+
decoding,
|
|
120
|
+
fetchPriority,
|
|
91
121
|
...rest
|
|
92
122
|
}: FocalImageProps) {
|
|
93
123
|
const focal = useFocalStyle(src);
|
|
@@ -97,10 +127,37 @@ export function FocalImage({
|
|
|
97
127
|
const [srcSetFailed, setSrcSetFailed] = useState(false);
|
|
98
128
|
useEffect(() => setSrcSetFailed(false), [src]);
|
|
99
129
|
|
|
130
|
+
if (process.env['NODE_ENV'] !== "production" && priority) {
|
|
131
|
+
if (loading === "lazy") {
|
|
132
|
+
console.warn(
|
|
133
|
+
"[FocalImage] priority image was also given loading=\"lazy\"; priority wins.",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (fetchPriority && fetchPriority !== "high") {
|
|
137
|
+
console.warn(
|
|
138
|
+
`[FocalImage] priority image was also given fetchPriority="${fetchPriority}"; priority wins.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// `priority` always wins over conflicting props; otherwise the caller decides
|
|
144
|
+
// and, failing that, the safe default applies. `decoding` is never "sync":
|
|
145
|
+
// blocking the main thread on a decode delays paint instead of helping it.
|
|
146
|
+
const loadingAttr = priority ? "eager" : (loading ?? "lazy");
|
|
147
|
+
const fetchPriorityAttr = priority ? "high" : fetchPriority;
|
|
148
|
+
const decodingAttr = decoding === "sync" ? "async" : (decoding ?? "async");
|
|
149
|
+
|
|
100
150
|
const img = responsive && !srcSetFailed ? responsiveImage(src, sizes) : { src: src ?? "" };
|
|
101
151
|
const merged: CSSProperties = { ...focal.style, ...style };
|
|
102
152
|
const className = [focal.className, rest.className].filter(Boolean).join(" ");
|
|
103
153
|
|
|
154
|
+
const imgProps = {
|
|
155
|
+
...rest,
|
|
156
|
+
loading: loadingAttr,
|
|
157
|
+
decoding: decodingAttr,
|
|
158
|
+
fetchPriority: fetchPriorityAttr,
|
|
159
|
+
};
|
|
160
|
+
|
|
104
161
|
// Only wrap in <picture> when a fallback PNG is advertised. The fallback URL
|
|
105
162
|
// is always derived, but it only exists for transparent uploads; using it for
|
|
106
163
|
// every image would cause unnecessary 404s on opaque assets.
|
|
@@ -112,17 +169,33 @@ export function FocalImage({
|
|
|
112
169
|
img.type === "image/webp" &&
|
|
113
170
|
img.srcSet;
|
|
114
171
|
|
|
115
|
-
|
|
172
|
+
const artSources =
|
|
173
|
+
responsive && !srcSetFailed
|
|
174
|
+
? (artDirected ?? [])
|
|
175
|
+
.map((a) => ({ media: a.media, img: responsiveImage(a.src, a.sizes ?? sizes) }))
|
|
176
|
+
.filter((a) => a.img.src)
|
|
177
|
+
: [];
|
|
178
|
+
|
|
179
|
+
if (artSources.length || (hasFallback && "fallbackSrc" in img)) {
|
|
116
180
|
// The <picture> is only a source selector: it must not take the layout or
|
|
117
181
|
// focus classes, or the inner <img> loses `object-position` and the crop
|
|
118
182
|
// ignores the editor-chosen focus point.
|
|
119
183
|
return (
|
|
120
184
|
<picture style={{ display: "contents" }}>
|
|
121
|
-
|
|
185
|
+
{artSources.map((a) => (
|
|
186
|
+
<source
|
|
187
|
+
key={a.media}
|
|
188
|
+
media={a.media}
|
|
189
|
+
srcSet={a.img.srcSet ?? a.img.src}
|
|
190
|
+
sizes={a.img.srcSet ? a.img.sizes : undefined}
|
|
191
|
+
type={a.img.srcSet ? "image/webp" : undefined}
|
|
192
|
+
/>
|
|
193
|
+
))}
|
|
194
|
+
{img.srcSet ? <source srcSet={img.srcSet} sizes={img.sizes} type="image/webp" /> : null}
|
|
122
195
|
<img
|
|
123
|
-
{...
|
|
196
|
+
{...imgProps}
|
|
124
197
|
className={className}
|
|
125
|
-
src={img.fallbackSrc}
|
|
198
|
+
src={hasFallback && "fallbackSrc" in img ? img.fallbackSrc : img.src}
|
|
126
199
|
alt={rest.alt}
|
|
127
200
|
style={merged}
|
|
128
201
|
onError={(event) => {
|
|
@@ -136,7 +209,7 @@ export function FocalImage({
|
|
|
136
209
|
|
|
137
210
|
return (
|
|
138
211
|
<img
|
|
139
|
-
{...
|
|
212
|
+
{...imgProps}
|
|
140
213
|
className={className}
|
|
141
214
|
src={img.src}
|
|
142
215
|
srcSet={"srcSet" in img ? img.srcSet : undefined}
|
|
@@ -150,3 +223,4 @@ export function FocalImage({
|
|
|
150
223
|
/>
|
|
151
224
|
);
|
|
152
225
|
}
|
|
226
|
+
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preload attributes for a page's one priority image.
|
|
3
|
+
*
|
|
4
|
+
* A preload only helps when the browser would otherwise discover the image
|
|
5
|
+
* late: a CSS background, an image inserted after hydration, or artwork behind
|
|
6
|
+
* a client-only component. When the image is already an `<img>` in the
|
|
7
|
+
* server-rendered HTML, `fetchpriority="high"` on that element is enough — a
|
|
8
|
+
* preload adds a second candidate list that can disagree with the rendered one
|
|
9
|
+
* and fetch a second file. `heroPreloadLink` therefore refuses to build a link
|
|
10
|
+
* unless the caller states the image is discovered late.
|
|
11
|
+
*
|
|
12
|
+
* The attributes are derived from exactly the same `responsiveImage()` output
|
|
13
|
+
* the rendered element uses, so the browser cannot pick a different candidate.
|
|
14
|
+
*/
|
|
15
|
+
import { responsiveImage } from "./srcset";
|
|
16
|
+
|
|
17
|
+
export type PreloadLink = {
|
|
18
|
+
rel: "preload";
|
|
19
|
+
as: "image";
|
|
20
|
+
href: string;
|
|
21
|
+
imageSrcSet?: string;
|
|
22
|
+
imageSizes?: string;
|
|
23
|
+
media?: string;
|
|
24
|
+
fetchPriority: "high";
|
|
25
|
+
crossOrigin?: "anonymous";
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type HeroPreloadInput = {
|
|
29
|
+
/** The image URL the page will render. */
|
|
30
|
+
src: string | undefined | null;
|
|
31
|
+
/** The exact `sizes` the rendered element uses. */
|
|
32
|
+
sizes?: string;
|
|
33
|
+
/** Media query, when the artwork is art-directed per breakpoint. */
|
|
34
|
+
media?: string;
|
|
35
|
+
/**
|
|
36
|
+
* True only when the image is NOT an `<img>`/`<picture>` in the initial HTML
|
|
37
|
+
* (CSS background, client-only component, post-hydration insert).
|
|
38
|
+
*/
|
|
39
|
+
discoveredLate: boolean;
|
|
40
|
+
/** Set when the asset is served from another origin with CORS. */
|
|
41
|
+
crossOrigin?: "anonymous";
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The preload link for a late-discovered hero, or null when preloading would
|
|
46
|
+
* be redundant (already in the HTML) or impossible (no image).
|
|
47
|
+
*/
|
|
48
|
+
export function heroPreloadLink(input: HeroPreloadInput): PreloadLink | null {
|
|
49
|
+
if (!input.src || !input.discoveredLate) return null;
|
|
50
|
+
const img = responsiveImage(input.src, input.sizes);
|
|
51
|
+
if (!img.src) return null;
|
|
52
|
+
const link: PreloadLink = {
|
|
53
|
+
rel: "preload",
|
|
54
|
+
as: "image",
|
|
55
|
+
href: img.src,
|
|
56
|
+
fetchPriority: "high",
|
|
57
|
+
};
|
|
58
|
+
if (img.srcSet) {
|
|
59
|
+
link.imageSrcSet = img.srcSet;
|
|
60
|
+
link.imageSizes = img.sizes;
|
|
61
|
+
}
|
|
62
|
+
if (input.media) link.media = input.media;
|
|
63
|
+
if (input.crossOrigin) link.crossOrigin = input.crossOrigin;
|
|
64
|
+
return link;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The same link as the `head().links` shape TanStack routes expect: React DOM
|
|
69
|
+
* spells these attributes camelCase, the HTML attributes are lowercase.
|
|
70
|
+
*/
|
|
71
|
+
export function heroPreloadHeadLink(
|
|
72
|
+
input: HeroPreloadInput,
|
|
73
|
+
): Record<string, string> | null {
|
|
74
|
+
const link = heroPreloadLink(input);
|
|
75
|
+
if (!link) return null;
|
|
76
|
+
const out: Record<string, string> = {
|
|
77
|
+
rel: link.rel,
|
|
78
|
+
as: link.as,
|
|
79
|
+
href: link.href,
|
|
80
|
+
fetchpriority: link.fetchPriority,
|
|
81
|
+
};
|
|
82
|
+
if (link.imageSrcSet) out.imagesrcset = link.imageSrcSet;
|
|
83
|
+
if (link.imageSizes) out.imagesizes = link.imageSizes;
|
|
84
|
+
if (link.media) out.media = link.media;
|
|
85
|
+
if (link.crossOrigin) out.crossorigin = link.crossOrigin;
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exactly one priority image per page.
|
|
3
|
+
*
|
|
4
|
+
* The largest image on the first screen should download eagerly at high fetch
|
|
5
|
+
* priority; every other image on the page should not. Which image that is
|
|
6
|
+
* depends on what the editor put first, so it cannot be hardcoded to one block
|
|
7
|
+
* type — and it must be decided while rendering (server included), never after
|
|
8
|
+
* hydration by measuring the viewport, or the decision arrives too late to
|
|
9
|
+
* matter.
|
|
10
|
+
*
|
|
11
|
+
* A page wraps its rendered blocks in `PriorityMediaProvider`. The first image
|
|
12
|
+
* that asks (`useClaimPriority`) during the first render pass wins, and every
|
|
13
|
+
* later asker is told no. Render order is deterministic and identical on the
|
|
14
|
+
* server and the client, so the same image wins in both.
|
|
15
|
+
*/
|
|
16
|
+
import { createContext, useContext, useRef, type ReactNode } from "react";
|
|
17
|
+
|
|
18
|
+
type Claim = {
|
|
19
|
+
/** The winning claim key, or null while nobody has claimed yet. */
|
|
20
|
+
claimed: string | null;
|
|
21
|
+
/** Whether this page allows a priority image at all. */
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const PriorityContext = createContext<{ ref: { current: Claim } } | null>(null);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Scopes the single priority claim to one page render.
|
|
29
|
+
*
|
|
30
|
+
* `enabled` exists for renders where no image should be prioritized at all —
|
|
31
|
+
* the block library preview, an admin thumbnail grid, an email render.
|
|
32
|
+
*/
|
|
33
|
+
export function PriorityMediaProvider({
|
|
34
|
+
enabled = true,
|
|
35
|
+
children,
|
|
36
|
+
}: {
|
|
37
|
+
enabled?: boolean;
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
}) {
|
|
40
|
+
const ref = useRef<Claim>({ claimed: null, enabled });
|
|
41
|
+
ref.current.enabled = enabled;
|
|
42
|
+
return <PriorityContext.Provider value={{ ref }}>{children}</PriorityContext.Provider>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Ask to be the page's priority image.
|
|
47
|
+
*
|
|
48
|
+
* `key` is normally the image URL. Asking twice with the same key (a component
|
|
49
|
+
* that re-renders) keeps the claim rather than losing it to a later image.
|
|
50
|
+
* Outside a provider nothing is prioritized, which is the safe answer for
|
|
51
|
+
* previews and standalone component renders.
|
|
52
|
+
*/
|
|
53
|
+
export function useClaimPriority(key: string | undefined | null): boolean {
|
|
54
|
+
const ctx = useContext(PriorityContext);
|
|
55
|
+
if (!ctx || !key) return false;
|
|
56
|
+
const state = ctx.ref.current;
|
|
57
|
+
if (!state.enabled) return false;
|
|
58
|
+
if (state.claimed === null) {
|
|
59
|
+
state.claimed = key;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return state.claimed === key;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The claim that won, for tests and for deriving a matching preload link. */
|
|
66
|
+
export function usePriorityClaim(): string | null {
|
|
67
|
+
const ctx = useContext(PriorityContext);
|
|
68
|
+
return ctx ? ctx.ref.current.claimed : null;
|
|
69
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { notFound, redirect } from "@tanstack/react-router";
|
|
10
10
|
import type { ComponentType, ReactElement, ReactNode } from "react";
|
|
11
|
+
import { PriorityMediaProvider } from "../../media/priority";
|
|
11
12
|
|
|
12
13
|
export type ResolvedPath =
|
|
13
14
|
| { kind: "page"; pageKey: string }
|
|
@@ -89,10 +90,14 @@ export function createPublicPageRoute<Ctx>(deps: PublicPageRouteDeps<Ctx>): Publ
|
|
|
89
90
|
return deps.buildPageHead(loaderData.pageKey, loaderData.ctx);
|
|
90
91
|
},
|
|
91
92
|
|
|
93
|
+
// Only the page content may hold the priority image: chrome (header logo,
|
|
94
|
+
// footer marks) is small, and one page gets exactly one priority claim.
|
|
92
95
|
PublicPage: ({ pageKey }) => (
|
|
93
96
|
<Shell>
|
|
94
97
|
<div className={deps.contentClassName ?? "pt-24 md:pt-32"}>
|
|
95
|
-
<
|
|
98
|
+
<PriorityMediaProvider>
|
|
99
|
+
<deps.PageRenderer pageKey={pageKey} />
|
|
100
|
+
</PriorityMediaProvider>
|
|
96
101
|
</div>
|
|
97
102
|
</Shell>
|
|
98
103
|
),
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders its children only once the placeholder is near the viewport.
|
|
3
|
+
*
|
|
4
|
+
* `React.lazy` on its own does not defer anything: if the lazy element is part
|
|
5
|
+
* of the first render, its chunk is requested immediately. Heavy integrations
|
|
6
|
+
* (embeds, interactive maps, third-party scripts) therefore need a second gate
|
|
7
|
+
* — this one — so the import starts when the reader is actually approaching.
|
|
8
|
+
*
|
|
9
|
+
* The placeholder keeps the block's height, so nothing shifts when the real
|
|
10
|
+
* content arrives. Without IntersectionObserver (old browsers, SSR) the gate
|
|
11
|
+
* opens on mount rather than never.
|
|
12
|
+
*/
|
|
13
|
+
import { Component, useEffect, useRef, useState, type ReactNode } from "react";
|
|
14
|
+
|
|
15
|
+
export type NearViewportProps = {
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
/** Space reserved before loading; keep it equal to the loaded height. */
|
|
18
|
+
placeholder?: ReactNode;
|
|
19
|
+
/** How early to start loading. */
|
|
20
|
+
rootMargin?: string;
|
|
21
|
+
className?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function NearViewport({
|
|
25
|
+
children,
|
|
26
|
+
placeholder = null,
|
|
27
|
+
rootMargin = "800px 0px",
|
|
28
|
+
className,
|
|
29
|
+
}: NearViewportProps) {
|
|
30
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
31
|
+
const [near, setNear] = useState(false);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (near) return;
|
|
35
|
+
const node = ref.current;
|
|
36
|
+
if (!node) return;
|
|
37
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
38
|
+
setNear(true);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const io = new IntersectionObserver(
|
|
42
|
+
(entries) => {
|
|
43
|
+
if (entries.some((e) => e.isIntersecting)) {
|
|
44
|
+
setNear(true);
|
|
45
|
+
io.disconnect();
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
{ rootMargin },
|
|
49
|
+
);
|
|
50
|
+
io.observe(node);
|
|
51
|
+
return () => io.disconnect();
|
|
52
|
+
}, [near, rootMargin]);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div ref={ref} className={className}>
|
|
56
|
+
{near ? children : placeholder}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Keeps one failed chunk load from blanking the page.
|
|
63
|
+
*
|
|
64
|
+
* A deferred chunk is fetched long after the document loaded, so a flaky
|
|
65
|
+
* network or a deploy that replaced the file is a normal outcome, not a bug.
|
|
66
|
+
* The fallback keeps the reserved space and whatever static alternative the
|
|
67
|
+
* block passed in.
|
|
68
|
+
*/
|
|
69
|
+
export class ChunkErrorBoundary extends Component<
|
|
70
|
+
{ children: ReactNode; fallback?: ReactNode },
|
|
71
|
+
{ failed: boolean }
|
|
72
|
+
> {
|
|
73
|
+
state = { failed: false };
|
|
74
|
+
|
|
75
|
+
static getDerivedStateFromError() {
|
|
76
|
+
return { failed: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
render() {
|
|
80
|
+
if (this.state.failed) return this.props.fallback ?? null;
|
|
81
|
+
return this.props.children;
|
|
82
|
+
}
|
|
83
|
+
}
|