@crtobiasdelsud/portal-ui 1.1.2 → 1.1.3
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": "@crtobiasdelsud/portal-ui",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "Componentes compartidos entre el portal (Next) y el CMS (Vite) — widgets, views, providers para adapters y article pool.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -57,7 +57,9 @@ function loadTwitterWidgets() {
|
|
|
57
57
|
|
|
58
58
|
function EmbedIframe({ src, service, style }) {
|
|
59
59
|
const safeSrc = sanitizeResourceUrl(src)
|
|
60
|
-
const
|
|
60
|
+
const wrapRef = useRef(null) // placeholder observado por IO
|
|
61
|
+
const contentRef = useRef(null) // contenedor real del tweet/iframe (post-inView)
|
|
62
|
+
const [inView, setInView] = useState(false)
|
|
61
63
|
const [autoHeight, setAutoHeight] = useState(null)
|
|
62
64
|
const [twitterDone, setTwitterDone] = useState(false)
|
|
63
65
|
if (!safeSrc) return null
|
|
@@ -68,10 +70,32 @@ function EmbedIframe({ src, service, style }) {
|
|
|
68
70
|
// solo → altura exacta, sin blanco ni scroll.
|
|
69
71
|
const tweetId = service === 'twitter' ? (String(src).match(/[?&]id=(\d+)/)?.[1] || null) : null
|
|
70
72
|
|
|
73
|
+
// Lazy load: difiere TODO (iframe src, widgets.js de Twitter, listener de
|
|
74
|
+
// Instagram MEASURE) hasta que el embed esté cerca del viewport. Sin esto,
|
|
75
|
+
// tweets / instagram / youtube debajo del fold cargan junto con la nota y
|
|
76
|
+
// pegan al LCP. El rootMargin de 400px precarga antes que el usuario llegue.
|
|
71
77
|
useEffect(() => {
|
|
72
|
-
if (
|
|
78
|
+
if (inView) return
|
|
79
|
+
if (typeof IntersectionObserver === 'undefined') { setInView(true); return }
|
|
80
|
+
const el = wrapRef.current
|
|
81
|
+
if (!el) return
|
|
82
|
+
const io = new IntersectionObserver(
|
|
83
|
+
(entries) => {
|
|
84
|
+
if (entries.some(e => e.isIntersecting)) {
|
|
85
|
+
setInView(true)
|
|
86
|
+
io.disconnect()
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
{ rootMargin: '400px 0px' },
|
|
90
|
+
)
|
|
91
|
+
io.observe(el)
|
|
92
|
+
return () => io.disconnect()
|
|
93
|
+
}, [inView])
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!inView || !tweetId) return
|
|
73
97
|
let cancelled = false
|
|
74
|
-
const el =
|
|
98
|
+
const el = contentRef.current
|
|
75
99
|
if (!el) return
|
|
76
100
|
loadTwitterWidgets().then((twttr) => {
|
|
77
101
|
if (cancelled || !twttr?.widgets || !el) return
|
|
@@ -81,16 +105,16 @@ function EmbedIframe({ src, service, style }) {
|
|
|
81
105
|
.then(() => { if (!cancelled) setTwitterDone(true) })
|
|
82
106
|
})
|
|
83
107
|
return () => { cancelled = true }
|
|
84
|
-
}, [tweetId])
|
|
108
|
+
}, [inView, tweetId])
|
|
85
109
|
|
|
86
110
|
useEffect(() => {
|
|
87
111
|
// Servicios que se sabe que postean MEASURE: Instagram. (Twitter se maneja
|
|
88
112
|
// arriba con widgets.js. TikTok no postea altura.)
|
|
89
|
-
if (service !== 'instagram') return
|
|
113
|
+
if (!inView || service !== 'instagram') return
|
|
90
114
|
|
|
91
115
|
function handleMessage(event) {
|
|
92
116
|
if (typeof event.origin !== 'string' || !event.origin.includes('instagram.com')) return
|
|
93
|
-
if (event.source !==
|
|
117
|
+
if (event.source !== contentRef.current?.contentWindow) return
|
|
94
118
|
|
|
95
119
|
let data = event.data
|
|
96
120
|
if (typeof data === 'string') {
|
|
@@ -102,18 +126,24 @@ function EmbedIframe({ src, service, style }) {
|
|
|
102
126
|
|
|
103
127
|
window.addEventListener('message', handleMessage)
|
|
104
128
|
return () => window.removeEventListener('message', handleMessage)
|
|
105
|
-
}, [service])
|
|
129
|
+
}, [inView, service])
|
|
130
|
+
|
|
131
|
+
// Placeholder antes de inView: ocupa el lugar (height del callsite) pero no
|
|
132
|
+
// dispara ningún fetch. Reserva el espacio → no genera CLS al "hidratar".
|
|
133
|
+
if (!inView) {
|
|
134
|
+
return <div ref={wrapRef} style={style} aria-hidden="true" />
|
|
135
|
+
}
|
|
106
136
|
|
|
107
137
|
// Twitter via widget: contenedor que crece con el tweet. `minHeight` es solo
|
|
108
138
|
// placeholder anti-CLS hasta que widgets.js mida; después el widget fija su
|
|
109
139
|
// propia altura exacta. Si no hubo tweetId (URL rara), cae al iframe normal.
|
|
110
140
|
if (tweetId) {
|
|
111
141
|
const { height, ...rest } = style || {}
|
|
112
|
-
return <div ref={
|
|
142
|
+
return <div ref={contentRef} style={{ ...rest, minHeight: twitterDone ? undefined : height }} />
|
|
113
143
|
}
|
|
114
144
|
|
|
115
145
|
const finalStyle = autoHeight != null ? { ...style, height: autoHeight } : style
|
|
116
|
-
return <iframe ref={
|
|
146
|
+
return <iframe ref={contentRef} src={safeSrc} style={finalStyle} allowFullScreen />
|
|
117
147
|
}
|
|
118
148
|
|
|
119
149
|
// Fixed class names for AMP (no CSS Modules hashing)
|
|
@@ -38,16 +38,39 @@ function resolveEmbedSrc({ service, embed, source }) {
|
|
|
38
38
|
|
|
39
39
|
function EmbedIframe({ src, service, style }) {
|
|
40
40
|
const safeSrc = sanitizeResourceUrl(src)
|
|
41
|
-
const
|
|
41
|
+
const wrapRef = useRef(null) // placeholder observado por IO
|
|
42
|
+
const contentRef = useRef(null) // iframe real (post-inView)
|
|
43
|
+
const [inView, setInView] = useState(false)
|
|
42
44
|
const [autoHeight, setAutoHeight] = useState(null)
|
|
43
45
|
if (!safeSrc) return null
|
|
44
46
|
|
|
47
|
+
// Lazy load: difiere el iframe src y el listener de Instagram hasta que el
|
|
48
|
+
// embed esté cerca del viewport (400px de margen para precargar). Sin esto
|
|
49
|
+
// los embeds abajo del fold cargan junto con la nota y suben el LCP.
|
|
45
50
|
useEffect(() => {
|
|
46
|
-
if (
|
|
51
|
+
if (inView) return
|
|
52
|
+
if (typeof IntersectionObserver === 'undefined') { setInView(true); return }
|
|
53
|
+
const el = wrapRef.current
|
|
54
|
+
if (!el) return
|
|
55
|
+
const io = new IntersectionObserver(
|
|
56
|
+
(entries) => {
|
|
57
|
+
if (entries.some(e => e.isIntersecting)) {
|
|
58
|
+
setInView(true)
|
|
59
|
+
io.disconnect()
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{ rootMargin: '400px 0px' },
|
|
63
|
+
)
|
|
64
|
+
io.observe(el)
|
|
65
|
+
return () => io.disconnect()
|
|
66
|
+
}, [inView])
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!inView || service !== 'instagram') return
|
|
47
70
|
|
|
48
71
|
function handleMessage(event) {
|
|
49
72
|
if (typeof event.origin !== 'string' || !event.origin.includes('instagram.com')) return
|
|
50
|
-
if (event.source !==
|
|
73
|
+
if (event.source !== contentRef.current?.contentWindow) return
|
|
51
74
|
|
|
52
75
|
let data = event.data
|
|
53
76
|
if (typeof data === 'string') {
|
|
@@ -59,10 +82,15 @@ function EmbedIframe({ src, service, style }) {
|
|
|
59
82
|
|
|
60
83
|
window.addEventListener('message', handleMessage)
|
|
61
84
|
return () => window.removeEventListener('message', handleMessage)
|
|
62
|
-
}, [service])
|
|
85
|
+
}, [inView, service])
|
|
86
|
+
|
|
87
|
+
// Placeholder antes de inView: ocupa el lugar pero no dispara fetch del iframe.
|
|
88
|
+
if (!inView) {
|
|
89
|
+
return <div ref={wrapRef} style={style} aria-hidden="true" />
|
|
90
|
+
}
|
|
63
91
|
|
|
64
92
|
const finalStyle = autoHeight != null ? { ...style, height: autoHeight } : style
|
|
65
|
-
return <iframe ref={
|
|
93
|
+
return <iframe ref={contentRef} src={safeSrc} style={finalStyle} allowFullScreen />
|
|
66
94
|
}
|
|
67
95
|
|
|
68
96
|
// Fixed class names for AMP (no CSS Modules hashing)
|
package/src/utils/volanta.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
// Helper compartido por cabezales, hero, bajada, etc.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Helper compartido por cabezales, hero, bajada, etc. Normaliza la volanta
|
|
2
|
+
// para que el render con el título siempre sea: «volanta + punto + espacio + titulo».
|
|
3
|
+
// El espacio entre volanta y titulo lo provee el callsite (literal " " en JSX,
|
|
4
|
+
// o `margin-right` en CSS).
|
|
5
|
+
//
|
|
6
|
+
// Normalización:
|
|
7
|
+
// 1. Quita whitespace trailing ("postgres " → "postgres").
|
|
8
|
+
// 2. Colapsa whitespace entre la palabra y un signo final ("postgres ." → "postgres.").
|
|
9
|
+
// 3. Si no termina en signo de cierre (.!?…), agrega ".".
|
|
10
|
+
//
|
|
11
|
+
// Resultado: "Política." / "Política." (sin doble) / "EXCLUSIVO!" / "" .
|
|
6
12
|
|
|
7
|
-
const SENTENCE_END = /[.!?…]
|
|
13
|
+
const SENTENCE_END = /[.!?…]$/
|
|
8
14
|
|
|
9
15
|
export function volantaWithStop(volanta) {
|
|
10
16
|
if (!volanta) return ''
|
|
11
|
-
|
|
17
|
+
// Colapsa "palabra <space> . <space>" → "palabra.", y limpia trailing.
|
|
18
|
+
const clean = volanta.replace(/\s+([.!?…])\s*$/, '$1').trimEnd()
|
|
19
|
+
if (!clean) return ''
|
|
20
|
+
return SENTENCE_END.test(clean) ? clean : `${clean}.`
|
|
12
21
|
}
|