@crtobiasdelsud/portal-ui 1.0.2 → 1.0.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.0.2",
3
+ "version": "1.0.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",
@@ -14,6 +14,11 @@ export default function BlockColumns({ widgets, registry, settings = {} }) {
14
14
  ? style.gridCategoriaDos
15
15
  : hasImportant ? style.gridFeatured : style.gridNormal
16
16
 
17
+ // Hero "fantasma": cuando hay un hero importante se agrega un Hero extra,
18
+ // sin articleId (artículo random), solo para ocupar el espacio del centro
19
+ // de la fila de abajo.
20
+ const HeroWidget = registry["HERO_BLOCK"]
21
+
17
22
  return (
18
23
  <section className={style.container}>
19
24
  <div className={`${style.grid} ${gridClass}`}>
@@ -31,6 +36,13 @@ export default function BlockColumns({ widgets, registry, settings = {} }) {
31
36
  </div>
32
37
  )
33
38
  })}
39
+ {hasImportant && HeroWidget && (
40
+ <div className={style.item} style={{ gridArea: "fantasma" }}>
41
+ <WidgetErrorBoundary>
42
+ <HeroWidget settings={{}} />
43
+ </WidgetErrorBoundary>
44
+ </div>
45
+ )}
34
46
  </div>
35
47
  </section>
36
48
  )
@@ -26,15 +26,20 @@
26
26
  }
27
27
  }
28
28
 
29
+ /* Hero "importante": hero grande arriba a todo el ancho; abajo
30
+ widget1 + hero fantasma (automático) + widget2. */
29
31
  .gridFeatured {
30
32
  grid-template-areas:
31
33
  "hero"
32
34
  "recommended"
35
+ "fantasma"
33
36
  "feed";
34
37
 
35
38
  @media (min-width: 1000px) {
36
39
  grid-template-columns: repeat(4, 1fr);
37
- grid-template-areas: "recommended hero hero feed";
40
+ grid-template-areas:
41
+ "hero hero hero hero"
42
+ "recommended fantasma fantasma feed";
38
43
  }
39
44
  }
40
45
 
@@ -121,6 +121,13 @@
121
121
  aspect-ratio: 16 / 7;
122
122
  }
123
123
 
124
+ /* La imagen del hero importante (AspectImage) a todo el ancho queda
125
+ altísima (~774px). La topamos para que sea un banner ancho y entre
126
+ en pantalla. En mobile la imagen es chica y este tope nunca se activa. */
127
+ .media {
128
+ max-height: 400px;
129
+ }
130
+
124
131
  .headline {
125
132
  font-size: 1.6rem;
126
133
 
@@ -30,6 +30,7 @@ export default function HeroView({ article, important = false }) {
30
30
  alt={article.imagen.alt ?? ''}
31
31
  aspect="16:9"
32
32
  focalPoint={article.focalPoint}
33
+ className={styles.media}
33
34
  />
34
35
  )}
35
36
  <div className={styles.body}>
@@ -35,11 +35,35 @@
35
35
  .icons {
36
36
  display: flex;
37
37
  align-items: center;
38
- gap: 10px;
38
+ gap: 6px;
39
39
 
40
40
  @include respond(desktop) {
41
41
  gap: 12px;
42
42
  }
43
+
44
+ // Non-AMP: el componente Icon renderiza <a><span aria-hidden/></a>.
45
+ // Achicamos en mobile y restauramos el tamaño completo en desktop.
46
+ a {
47
+ width: 26px;
48
+ height: 26px;
49
+ }
50
+
51
+ span[aria-hidden] {
52
+ width: 22px;
53
+ height: 22px;
54
+ }
55
+
56
+ @include respond(desktop) {
57
+ a {
58
+ width: 33px;
59
+ height: 33px;
60
+ }
61
+
62
+ span[aria-hidden] {
63
+ width: 32px;
64
+ height: 32px;
65
+ }
66
+ }
43
67
  }
44
68
 
45
69
  .iconLink {
@@ -7,7 +7,7 @@ import styles from './SpeechPlayerBar.module.scss'
7
7
 
8
8
  export default function SpeechPlayerBar() {
9
9
  const [mounted, setMounted] = useState(false)
10
- const { playing, paused, progress, meta, pause, resume, stop, skip, seekTo } = useSpeech()
10
+ const { playing, paused, progress, meta, pause, resume, stop, seekTo } = useSpeech()
11
11
 
12
12
  useEffect(() => { setMounted(true) }, [])
13
13
 
@@ -22,36 +22,8 @@ export default function SpeechPlayerBar() {
22
22
  return createPortal(
23
23
  <div className={styles.bar} role="region" aria-label="Reproductor de nota">
24
24
 
25
- <button className={styles.closeBtn} onClick={stop} aria-label="Detener lectura">✕</button>
26
-
27
- {/* Thumbnail */}
28
- <div className={styles.thumb}>
29
- {meta.imagen
30
- ? <img src={meta.imagen} alt="" className={styles.thumbImg} />
31
- : <span className={styles.thumbIcon}>🔊</span>
32
- }
33
- </div>
34
-
35
- {/* Info */}
36
- <div className={styles.info}>
37
- <span className={styles.titulo}>{meta.titulo}</span>
38
- <span className={styles.subtitle}>{playing ? 'Escuchando nota...' : 'En pausa'}</span>
39
- </div>
40
-
41
- {/* Equalizer */}
42
- <div className={`${styles.eq} ${!playing ? styles.eqPaused : ''}`} aria-hidden="true">
43
- <span className={styles.eqBar} />
44
- <span className={styles.eqBar} />
45
- <span className={styles.eqBar} />
46
- <span className={styles.eqBar} />
47
- </div>
48
-
49
- {/* Skip back */}
50
- <button className={styles.skipBtn} onClick={() => skip(-1)} aria-label="Retroceder">
51
- <svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
52
- <path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/>
53
- </svg>
54
- </button>
25
+ {/* Título */}
26
+ <span className={styles.titulo}>{meta.titulo}</span>
55
27
 
56
28
  {/* Play / Pause */}
57
29
  <button
@@ -60,16 +32,15 @@ export default function SpeechPlayerBar() {
60
32
  aria-label={playing ? 'Pausar' : 'Continuar'}
61
33
  >
62
34
  {playing
63
- ? <svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
64
- : <svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M8 5v14l11-7z"/></svg>
35
+ ? <svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
36
+ : <svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M8 5v14l11-7z"/></svg>
65
37
  }
66
38
  </button>
67
39
 
68
- {/* Skip forward */}
69
- <button className={styles.skipBtn} onClick={() => skip(1)} aria-label="Adelantar">
70
- <svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
71
- <path d="M6 18l8.5-6L6 6v12zm2.5-6L15 6v12z" style={{display:'none'}}/>
72
- <path d="M16 6h-2v12h2zm-3.5 6L4 6v12z"/>
40
+ {/* Cerrar */}
41
+ <button className={styles.closeBtn} onClick={stop} aria-label="Detener lectura">
42
+ <svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14">
43
+ <path d="M18.3 5.7 12 12l6.3 6.3-1.4 1.4L10.6 13.4 4.3 19.7 2.9 18.3 9.2 12 2.9 5.7 4.3 4.3l6.3 6.3 6.3-6.3z"/>
73
44
  </svg>
74
45
  </button>
75
46
 
@@ -1,174 +1,85 @@
1
1
  @use "../../styles/index.scss" as *;
2
2
 
3
- // ── Mini player bar ───────────────────────────────────────────────────────────
3
+ // ── Mini player bar (minimalista) ─────────────────────────────────────────────
4
4
 
5
5
  .bar {
6
6
  position: fixed;
7
7
  bottom: 20px;
8
8
  left: 50%;
9
9
  transform: translateX(-50%);
10
- width: calc(100% - 32px);
11
- max-width: 500px;
10
+ width: max-content;
11
+ max-width: calc(100% - 32px);
12
12
  background: var(--secondary-color, #0D1333);
13
- border-radius: 16px;
14
- padding: 10px 12px 18px; // extra bottom padding para la barra de progreso
13
+ border-radius: 14px;
14
+ padding: 7px 10px 7px 16px;
15
15
  display: flex;
16
16
  align-items: center;
17
- gap: 10px;
17
+ gap: 6px;
18
18
  z-index: 9999;
19
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
20
-
21
- @include respond(desktop) {
22
- padding: 0px;
23
- margin: 0px;
24
- }
19
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
25
20
  }
26
21
 
27
- // ── Botón cerrar ──────────────────────────────────────────────────────────────
28
-
29
- .closeBtn {
30
- position: absolute;
31
- top: 6px;
32
- right: 8px;
33
- background: none;
34
- border: none;
35
- color: rgba(255, 255, 255, 0.4);
36
- font-size: 0.7rem;
37
- cursor: pointer;
38
- padding: 2px 4px;
39
- line-height: 1;
40
-
41
- &:hover { color: rgba(255, 255, 255, 0.9); }
42
- }
43
-
44
- // ── Thumbnail ─────────────────────────────────────────────────────────────────
45
-
46
- .thumb {
47
- width: 44px;
48
- height: 44px;
49
- border-radius: 8px;
50
- background: rgba(255, 255, 255, 0.08);
51
- flex-shrink: 0;
52
- overflow: hidden;
53
- display: flex;
54
- align-items: center;
55
- justify-content: center;
56
- }
57
-
58
- .thumbImg {
59
- width: 100%;
60
- height: 100%;
61
- object-fit: cover;
62
- }
63
-
64
- .thumbIcon {
65
- font-size: 1.3rem;
66
- }
67
-
68
- // ── Info ──────────────────────────────────────────────────────────────────────
69
-
70
- .info {
71
- flex: 1;
72
- min-width: 0;
73
- display: flex;
74
- flex-direction: column;
75
- gap: 2px;
76
- }
22
+ // ── Título ────────────────────────────────────────────────────────────────────
77
23
 
78
24
  .titulo {
79
25
  color: #fff;
80
- font-size: 0.85rem;
26
+ font-size: 0.8rem;
81
27
  font-weight: 600;
82
28
  white-space: nowrap;
83
29
  overflow: hidden;
84
30
  text-overflow: ellipsis;
31
+ max-width: 220px;
32
+ margin-right: 4px;
85
33
  }
86
34
 
87
- .subtitle {
88
- color: rgba(255, 255, 255, 0.5);
89
- font-size: 0.72rem;
90
- }
91
-
92
- // ── Equalizer animation ───────────────────────────────────────────────────────
93
-
94
- .eq {
95
- display: flex;
96
- gap: 2px;
97
- align-items: flex-end;
98
- height: 18px;
99
- flex-shrink: 0;
100
- }
101
-
102
- .eqBar {
103
- width: 3px;
104
- background: var(--primary-color, #B1043F);
105
- border-radius: 2px;
106
- transform-origin: bottom;
107
- animation: eq 0.7s ease-in-out infinite alternate;
108
-
109
- &:nth-child(1) { height: 35%; animation-delay: 0s; }
110
- &:nth-child(2) { height: 75%; animation-delay: 0.15s; }
111
- &:nth-child(3) { height: 55%; animation-delay: 0.3s; }
112
- &:nth-child(4) { height: 90%; animation-delay: 0.1s; }
113
- }
114
-
115
- .eqPaused .eqBar {
116
- animation-play-state: paused;
117
- opacity: 0.4;
118
- }
119
-
120
- @keyframes eq {
121
- from { transform: scaleY(0.25); }
122
- to { transform: scaleY(1); }
123
- }
124
-
125
- // ── Skip buttons ──────────────────────────────────────────────────────────────
35
+ // ── Play / Pause button ───────────────────────────────────────────────────────
126
36
 
127
- .skipBtn {
128
- background: none;
37
+ .playBtn {
38
+ width: 34px;
39
+ height: 34px;
40
+ border-radius: 50%;
41
+ background: #fff;
129
42
  border: none;
130
- color: rgba(255, 255, 255, 0.65);
43
+ color: var(--secondary-color, #0D1333);
131
44
  cursor: pointer;
132
- padding: 4px;
133
45
  display: flex;
134
46
  align-items: center;
47
+ justify-content: center;
135
48
  flex-shrink: 0;
49
+ transition: transform 0.12s;
136
50
 
137
- &:hover { color: #fff; }
51
+ &:hover { transform: scale(1.05); }
138
52
  }
139
53
 
140
- // ── Play / Pause button ───────────────────────────────────────────────────────
54
+ // ── Botón cerrar ──────────────────────────────────────────────────────────────
141
55
 
142
- .playBtn {
143
- width: 38px;
144
- height: 38px;
145
- border-radius: 10px;
146
- background: rgba(255, 255, 255, 0.92);
56
+ .closeBtn {
57
+ background: none;
147
58
  border: none;
148
- color: var(--secondary-color, #0D1333);
59
+ color: rgba(255, 255, 255, 0.45);
149
60
  cursor: pointer;
61
+ padding: 4px;
150
62
  display: flex;
151
63
  align-items: center;
152
- justify-content: center;
153
64
  flex-shrink: 0;
154
- transition: background 0.15s;
65
+ transition: color 0.15s;
155
66
 
156
- &:hover { background: #fff; }
67
+ &:hover { color: rgba(255, 255, 255, 0.95); }
157
68
  }
158
69
 
159
70
  // ── Progress bar ──────────────────────────────────────────────────────────────
160
71
 
161
72
  .progressTrack {
162
73
  position: absolute;
163
- bottom: 8px;
164
- left: 14px;
165
- right: 14px;
166
- height: 3px;
74
+ bottom: 0;
75
+ left: 16px;
76
+ right: 16px;
77
+ height: 2px;
167
78
  background: rgba(255, 255, 255, 0.15);
168
79
  border-radius: 2px;
169
80
  cursor: pointer;
170
81
 
171
- &:hover { height: 5px; bottom: 7px; }
82
+ &:hover { height: 4px; }
172
83
  }
173
84
 
174
85
  .progressFill {
@@ -4,6 +4,27 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
4
4
 
5
5
  const SpeechCtx = createContext(null)
6
6
 
7
+ const STORAGE_KEY = 'portal-ui:speech'
8
+
9
+ // ── Persistencia en sessionStorage ────────────────────────────────────────────
10
+ // Permite que la barra sobreviva a un remount del provider (navegación SPA).
11
+
12
+ const readSession = () => {
13
+ try { return JSON.parse(sessionStorage.getItem(STORAGE_KEY) || 'null') }
14
+ catch { return null }
15
+ }
16
+
17
+ const updateSession = (patch) => {
18
+ try {
19
+ const cur = readSession() || {}
20
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ ...cur, ...patch }))
21
+ } catch { /* sessionStorage no disponible */ }
22
+ }
23
+
24
+ const clearSession = () => {
25
+ try { sessionStorage.removeItem(STORAGE_KEY) } catch { /* noop */ }
26
+ }
27
+
7
28
  const strip = (html) => (html ?? '').replace(/<[^>]+>/g, '').trim()
8
29
 
9
30
  function extractText(titulo, copete, cuerpo) {
@@ -51,11 +72,40 @@ export function SpeechProvider({ children }) {
51
72
  const [progress, setProgress] = useState(0)
52
73
  const [meta, setMeta] = useState({ titulo: '', imagen: null })
53
74
 
54
- const utterRef = useRef(null)
55
- const textRef = useRef('')
56
- const charRef = useRef(0)
75
+ const utterRef = useRef(null)
76
+ const textRef = useRef('')
77
+ const charRef = useRef(0)
78
+ const lastSaveRef = useRef(0) // throttle de escritura a sessionStorage
79
+ const mountedRef = useRef(true) // false tras desmontar este provider
80
+
81
+ // ── Restaura el estado tras un remount del provider ─────────────────────────
82
+ // Antes este efecto cancelaba el audio en CADA desmontaje, lo que rompía la
83
+ // navegación SPA. Ahora solo cancela al cerrar/recargar la pestaña de verdad.
84
+ useEffect(() => {
85
+ const saved = readSession()
86
+ if (saved?.text) {
87
+ textRef.current = saved.text
88
+ charRef.current = saved.charIndex || 0
89
+ setMeta({ titulo: saved.titulo || '', imagen: saved.imagen || null })
90
+ setProgress(saved.progress || 0)
91
+ // El audio del navegador no sobrevive a un remount de React: cancelamos
92
+ // la utterance huérfana y dejamos la barra en pausa para que el usuario
93
+ // retome desde la posición guardada (resume() re-arranca solo).
94
+ window.speechSynthesis?.cancel()
95
+ setPlaying(false)
96
+ setPaused(true)
97
+ }
57
98
 
58
- useEffect(() => () => window.speechSynthesis?.cancel(), [])
99
+ const onUnload = () => {
100
+ window.speechSynthesis?.cancel()
101
+ clearSession()
102
+ }
103
+ window.addEventListener('beforeunload', onUnload)
104
+ return () => {
105
+ mountedRef.current = false
106
+ window.removeEventListener('beforeunload', onUnload)
107
+ }
108
+ }, [])
59
109
 
60
110
  const startFrom = useCallback((charIndex) => {
61
111
  window.speechSynthesis.cancel()
@@ -68,13 +118,34 @@ export function SpeechProvider({ children }) {
68
118
  if (esVoice) utter.voice = esVoice
69
119
  utter.rate = 1
70
120
 
121
+ // Ignora eventos de utterances viejas (tras un skip/seek) o de un provider
122
+ // ya desmontado. Sin esto, un cancel() deliberado disparaba onend → y onend
123
+ // borraba la sesión, cerrando la barra en la siguiente navegación.
124
+ const isStale = () => !mountedRef.current || utterRef.current !== utter
125
+
71
126
  utter.onboundary = (e) => {
127
+ if (isStale()) return
72
128
  const abs = charIndex + (e.charIndex ?? 0)
73
129
  charRef.current = abs
74
- setProgress(abs / textRef.current.length)
130
+ const p = abs / textRef.current.length
131
+ setProgress(p)
132
+ // Persiste el avance, pero como máximo cada 1.5s para no saturar.
133
+ const now = Date.now()
134
+ if (now - lastSaveRef.current > 1500) {
135
+ lastSaveRef.current = now
136
+ updateSession({ charIndex: abs, progress: p })
137
+ }
138
+ }
139
+ utter.onend = () => {
140
+ if (isStale()) return // fue un cancel() deliberado, NO un fin de lectura
141
+ setPlaying(false); setPaused(false); setProgress(0)
142
+ charRef.current = 0
143
+ clearSession()
144
+ }
145
+ utter.onerror = () => {
146
+ if (isStale()) return
147
+ setPlaying(false); setPaused(false)
75
148
  }
76
- utter.onend = () => { setPlaying(false); setPaused(false); setProgress(0); charRef.current = 0 }
77
- utter.onerror = () => { setPlaying(false); setPaused(false) }
78
149
 
79
150
  utterRef.current = utter
80
151
  setTimeout(() => window.speechSynthesis.speak(utter), 100)
@@ -84,12 +155,14 @@ export function SpeechProvider({ children }) {
84
155
 
85
156
  const play = useCallback(({ titulo, copete, cuerpo, imagen }) => {
86
157
  const text = extractText(titulo, copete, cuerpo)
87
- console.log('[Speech] voz cargada:', window.speechSynthesis.getVoices().find(v => v.lang.startsWith('es'))?.name ?? 'default')
88
- console.log('[Speech] texto completo:', text)
89
158
  textRef.current = text
90
159
  charRef.current = 0
91
160
  setMeta({ titulo, imagen: imagen ?? null })
92
161
  setProgress(0)
162
+ updateSession({
163
+ titulo, imagen: imagen ?? null,
164
+ text, charIndex: 0, progress: 0, status: 'playing',
165
+ })
93
166
  startFrom(0)
94
167
  }, [startFrom])
95
168
 
@@ -97,20 +170,30 @@ export function SpeechProvider({ children }) {
97
170
  window.speechSynthesis.pause()
98
171
  setPlaying(false)
99
172
  setPaused(true)
173
+ updateSession({ status: 'paused', charIndex: charRef.current })
100
174
  }, [])
101
175
 
102
176
  const resume = useCallback(() => {
103
- window.speechSynthesis.resume()
104
- setPlaying(true)
105
- setPaused(false)
106
- }, [])
177
+ // Si la utterance sigue viva en el navegador, simplemente la reanudamos.
178
+ // Si no (p. ej. tras un remount), re-arrancamos desde la posición guardada.
179
+ if (window.speechSynthesis.speaking && window.speechSynthesis.paused) {
180
+ window.speechSynthesis.resume()
181
+ setPlaying(true)
182
+ setPaused(false)
183
+ } else {
184
+ startFrom(charRef.current)
185
+ }
186
+ updateSession({ status: 'playing' })
187
+ }, [startFrom])
107
188
 
108
189
  const stop = useCallback(() => {
190
+ utterRef.current = null // su onend quedará marcado como "stale" → no re-procesa
109
191
  window.speechSynthesis.cancel()
110
192
  setPlaying(false)
111
193
  setPaused(false)
112
194
  setProgress(0)
113
195
  charRef.current = 0
196
+ clearSession()
114
197
  }, [])
115
198
 
116
199
  // Salta ±10% del texto total
@@ -118,6 +201,7 @@ export function SpeechProvider({ children }) {
118
201
  const delta = Math.floor(textRef.current.length * 0.10) * direction
119
202
  const newPos = Math.max(0, Math.min(charRef.current + delta, textRef.current.length - 1))
120
203
  charRef.current = newPos
204
+ updateSession({ charIndex: newPos, status: 'playing' })
121
205
  startFrom(newPos)
122
206
  }, [startFrom])
123
207
 
@@ -125,6 +209,7 @@ export function SpeechProvider({ children }) {
125
209
  const seekTo = useCallback((ratio) => {
126
210
  const pos = Math.floor(ratio * textRef.current.length)
127
211
  charRef.current = pos
212
+ updateSession({ charIndex: pos, status: 'playing' })
128
213
  startFrom(pos)
129
214
  }, [startFrom])
130
215