@crtobiasdelsud/portal-ui 1.0.2 → 1.0.4
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 +1 -1
- package/src/components/Blocks/BlockColumns/BlockColumns.jsx +12 -0
- package/src/components/Blocks/BlockColumns/BlockColumns.module.scss +6 -1
- package/src/components/Cabezal/CabezalView.jsx +10 -1
- package/src/components/Cabezal/CardCabezal/CardCabezal.module.scss +3 -0
- package/src/components/Cabezal/variants/Etiquetas/Etiquetas.jsx +52 -0
- package/src/components/Cabezal/variants/Etiquetas/Etiquetas.module.scss +70 -0
- package/src/components/Cabezal/variants/Tres/Tres.module.scss +8 -1
- package/src/components/Hero/Hero.module.scss +7 -0
- package/src/components/Hero/HeroView.jsx +1 -0
- package/src/components/ShareBlock/ShareBlock.jsx +21 -8
- package/src/components/ShareBlock/ShareBlock.module.scss +36 -1
- package/src/components/ShareBlock/icons/Email.jsx +21 -0
- package/src/components/ShareBlock/icons/Facebook.jsx +15 -0
- package/src/components/ShareBlock/icons/Linkedin.jsx +12 -0
- package/src/components/ShareBlock/icons/Telegram.jsx +21 -0
- package/src/components/ShareBlock/icons/Whatsapp.jsx +12 -0
- package/src/components/ShareBlock/icons/X.jsx +15 -0
- package/src/components/ShareBlock/icons/index.js +18 -0
- package/src/components/ShareBlock/variants/V1/V1.jsx +18 -8
- package/src/components/ShareBlock/variants/V2/V2.jsx +18 -8
- package/src/components/SpeechPlayerBar/SpeechPlayerBar.jsx +9 -38
- package/src/components/SpeechPlayerBar/SpeechPlayerBar.module.scss +33 -122
- package/src/components/UI/Icon/Icon.jsx +12 -7
- package/src/components/UI/Icon/Icon.module.scss +20 -0
- package/src/context/SpeechContext.jsx +98 -13
- package/src/styles/mixins/_media.scss +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crtobiasdelsud/portal-ui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
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:
|
|
40
|
+
grid-template-areas:
|
|
41
|
+
"hero hero hero hero"
|
|
42
|
+
"recommended fantasma fantasma feed";
|
|
38
43
|
}
|
|
39
44
|
}
|
|
40
45
|
|
|
@@ -14,6 +14,7 @@ import Carrusel from './variants/Carrusel/Carrusel.jsx'
|
|
|
14
14
|
import LeeAdemas from './variants/LeeAdemas/LeeAdemas.jsx'
|
|
15
15
|
import LoQueSeLee from './variants/LoQueSeLee/LoQueSeLee.jsx'
|
|
16
16
|
import SeguiLeyendo from './variants/SeguiLeyendo/SeguiLeyendo.jsx'
|
|
17
|
+
import Etiquetas from './variants/Etiquetas/Etiquetas.jsx'
|
|
17
18
|
|
|
18
19
|
const VARIANTS = {
|
|
19
20
|
default: Default,
|
|
@@ -41,6 +42,7 @@ const VARIANTS = {
|
|
|
41
42
|
loqueselee: LoQueSeLee,
|
|
42
43
|
seguiLeyendo: SeguiLeyendo,
|
|
43
44
|
seguileyendo: SeguiLeyendo,
|
|
45
|
+
etiquetas: Etiquetas,
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
/**
|
|
@@ -53,8 +55,9 @@ const VARIANTS = {
|
|
|
53
55
|
* @param {string} [props.titulo]
|
|
54
56
|
* @param {string} [props.tipo='default']
|
|
55
57
|
* @param {string} [props.verMasUrl]
|
|
56
|
-
* @param {Array<object>} [props.articles=[]] — todos los tipos excepto loQueSeLee
|
|
58
|
+
* @param {Array<object>} [props.articles=[]] — todos los tipos excepto loQueSeLee/etiquetas
|
|
57
59
|
* @param {object} [props.article] — solo para loQueSeLee
|
|
60
|
+
* @param {Array<object>} [props.tags] — solo para etiquetas (tags del artículo actual)
|
|
58
61
|
* @param {boolean} [props.isAmp=false]
|
|
59
62
|
*/
|
|
60
63
|
export default function CabezalView({
|
|
@@ -63,6 +66,7 @@ export default function CabezalView({
|
|
|
63
66
|
verMasUrl,
|
|
64
67
|
articles = [],
|
|
65
68
|
article,
|
|
69
|
+
tags,
|
|
66
70
|
isAmp = false,
|
|
67
71
|
}) {
|
|
68
72
|
const isLoQueSeLee = tipo === 'loQueSeLee' || tipo === 'loqueselee'
|
|
@@ -72,6 +76,11 @@ export default function CabezalView({
|
|
|
72
76
|
return <LoQueSeLee article={article} />
|
|
73
77
|
}
|
|
74
78
|
|
|
79
|
+
// `etiquetas` recibe los tags del artículo en curso, no un array de artículos.
|
|
80
|
+
if (tipo === 'etiquetas') {
|
|
81
|
+
return <Etiquetas tags={tags} />
|
|
82
|
+
}
|
|
83
|
+
|
|
75
84
|
if (!titulo && !articles.length) return null
|
|
76
85
|
|
|
77
86
|
const Component = VARIANTS[tipo] ?? Default
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useAdapters } from '../../../../adapters/AdaptersContext.jsx'
|
|
2
|
+
import styles from './Etiquetas.module.scss'
|
|
3
|
+
|
|
4
|
+
// View pura: muestra las etiquetas/tags del artículo en curso.
|
|
5
|
+
// A diferencia de LoQueSeLee, no hay fetch: la data layer le pasa los `tags`
|
|
6
|
+
// que ya vienen resueltos en el artículo (`/api/portal/articles/:id` los incluye).
|
|
7
|
+
// Cada tag enlaza a la pantalla de etiqueta ya existente: /etiqueta/{slug}.
|
|
8
|
+
export default function Etiquetas({ tags = [] }) {
|
|
9
|
+
const { Link } = useAdapters()
|
|
10
|
+
|
|
11
|
+
// Acepta los dos shapes del backend:
|
|
12
|
+
// - article.tags → { id, name, slug }
|
|
13
|
+
// - article.etiquetas → { slug, nombre }
|
|
14
|
+
const items = (Array.isArray(tags) ? tags : [])
|
|
15
|
+
.map((t) => ({ slug: t?.slug, name: t?.name ?? t?.nombre }))
|
|
16
|
+
.filter((t) => t.slug && t.name)
|
|
17
|
+
if (items.length === 0) return null
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<section className={styles.container} aria-label="Temas del artículo">
|
|
21
|
+
<span className={styles.label}>
|
|
22
|
+
<svg
|
|
23
|
+
className={styles.icon}
|
|
24
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
25
|
+
width="19"
|
|
26
|
+
height="19"
|
|
27
|
+
viewBox="0 0 19 19"
|
|
28
|
+
aria-hidden="true"
|
|
29
|
+
>
|
|
30
|
+
<path
|
|
31
|
+
fill="currentColor"
|
|
32
|
+
d="M17.1 0H1.9A1.906 1.906 0 0 0 0 1.9V19l3.8-3.8h13.3a1.906 1.906 0 0 0 1.9-1.9V1.9A1.906 1.906 0 0 0 17.1 0Zm0 13.3H3.8l-1.9 1.9V1.9h15.2Z"
|
|
33
|
+
/>
|
|
34
|
+
<path
|
|
35
|
+
fill="currentColor"
|
|
36
|
+
d="m11.87 6.826-.194 1.64H13.1v1.469h-1.606l-.216 1.7H9.729l.216-1.7h-1.4l-.216 1.7H6.78l.216-1.7H5.561V8.466h1.617l.194-1.64H5.937V5.36h1.617l.216-1.7h1.549l-.216 1.7h1.4l.216-1.7h1.55l-.217 1.7h1.437v1.469Zm-1.549 0h-1.4l-.194 1.64h1.4Z"
|
|
37
|
+
/>
|
|
38
|
+
</svg>
|
|
39
|
+
Temas
|
|
40
|
+
</span>
|
|
41
|
+
<ul className={styles.list}>
|
|
42
|
+
{items.map((tag) => (
|
|
43
|
+
<li key={tag.id ?? tag.slug} className={styles.item}>
|
|
44
|
+
<Link href={`/etiqueta/${tag.slug}`} className={styles.tag}>
|
|
45
|
+
{tag.name}
|
|
46
|
+
</Link>
|
|
47
|
+
</li>
|
|
48
|
+
))}
|
|
49
|
+
</ul>
|
|
50
|
+
</section>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
@use "../../../../styles/index" as *;
|
|
2
|
+
|
|
3
|
+
.container {
|
|
4
|
+
display: flex;
|
|
5
|
+
align-items: flex-start;
|
|
6
|
+
flex-wrap: wrap;
|
|
7
|
+
gap: 12px 16px;
|
|
8
|
+
width: 100%;
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
padding: 16px 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ── Badge "# TEMAS" ─────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
.label {
|
|
16
|
+
display: inline-flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
gap: 6px;
|
|
19
|
+
flex-shrink: 0;
|
|
20
|
+
padding: 6px 10px;
|
|
21
|
+
border: 1px solid var(--primary-color, #af0437);
|
|
22
|
+
color: var(--primary-color, #af0437);
|
|
23
|
+
font-family: var(--font-inter, Inter, sans-serif);
|
|
24
|
+
font-size: 16px;
|
|
25
|
+
font-weight: 700;
|
|
26
|
+
line-height: 1;
|
|
27
|
+
text-transform: uppercase;
|
|
28
|
+
letter-spacing: 0.04em;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.icon {
|
|
32
|
+
width: 15px;
|
|
33
|
+
height: 15px;
|
|
34
|
+
flex-shrink: 0;
|
|
35
|
+
color: var(--primary-color, #af0437);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Lista de tags ───────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
.list {
|
|
41
|
+
display: flex;
|
|
42
|
+
flex: 1;
|
|
43
|
+
min-width: 0;
|
|
44
|
+
flex-wrap: wrap;
|
|
45
|
+
gap: 8px 20px;
|
|
46
|
+
margin: 0;
|
|
47
|
+
padding: 0;
|
|
48
|
+
list-style: none;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.item {
|
|
52
|
+
display: inline-flex;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.tag {
|
|
56
|
+
font-family: var(--font-inter, Inter, sans-serif);
|
|
57
|
+
font-size: 16px;
|
|
58
|
+
font-weight: 600;
|
|
59
|
+
line-height: 20px;
|
|
60
|
+
text-transform: uppercase;
|
|
61
|
+
letter-spacing: 0.02em;
|
|
62
|
+
color: var(--text-color, #252523);
|
|
63
|
+
text-decoration: none;
|
|
64
|
+
transition: color 0.15s ease;
|
|
65
|
+
|
|
66
|
+
&:hover {
|
|
67
|
+
color: var(--primary-color, #af0437);
|
|
68
|
+
text-decoration: underline;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -60,8 +60,15 @@
|
|
|
60
60
|
flex-direction: column;
|
|
61
61
|
gap: 16px;
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
// Intermedio (~480–768px): 2 columnas
|
|
64
|
+
@include respond(mobile) {
|
|
64
65
|
display: grid;
|
|
66
|
+
grid-template-columns: repeat(2, 1fr);
|
|
67
|
+
gap: 20px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Ancho (≥768px): 3 columnas
|
|
71
|
+
@include respond(tablet) {
|
|
65
72
|
grid-template-columns: repeat(3, 1fr);
|
|
66
73
|
gap: 30px;
|
|
67
74
|
}
|
|
@@ -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: 500px;
|
|
129
|
+
}
|
|
130
|
+
|
|
124
131
|
.headline {
|
|
125
132
|
font-size: 1.6rem;
|
|
126
133
|
|
|
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
|
|
4
4
|
import { useTheme } from '../../context/SiteConfigContext.jsx'
|
|
5
5
|
import V1 from './variants/V1/V1'
|
|
6
6
|
import V2 from './variants/V2/V2'
|
|
7
|
+
import { NETWORK_ICONS } from './icons/index.js'
|
|
7
8
|
|
|
8
9
|
const VARIANTS = { '1': V1, '2': V2 }
|
|
9
10
|
|
|
@@ -12,42 +13,42 @@ function buildNetworks(url) {
|
|
|
12
13
|
return [
|
|
13
14
|
{
|
|
14
15
|
key: 'x',
|
|
15
|
-
|
|
16
|
+
Glyph: NETWORK_ICONS.x,
|
|
16
17
|
label: 'X',
|
|
17
18
|
tooltip: 'Compartir en X',
|
|
18
19
|
href: `https://twitter.com/intent/tweet?url=${encoded}`,
|
|
19
20
|
},
|
|
20
21
|
{
|
|
21
22
|
key: 'facebook',
|
|
22
|
-
|
|
23
|
+
Glyph: NETWORK_ICONS.facebook,
|
|
23
24
|
label: 'Facebook',
|
|
24
25
|
tooltip: 'Compartir en Facebook',
|
|
25
26
|
href: `https://www.facebook.com/sharer/sharer.php?u=${encoded}`,
|
|
26
27
|
},
|
|
27
28
|
{
|
|
28
29
|
key: 'linkedin',
|
|
29
|
-
|
|
30
|
+
Glyph: NETWORK_ICONS.linkedin,
|
|
30
31
|
label: 'LinkedIn',
|
|
31
32
|
tooltip: 'Compartir en LinkedIn',
|
|
32
33
|
href: `https://www.linkedin.com/shareArticle?mini=true&url=${encoded}`,
|
|
33
34
|
},
|
|
34
35
|
{
|
|
35
36
|
key: 'telegram',
|
|
36
|
-
|
|
37
|
+
Glyph: NETWORK_ICONS.telegram,
|
|
37
38
|
label: 'Telegram',
|
|
38
39
|
tooltip: 'Compartir en Telegram',
|
|
39
40
|
href: `https://t.me/share/url?url=${encoded}`,
|
|
40
41
|
},
|
|
41
42
|
{
|
|
42
43
|
key: 'whatsapp',
|
|
43
|
-
|
|
44
|
+
Glyph: NETWORK_ICONS.whatsapp,
|
|
44
45
|
label: 'WhatsApp',
|
|
45
46
|
tooltip: 'Compartir en WhatsApp',
|
|
46
47
|
href: `https://wa.me/?text=${encoded}`,
|
|
47
48
|
},
|
|
48
49
|
{
|
|
49
50
|
key: 'email',
|
|
50
|
-
|
|
51
|
+
Glyph: NETWORK_ICONS.email,
|
|
51
52
|
label: 'Email',
|
|
52
53
|
tooltip: 'Compartir por Email',
|
|
53
54
|
href: `mailto:?body=${encoded}`,
|
|
@@ -55,7 +56,7 @@ function buildNetworks(url) {
|
|
|
55
56
|
]
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
export default function ShareBlock({ isAmp = false }) {
|
|
59
|
+
export default function ShareBlock({ isAmp = false, settings = {} }) {
|
|
59
60
|
const theme = useTheme()
|
|
60
61
|
const [networks, setNetworks] = useState(() => buildNetworks(''))
|
|
61
62
|
|
|
@@ -66,10 +67,22 @@ export default function ShareBlock({ isAmp = false }) {
|
|
|
66
67
|
const v = String(theme.shareBlock ?? 1)
|
|
67
68
|
const Variant = VARIANTS[v] ?? V1
|
|
68
69
|
|
|
70
|
+
// `borderLeft`: barra lateral con el color primario. Se usa cuando el
|
|
71
|
+
// ShareBlock va dentro del cuerpo del artículo (lo setea el widget del CMS).
|
|
72
|
+
const borderLeft = settings?.borderLeft ?? false
|
|
73
|
+
|
|
69
74
|
const inlineStyle = isAmp ? {} : {
|
|
70
75
|
'--primary-color': theme.primary,
|
|
71
76
|
'--surface-color': theme.surface,
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
return
|
|
79
|
+
return (
|
|
80
|
+
<Variant
|
|
81
|
+
isAmp={isAmp}
|
|
82
|
+
inlineStyle={inlineStyle}
|
|
83
|
+
networks={networks}
|
|
84
|
+
v={v}
|
|
85
|
+
borderLeft={borderLeft}
|
|
86
|
+
/>
|
|
87
|
+
)
|
|
75
88
|
}
|
|
@@ -18,6 +18,17 @@
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// Barra lateral con el color primario. Se activa con la prop `borderLeft`
|
|
22
|
+
// (la setea el widget del CMS cuando el ShareBlock va en el cuerpo del artículo).
|
|
23
|
+
.borderLeft {
|
|
24
|
+
border-left: 4px solid var(--primary-color, #B1043F);
|
|
25
|
+
padding-left: 12px;
|
|
26
|
+
|
|
27
|
+
@include respond(desktop) {
|
|
28
|
+
padding-left: 16px;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
.label {
|
|
22
33
|
font-size: 14px;
|
|
23
34
|
line-height: 14px;
|
|
@@ -35,11 +46,35 @@
|
|
|
35
46
|
.icons {
|
|
36
47
|
display: flex;
|
|
37
48
|
align-items: center;
|
|
38
|
-
gap:
|
|
49
|
+
gap: 6px;
|
|
39
50
|
|
|
40
51
|
@include respond(desktop) {
|
|
41
52
|
gap: 12px;
|
|
42
53
|
}
|
|
54
|
+
|
|
55
|
+
// Non-AMP: el componente Icon renderiza <a><span aria-hidden/></a>.
|
|
56
|
+
// Achicamos en mobile y restauramos el tamaño completo en desktop.
|
|
57
|
+
a {
|
|
58
|
+
width: 26px;
|
|
59
|
+
height: 26px;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
span[aria-hidden] {
|
|
63
|
+
width: 22px;
|
|
64
|
+
height: 22px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@include respond(desktop) {
|
|
68
|
+
a {
|
|
69
|
+
width: 33px;
|
|
70
|
+
height: 33px;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
span[aria-hidden] {
|
|
74
|
+
width: 32px;
|
|
75
|
+
height: 32px;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
43
78
|
}
|
|
44
79
|
|
|
45
80
|
.iconLink {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Ícono Email — vector monocromo. Recolorea vía `currentColor`.
|
|
2
|
+
export default function Email(props) {
|
|
3
|
+
return (
|
|
4
|
+
<svg viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
|
5
|
+
<rect
|
|
6
|
+
x="0.448591"
|
|
7
|
+
y="0.448591"
|
|
8
|
+
width="31.2056"
|
|
9
|
+
height="31.2056"
|
|
10
|
+
rx="15.6028"
|
|
11
|
+
fill="none"
|
|
12
|
+
stroke="currentColor"
|
|
13
|
+
strokeWidth="0.897182"
|
|
14
|
+
/>
|
|
15
|
+
<path
|
|
16
|
+
fill="currentColor"
|
|
17
|
+
d="M25.0227 10.6694C25.0227 9.68246 24.2153 8.875 23.2284 8.875H8.87347C7.88657 8.875 7.0791 9.68246 7.0791 10.6694V21.4356C7.0791 22.4225 7.88657 23.2299 8.87347 23.2299H23.2284C24.2153 23.2299 25.0227 22.4225 25.0227 21.4356V10.6694ZM23.2284 10.6694L16.0509 15.1553L8.87347 10.6694H23.2284ZM23.2284 21.4356H8.87347V12.4637L16.0509 16.9496L23.2284 12.4637V21.4356Z"
|
|
18
|
+
/>
|
|
19
|
+
</svg>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Ícono Facebook — vector monocromo. Recolorea vía `fill="currentColor"`.
|
|
2
|
+
export default function Facebook(props) {
|
|
3
|
+
return (
|
|
4
|
+
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
|
5
|
+
<path
|
|
6
|
+
fill="currentColor"
|
|
7
|
+
d="M11.7645 23.3479H11.7132C5.29228 23.3479 0.0683594 18.1224 0.0683594 11.6996V11.6482C0.0683594 5.22548 5.29228 0 11.7132 0H11.7645C18.1854 0 23.4093 5.22548 23.4093 11.6482V11.6996C23.4093 18.1224 18.1854 23.3479 11.7645 23.3479ZM11.7132 0.790298C5.72761 0.790298 0.858423 5.66093 0.858423 11.6482V11.6996C0.858423 17.6869 5.72761 22.5575 11.7132 22.5575H11.7645C17.7501 22.5575 22.6193 17.6869 22.6193 11.6996V11.6482C22.6193 5.66093 17.7501 0.790298 11.7645 0.790298H11.7132Z"
|
|
8
|
+
/>
|
|
9
|
+
<path
|
|
10
|
+
fill="currentColor"
|
|
11
|
+
d="M13.3092 9.0559V11.5129H16.3478L15.8667 14.8227H13.3092V22.4483C12.7964 22.5195 12.2718 22.5566 11.7393 22.5566C11.1246 22.5566 10.5211 22.5076 9.93321 22.4128V14.8227H7.13086V11.5129H9.93321V8.50666C9.93321 6.64154 11.4446 5.12891 13.31 5.12891V5.13049C13.3155 5.13049 13.3203 5.12891 13.3258 5.12891H16.3486V7.99139H14.3734C13.7864 7.99139 13.31 8.4679 13.31 9.0551L13.3092 9.0559Z"
|
|
12
|
+
/>
|
|
13
|
+
</svg>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Ícono LinkedIn — vector monocromo. Recolorea vía `currentColor`.
|
|
2
|
+
export default function Linkedin(props) {
|
|
3
|
+
return (
|
|
4
|
+
<svg viewBox="0 0 23 23" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
|
5
|
+
<rect x="0.5" y="0.5" width="22" height="22" rx="11" fill="none" stroke="currentColor" />
|
|
6
|
+
<path
|
|
7
|
+
fill="currentColor"
|
|
8
|
+
d="M8.33525 6.17784C8.33508 6.54495 8.19718 6.89695 7.9519 7.15641C7.70662 7.41586 7.37404 7.56152 7.02733 7.56133C6.68061 7.56115 6.34817 7.41514 6.10313 7.15543C5.85809 6.89571 5.72053 6.54357 5.7207 6.17646C5.72088 5.80935 5.85877 5.45735 6.10406 5.1979C6.34934 4.93844 6.68192 4.79279 7.02863 4.79297C7.37534 4.79315 7.70779 4.93916 7.95282 5.19888C8.19786 5.45859 8.33543 5.81074 8.33525 6.17784ZM8.37447 8.58632H5.75992V17.2513H8.37447V8.58632ZM12.5055 8.58632H9.90398V17.2513H12.4793V12.7043C12.4793 10.1712 15.5972 9.9359 15.5972 12.7043V17.2513H18.179V11.763C18.179 7.49282 13.5644 7.652 12.4793 9.74903L12.5055 8.58632Z"
|
|
9
|
+
/>
|
|
10
|
+
</svg>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Ícono Telegram — vector monocromo. Recolorea vía `currentColor`.
|
|
2
|
+
export default function Telegram(props) {
|
|
3
|
+
return (
|
|
4
|
+
<svg viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
|
5
|
+
<rect
|
|
6
|
+
x="0.448591"
|
|
7
|
+
y="0.448591"
|
|
8
|
+
width="31.2056"
|
|
9
|
+
height="31.2056"
|
|
10
|
+
rx="15.6028"
|
|
11
|
+
fill="none"
|
|
12
|
+
stroke="currentColor"
|
|
13
|
+
strokeWidth="0.897182"
|
|
14
|
+
/>
|
|
15
|
+
<path
|
|
16
|
+
fill="currentColor"
|
|
17
|
+
d="M24.0141 8.23267L7.16884 14.7622C6.49096 15.0662 6.26168 15.6751 7.005 16.0056L11.3265 17.3861L21.7754 10.8951C22.3459 10.4876 22.93 10.5962 22.4274 11.0445L13.4532 19.212L13.1713 22.6685C13.4324 23.2022 13.9105 23.2047 14.2155 22.9394L16.6983 20.578L20.9506 23.7786C21.9382 24.3663 22.4756 23.9871 22.6881 22.9098L25.4772 9.63482C25.7668 8.30888 25.273 7.72465 24.0141 8.23267Z"
|
|
18
|
+
/>
|
|
19
|
+
</svg>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Ícono WhatsApp — vector monocromo. Recolorea vía `currentColor`.
|
|
2
|
+
export default function Whatsapp(props) {
|
|
3
|
+
return (
|
|
4
|
+
<svg viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
|
5
|
+
<rect x="0.5" y="0.5" width="32" height="32" rx="16" fill="none" stroke="currentColor" />
|
|
6
|
+
<path
|
|
7
|
+
fill="currentColor"
|
|
8
|
+
d="M23.3769 9.61904C22.5475 8.78574 21.5597 8.12502 20.4711 7.67537C19.3825 7.22573 18.2148 6.99616 17.0362 7.00005C12.0975 7.00005 8.07236 11.005 8.07236 15.919C8.07236 17.494 8.48844 19.024 9.26633 20.374L8 25L12.7487 23.758C14.0603 24.469 15.5347 24.847 17.0362 24.847C21.9749 24.847 26 20.842 26 15.928C26 13.543 25.0683 11.302 23.3769 9.61904ZM17.0362 23.335C15.6975 23.335 14.3859 22.975 13.2372 22.3L12.9658 22.138L10.1437 22.876L10.8945 20.14L10.7136 19.861C9.96964 18.6794 9.57471 17.3134 9.57387 15.919C9.57387 11.833 12.9206 8.50304 17.0271 8.50304C19.0171 8.50304 20.8894 9.27704 22.2915 10.681C22.9858 11.3685 23.536 12.1863 23.9102 13.087C24.2844 13.9877 24.4752 14.9534 24.4714 15.928C24.4894 20.014 21.1427 23.335 17.0362 23.335ZM21.1246 17.791C20.8985 17.683 19.795 17.143 19.596 17.062C19.3879 16.99 19.2432 16.954 19.0894 17.17C18.9357 17.395 18.5106 17.899 18.3839 18.043C18.2573 18.196 18.1216 18.214 17.8955 18.097C17.6693 17.989 16.9457 17.746 16.0955 16.99C15.4261 16.396 14.9829 15.667 14.8472 15.442C14.7206 15.217 14.8291 15.1 14.9467 14.983C15.0462 14.884 15.1729 14.722 15.2814 14.596C15.3899 14.47 15.4352 14.371 15.5075 14.227C15.5799 14.074 15.5437 13.948 15.4894 13.84C15.4352 13.732 14.9829 12.634 14.802 12.184C14.6211 11.752 14.4312 11.806 14.2955 11.797H13.8613C13.7075 11.797 13.4724 11.851 13.2643 12.076C13.0653 12.301 12.4864 12.841 12.4864 13.939C12.4864 15.037 13.2915 16.099 13.4 16.243C13.5085 16.396 14.9829 18.646 17.2261 19.609C17.7598 19.843 18.1759 19.978 18.5015 20.077C19.0352 20.248 19.5236 20.221 19.9126 20.167C20.3467 20.104 21.2422 19.627 21.4231 19.105C21.6131 18.583 21.6131 18.142 21.5497 18.043C21.4864 17.944 21.3508 17.899 21.1246 17.791Z"
|
|
9
|
+
/>
|
|
10
|
+
</svg>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Ícono X (Twitter) — vector monocromo. Recolorea vía `fill="currentColor"`.
|
|
2
|
+
export default function X(props) {
|
|
3
|
+
return (
|
|
4
|
+
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
|
5
|
+
<path
|
|
6
|
+
fill="currentColor"
|
|
7
|
+
d="M11.6961 23.3479H11.6448C5.22393 23.3479 0 18.1224 0 11.6996V11.6482C0 5.22545 5.22393 0 11.6448 0H11.6961C18.117 0 23.3409 5.22545 23.3409 11.6482V11.6996C23.3409 18.1224 18.117 23.3479 11.6961 23.3479ZM11.6448 0.790298C5.65925 0.790298 0.790065 5.66091 0.790065 11.6482V11.6996C0.790065 17.6869 5.65925 22.5575 11.6448 22.5575H11.6961C17.6817 22.5575 22.5509 17.6869 22.5509 11.6996V11.6482C22.5509 5.66091 17.6817 0.790298 11.6961 0.790298H11.6448Z"
|
|
8
|
+
/>
|
|
9
|
+
<path
|
|
10
|
+
fill="currentColor"
|
|
11
|
+
d="M4.96892 5.50391L10.1691 12.4586L4.93652 18.1131H6.11452L10.6961 13.1627L14.3976 18.1131H18.4056L12.913 10.7673L17.7838 5.50391H16.6058L12.3869 10.0632L8.9777 5.50391H4.96969H4.96892ZM6.70073 6.37167H8.54163L16.6722 17.2454H14.8313L6.70073 6.37167Z"
|
|
12
|
+
/>
|
|
13
|
+
</svg>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Íconos de redes del ShareBlock — componentes SVG inline, monocromos.
|
|
2
|
+
// Cada uno usa `fill="currentColor"`, así el color (y el hover) lo controla
|
|
3
|
+
// el CSS del componente `Icon` (ver Icon.module.scss).
|
|
4
|
+
import X from './X.jsx'
|
|
5
|
+
import Facebook from './Facebook.jsx'
|
|
6
|
+
import Linkedin from './Linkedin.jsx'
|
|
7
|
+
import Telegram from './Telegram.jsx'
|
|
8
|
+
import Whatsapp from './Whatsapp.jsx'
|
|
9
|
+
import Email from './Email.jsx'
|
|
10
|
+
|
|
11
|
+
export const NETWORK_ICONS = {
|
|
12
|
+
x: X,
|
|
13
|
+
facebook: Facebook,
|
|
14
|
+
linkedin: Linkedin,
|
|
15
|
+
telegram: Telegram,
|
|
16
|
+
whatsapp: Whatsapp,
|
|
17
|
+
email: Email,
|
|
18
|
+
}
|
|
@@ -2,8 +2,10 @@ import Icon from '../../../UI/Icon/Icon.jsx'
|
|
|
2
2
|
import shared from '../../ShareBlock.module.scss'
|
|
3
3
|
import s from './V1.module.scss'
|
|
4
4
|
|
|
5
|
-
export default function V1({ isAmp, inlineStyle, networks, v }) {
|
|
6
|
-
const containerCls = isAmp
|
|
5
|
+
export default function V1({ isAmp, inlineStyle, networks, v, borderLeft = false }) {
|
|
6
|
+
const containerCls = isAmp
|
|
7
|
+
? `share-block share-block--${v}${borderLeft ? ' share-block--bordered' : ''}`
|
|
8
|
+
: `${shared.container} ${s.root}${borderLeft ? ` ${shared.borderLeft}` : ''}`
|
|
7
9
|
|
|
8
10
|
return (
|
|
9
11
|
<div className={containerCls} style={inlineStyle}>
|
|
@@ -11,16 +13,24 @@ export default function V1({ isAmp, inlineStyle, networks, v }) {
|
|
|
11
13
|
Compartí esta nota:
|
|
12
14
|
</span>
|
|
13
15
|
<div className={isAmp ? 'share-block__icons' : shared.icons}>
|
|
14
|
-
{networks.map(({ key,
|
|
15
|
-
|
|
16
|
+
{networks.map(({ key, Glyph, label, href, tooltip }) => {
|
|
17
|
+
// Links externos (http) → pestaña nueva. `mailto:` queda en la misma.
|
|
18
|
+
const external = (href ?? '').startsWith('http')
|
|
19
|
+
return isAmp
|
|
16
20
|
? (
|
|
17
|
-
<a
|
|
18
|
-
|
|
21
|
+
<a
|
|
22
|
+
key={key}
|
|
23
|
+
href={href ?? '#'}
|
|
24
|
+
className="share-block__icon-link"
|
|
25
|
+
aria-label={label}
|
|
26
|
+
{...(external && { target: '_blank', rel: 'noopener noreferrer' })}
|
|
27
|
+
>
|
|
28
|
+
<Glyph className="share-block__icon" />
|
|
19
29
|
</a>
|
|
20
30
|
) : (
|
|
21
|
-
<Icon key={key}
|
|
31
|
+
<Icon key={key} glyph={<Glyph />} label={label} href={href ?? '#'} tooltipText={tooltip} newTab={external} />
|
|
22
32
|
)
|
|
23
|
-
)
|
|
33
|
+
})}
|
|
24
34
|
</div>
|
|
25
35
|
</div>
|
|
26
36
|
)
|
|
@@ -2,8 +2,10 @@ import Icon from '../../../UI/Icon/Icon.jsx'
|
|
|
2
2
|
import shared from '../../ShareBlock.module.scss'
|
|
3
3
|
import s from './V2.module.scss'
|
|
4
4
|
|
|
5
|
-
export default function V2({ isAmp, inlineStyle, networks, v }) {
|
|
6
|
-
const containerCls = isAmp
|
|
5
|
+
export default function V2({ isAmp, inlineStyle, networks, v, borderLeft = false }) {
|
|
6
|
+
const containerCls = isAmp
|
|
7
|
+
? `share-block share-block--${v}${borderLeft ? ' share-block--bordered' : ''}`
|
|
8
|
+
: `${shared.container} ${s.root}${borderLeft ? ` ${shared.borderLeft}` : ''}`
|
|
7
9
|
|
|
8
10
|
return (
|
|
9
11
|
<div className={containerCls} style={inlineStyle}>
|
|
@@ -11,16 +13,24 @@ export default function V2({ isAmp, inlineStyle, networks, v }) {
|
|
|
11
13
|
Compartí esta nota:
|
|
12
14
|
</span>
|
|
13
15
|
<div className={isAmp ? 'share-block__icons' : shared.icons}>
|
|
14
|
-
{networks.map(({ key,
|
|
15
|
-
|
|
16
|
+
{networks.map(({ key, Glyph, label, href, tooltip }) => {
|
|
17
|
+
// Links externos (http) → pestaña nueva. `mailto:` queda en la misma.
|
|
18
|
+
const external = (href ?? '').startsWith('http')
|
|
19
|
+
return isAmp
|
|
16
20
|
? (
|
|
17
|
-
<a
|
|
18
|
-
|
|
21
|
+
<a
|
|
22
|
+
key={key}
|
|
23
|
+
href={href ?? '#'}
|
|
24
|
+
className="share-block__icon-link"
|
|
25
|
+
aria-label={label}
|
|
26
|
+
{...(external && { target: '_blank', rel: 'noopener noreferrer' })}
|
|
27
|
+
>
|
|
28
|
+
<Glyph className="share-block__icon" />
|
|
19
29
|
</a>
|
|
20
30
|
) : (
|
|
21
|
-
<Icon key={key}
|
|
31
|
+
<Icon key={key} glyph={<Glyph />} label={label} href={href ?? '#'} tooltipText={tooltip} newTab={external} />
|
|
22
32
|
)
|
|
23
|
-
)
|
|
33
|
+
})}
|
|
24
34
|
</div>
|
|
25
35
|
</div>
|
|
26
36
|
)
|
|
@@ -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,
|
|
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
|
-
|
|
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="
|
|
64
|
-
: <svg viewBox="0 0 24 24" fill="currentColor" width="
|
|
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
|
-
{/*
|
|
69
|
-
<button className={styles.
|
|
70
|
-
<svg viewBox="0 0 24 24" fill="currentColor" width="
|
|
71
|
-
<path d="
|
|
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:
|
|
11
|
-
max-width:
|
|
10
|
+
width: max-content;
|
|
11
|
+
max-width: calc(100% - 32px);
|
|
12
12
|
background: var(--secondary-color, #0D1333);
|
|
13
|
-
border-radius:
|
|
14
|
-
padding: 10px
|
|
13
|
+
border-radius: 14px;
|
|
14
|
+
padding: 7px 10px 7px 16px;
|
|
15
15
|
display: flex;
|
|
16
16
|
align-items: center;
|
|
17
|
-
gap:
|
|
17
|
+
gap: 6px;
|
|
18
18
|
z-index: 9999;
|
|
19
|
-
box-shadow: 0
|
|
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
|
-
// ──
|
|
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.
|
|
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
|
-
|
|
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
|
-
.
|
|
128
|
-
|
|
37
|
+
.playBtn {
|
|
38
|
+
width: 34px;
|
|
39
|
+
height: 34px;
|
|
40
|
+
border-radius: 50%;
|
|
41
|
+
background: #fff;
|
|
129
42
|
border: none;
|
|
130
|
-
color:
|
|
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 {
|
|
51
|
+
&:hover { transform: scale(1.05); }
|
|
138
52
|
}
|
|
139
53
|
|
|
140
|
-
// ──
|
|
54
|
+
// ── Botón cerrar ──────────────────────────────────────────────────────────────
|
|
141
55
|
|
|
142
|
-
.
|
|
143
|
-
|
|
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:
|
|
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:
|
|
65
|
+
transition: color 0.15s;
|
|
155
66
|
|
|
156
|
-
&:hover {
|
|
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:
|
|
164
|
-
left:
|
|
165
|
-
right:
|
|
166
|
-
height:
|
|
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:
|
|
82
|
+
&:hover { height: 4px; }
|
|
172
83
|
}
|
|
173
84
|
|
|
174
85
|
.progressFill {
|
|
@@ -10,13 +10,17 @@ function buildTooltip(label, href) {
|
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export default function Icon({ src, label, href, tooltipText }) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
export default function Icon({ src, glyph, label, href, tooltipText, newTab = false }) {
|
|
14
|
+
// Dos modos de render del ícono:
|
|
15
|
+
// - `glyph`: SVG inline (React node). Recolorea vía `fill="currentColor"`.
|
|
16
|
+
// - `src`: ruta a un .svg, usado como `mask-image` (modo legacy).
|
|
17
|
+
const icon = glyph
|
|
18
|
+
? <span aria-hidden="true" className={style.glyph}>{glyph}</span>
|
|
19
|
+
: <span
|
|
20
|
+
aria-hidden="true"
|
|
21
|
+
className={style.icon}
|
|
22
|
+
style={{ maskImage: `url(${src})`, WebkitMaskImage: `url(${src})` }}
|
|
23
|
+
/>
|
|
20
24
|
|
|
21
25
|
if (href) {
|
|
22
26
|
return (
|
|
@@ -25,6 +29,7 @@ export default function Icon({ src, label, href, tooltipText }) {
|
|
|
25
29
|
className={style.container}
|
|
26
30
|
href={href}
|
|
27
31
|
aria-label={label}
|
|
32
|
+
{...(newTab && { target: '_blank', rel: 'noopener noreferrer' })}
|
|
28
33
|
>
|
|
29
34
|
{icon}
|
|
30
35
|
|
|
@@ -27,6 +27,26 @@
|
|
|
27
27
|
background-color: var(--primary-color, #4bac48);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/* Modo glifo: SVG inline. El color (y su hover) se aplica vía `currentColor`
|
|
31
|
+
— el <svg> del ícono usa fill="currentColor". */
|
|
32
|
+
.glyph{
|
|
33
|
+
display: block;
|
|
34
|
+
height: 32px;
|
|
35
|
+
width: 32px;
|
|
36
|
+
color: currentColor;
|
|
37
|
+
transition: color 0.3s;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.glyph svg{
|
|
41
|
+
display: block;
|
|
42
|
+
height: 100%;
|
|
43
|
+
width: 100%;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.container:hover .glyph{
|
|
47
|
+
color: var(--primary-color, #4bac48);
|
|
48
|
+
}
|
|
49
|
+
|
|
30
50
|
.container[data-tooltip] {
|
|
31
51
|
position: relative;
|
|
32
52
|
}
|
|
@@ -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
|
|
55
|
-
const textRef
|
|
56
|
-
const charRef
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
@use "../variables/breakpoint" as *;
|
|
2
2
|
|
|
3
3
|
@mixin respond($breakpoint) {
|
|
4
|
+
@if $breakpoint == mobile {
|
|
5
|
+
@supports (container-type: inline-size) {
|
|
6
|
+
@container (min-width: #{$mobile}) { @content; }
|
|
7
|
+
}
|
|
8
|
+
@supports not (container-type: inline-size) {
|
|
9
|
+
@media (min-width: $mobile) { @content; }
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
4
13
|
@if $breakpoint == tablet {
|
|
5
14
|
@supports (container-type: inline-size) {
|
|
6
15
|
@container (min-width: #{$tablet}) { @content; }
|