@crtobiasdelsud/portal-ui 1.0.6 → 1.0.8

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.6",
3
+ "version": "1.0.8",
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",
@@ -0,0 +1,39 @@
1
+ 'use client'
2
+
3
+ import styles from './ArticleDetailView.module.scss'
4
+ import Standard from './Standard.jsx'
5
+ import Full from './Full.jsx'
6
+
7
+ /**
8
+ * ArticleDetailView — réplica portable de las screens `ArticleDetail` /
9
+ * `ArticleDetailFull` del portal (editor-template-front).
10
+ *
11
+ * Pensada para previews fuera del portal (ej. el CMS): es una *vista pura*,
12
+ * recibe el artículo ya resuelto y no hace data-fetching ni depende del
13
+ * registry de widgets. Reproduce el layout y los estilos de las screens, y
14
+ * elige la variante igual que el portal:
15
+ *
16
+ * article.tipoContenido === 'notaEspecial' → Full (ArticleDetailFull)
17
+ * resto → Standard (ArticleDetail)
18
+ *
19
+ * El root recrea el `<main>` del portal con `container-type: inline-size`
20
+ * para que las container-queries de los componentes portal-ui respondan al
21
+ * ancho real del contenedor — imprescindible al renderizar en un iframe.
22
+ *
23
+ * @param {object} props
24
+ * @param {object} props.article - Artículo resuelto. Shape esperado:
25
+ * { id, titulo, volanta, copete, imagen: { url, epigrafe }, imagenEpigrafe,
26
+ * focalPoint, categoria: { nombre, slug }, autor, publicarComoOrg,
27
+ * fechaPublicacion, tipoContenido, cuerpo (payload EditorJS) }
28
+ */
29
+ export default function ArticleDetailView({ article }) {
30
+ if (!article) return null
31
+
32
+ const isFull = article.tipoContenido === 'notaEspecial'
33
+
34
+ return (
35
+ <main className={styles.root}>
36
+ {isFull ? <Full article={article} /> : <Standard article={article} />}
37
+ </main>
38
+ )
39
+ }
@@ -0,0 +1,18 @@
1
+ // Root del preview — recrea el `<main>` del portal: es el container de las
2
+ // container-queries (`container-type: inline-size`) de los componentes
3
+ // portal-ui, para que el responsive responda al ancho real del contenedor
4
+ // (clave cuando se renderiza dentro de un iframe).
5
+ //
6
+ // NOTA: a diferencia del `<main>` real, NO se usa `min-height: 100dvh` —
7
+ // dentro de un iframe auto-dimensionado generaría un loop de altura.
8
+ .root {
9
+ overflow-x: clip;
10
+ display: flex;
11
+ flex-direction: column;
12
+ container-type: inline-size;
13
+ background: #fff;
14
+
15
+ // Fija la altura del hero "full" a un valor estable: dentro de un iframe
16
+ // auto-dimensionado, el `90vh/75vh` por defecto generaría un loop de altura.
17
+ --ahf-min-h: 620px;
18
+ }
@@ -0,0 +1,46 @@
1
+ 'use client'
2
+
3
+ import ArticleHeroFull from '../ArticleHeroFull/ArticleHeroFull.jsx'
4
+ import AuthorBlock from '../AuthorBlock/AuthorBlock.jsx'
5
+ import ShareBlock from '../ShareBlock/ShareBlock.jsx'
6
+ import EditorOutputFull from '../EditorOutputFull/EditorOutputFull.jsx'
7
+ import styles from './Full.module.scss'
8
+
9
+ /**
10
+ * Réplica de la screen `ArticleDetailFull` (camino non-AMP) del portal —
11
+ * la "nota especial": hero a sangre + cuerpo a una columna.
12
+ *
13
+ * Se omiten las piezas de infraestructura de la app (ArticleTracker y la
14
+ * zona post-body de widgets desde el registry).
15
+ */
16
+ export default function Full({ article }) {
17
+ const imagenEpigrafe = article.imagen?.epigrafe ?? article.imagenEpigrafe ?? null
18
+
19
+ return (
20
+ <>
21
+ <ArticleHeroFull
22
+ titulo={article.titulo}
23
+ copete={article.copete}
24
+ imagen={article.imagen?.url ?? null}
25
+ imagenEpigrafe={imagenEpigrafe}
26
+ focalPoint={article.focalPoint}
27
+ categoria={article.categoria ?? null}
28
+ />
29
+
30
+ <div className={styles.wrap}>
31
+ <div className={styles.author}>
32
+ <AuthorBlock
33
+ autor={article.autor}
34
+ publicarComoOrg={article.publicarComoOrg}
35
+ fechaPublicacion={article.fechaPublicacion}
36
+ />
37
+ <ShareBlock />
38
+ </div>
39
+
40
+ <div className={styles.body}>
41
+ <EditorOutputFull data={article.cuerpo} />
42
+ </div>
43
+ </div>
44
+ </>
45
+ )
46
+ }
@@ -0,0 +1,48 @@
1
+ // Réplica de ArticleDetailFull.module.scss del portal (editor-template-front).
2
+ @use "../../styles/index" as *;
3
+
4
+ .wrap {
5
+ width: 100%;
6
+ margin: 0 auto;
7
+ padding: 1.5rem 1rem;
8
+ box-sizing: border-box;
9
+
10
+ @include respond(tablet) {
11
+ max-width: 66.66667%;
12
+ min-width: 66.66667%;
13
+ margin-right: auto;
14
+ margin-left: auto;
15
+ padding: 0;
16
+ }
17
+
18
+ @include respond(laptop) {
19
+ max-width: 1250px;
20
+ }
21
+ }
22
+
23
+ .author {
24
+ display: flex;
25
+ align-items: stretch;
26
+ justify-content: space-between;
27
+ flex-direction: column;
28
+ margin: 1rem 0 1.5rem;
29
+ width: 100%;
30
+
31
+ @include respond(tablet) {
32
+ flex-direction: row;
33
+ align-items: center;
34
+ }
35
+ }
36
+
37
+ .heroImage {
38
+ width: 100%;
39
+ margin-bottom: 2rem;
40
+ }
41
+
42
+ .body {
43
+ width: 100%;
44
+ }
45
+
46
+ .postBody {
47
+ margin-top: 2rem;
48
+ }
@@ -0,0 +1,116 @@
1
+ 'use client'
2
+
3
+ import PageWrapper from '../UI/PageWrapper/PageWrapper.jsx'
4
+ import AspectImage from '../UI/AspectImage/AspectImage.jsx'
5
+ import EditorOutput from '../EditorOutput/EditorOutput.jsx'
6
+ import Breadcrumb from '../Breadcrumb/Breadcrumb.jsx'
7
+ import ArticleHero from '../ArticleHero/ArticleHero.jsx'
8
+ import AuthorBlock from '../AuthorBlock/AuthorBlock.jsx'
9
+ import ShareBlock from '../ShareBlock/ShareBlock.jsx'
10
+ import SpeechButton from '../SpeechButton/SpeechButton.jsx'
11
+ import ArticleSidebar from '../ArticleSidebar/ArticleSidebar.jsx'
12
+ import styles from './Standard.module.scss'
13
+
14
+ /**
15
+ * Réplica de la screen `ArticleDetail` (camino non-AMP) del portal.
16
+ *
17
+ * Reproduce el mismo árbol JSX y los mismos estilos. Se omiten las piezas
18
+ * que dependen de infraestructura de la app (ArticleTracker, zonas de
19
+ * widgets pre/post/in-body desde el registry, LoQueSeLee con data-fetching):
20
+ * el preview muestra el artículo en sí, no la config de layout del sitio.
21
+ */
22
+ export default function Standard({ article }) {
23
+ // Epígrafe de la imagen principal — normalizado en `imagen.epigrafe` y/o
24
+ // `imagenEpigrafe` a nivel raíz; puede venir null.
25
+ const imagenEpigrafe = article.imagen?.epigrafe ?? article.imagenEpigrafe ?? null
26
+
27
+ const breadcrumbItems = [
28
+ { label: 'Inicio', href: '/' },
29
+ { label: article.categoria?.nombre ?? '', href: `/${article.categoria?.slug ?? ''}` },
30
+ ]
31
+
32
+ return (
33
+ <PageWrapper>
34
+ <div className={styles.pageWrap}>
35
+ <div className={styles.articleWrap}>
36
+
37
+ {/* Row 2 — breadcrumb, hero, autor, speechbutton */}
38
+ <div className={styles.articleHeader}>
39
+ <Breadcrumb items={breadcrumbItems} />
40
+ <ArticleHero
41
+ titulo={article.titulo}
42
+ volanta={article.volanta}
43
+ copete={article.copete}
44
+ imagen={article.imagen?.url ?? null}
45
+ imagenEpigrafe={imagenEpigrafe}
46
+ focalPoint={article.focalPoint}
47
+ hideImageOnDesktop
48
+ extras={
49
+ <div className={styles.heroExtras}>
50
+ <AuthorBlock
51
+ autor={article.autor}
52
+ publicarComoOrg={article.publicarComoOrg}
53
+ fechaPublicacion={article.fechaPublicacion}
54
+ />
55
+ <ShareBlock />
56
+ </div>
57
+ }
58
+ />
59
+ {/* mobile-only: autor + share debajo del hero */}
60
+ <div className={styles.authorContainer}>
61
+ <AuthorBlock
62
+ autor={article.autor}
63
+ publicarComoOrg={article.publicarComoOrg}
64
+ fechaPublicacion={article.fechaPublicacion}
65
+ />
66
+ <ShareBlock />
67
+ </div>
68
+ {/* mobile-only: speechbutton debajo del hero */}
69
+ <div className={styles.speechMobile}>
70
+ <SpeechButton
71
+ titulo={article.titulo}
72
+ copete={article.copete}
73
+ cuerpo={article.cuerpo}
74
+ imagen={article.imagen?.url ?? null}
75
+ />
76
+ </div>
77
+ </div>
78
+
79
+ {/* Row 3 — body (2fr) + sidebar sticky (1fr) */}
80
+ <div className={styles.contentRow}>
81
+ <div className={styles.body}>
82
+ {article.imagen?.url && (
83
+ <figure className={styles.bodyImage}>
84
+ <AspectImage
85
+ src={article.imagen.url}
86
+ alt={article.titulo ?? ''}
87
+ aspect="16:9"
88
+ focalPoint={article.focalPoint}
89
+ />
90
+ {imagenEpigrafe && (
91
+ <figcaption className={styles.bodyImageCaption}>{imagenEpigrafe}</figcaption>
92
+ )}
93
+ </figure>
94
+ )}
95
+ <div className={styles.speechDesktop}>
96
+ <SpeechButton
97
+ titulo={article.titulo}
98
+ copete={article.copete}
99
+ cuerpo={article.cuerpo}
100
+ imagen={article.imagen?.url ?? null}
101
+ />
102
+ </div>
103
+ {article.cuerpo?.blocks?.length > 0 && (
104
+ <EditorOutput data={article.cuerpo} />
105
+ )}
106
+ </div>
107
+ <div className={styles.sidebarPlacement}>
108
+ <ArticleSidebar hasWidgets={false} />
109
+ </div>
110
+ </div>
111
+
112
+ </div>
113
+ </div>
114
+ </PageWrapper>
115
+ )
116
+ }
@@ -0,0 +1,179 @@
1
+ // Réplica de ArticleDetail.module.scss del portal (editor-template-front).
2
+ @use "../../styles/index" as *;
3
+
4
+ // ── Tokens ────────────────────────────────────────────────────────────────────
5
+ $page-max: 1376px;
6
+ $sidebar-w: 322px;
7
+ $col-gap: 30px;
8
+ $page-pad: 16px;
9
+
10
+ // ── Contenedor de ancho máximo ────────────────────────────────────────────────
11
+ .pageWrap {
12
+ width: 100%;
13
+ max-width: $page-max;
14
+ margin: 0 auto;
15
+ box-sizing: border-box;
16
+
17
+ @include respond(tablet) {
18
+ max-width: 720px;
19
+ }
20
+
21
+ @include respond(laptop) {
22
+ max-width: 960px;
23
+ }
24
+
25
+ @include respond(desktop) {
26
+ max-width: $page-max;
27
+ }
28
+ }
29
+
30
+ // ── Grid de 4 columnas iguales (igual que Home) ───────────────────────────────
31
+ .articleWrap {
32
+ @include respond(laptop) {
33
+ display: grid;
34
+ grid-template-columns: 1fr 1fr 1fr;
35
+ column-gap: $col-gap;
36
+ row-gap: 0;
37
+ align-items: start;
38
+ }
39
+
40
+ @include respond(desktop) {
41
+ display: grid;
42
+ grid-template-columns: 1fr 1fr 1fr 1fr;
43
+ column-gap: $col-gap;
44
+ row-gap: 0;
45
+ align-items: start;
46
+ }
47
+ }
48
+
49
+ // Breadcrumb + Hero + Author + SpeechButton — cols 1-3, row 2
50
+ .articleHeader {
51
+ min-width: 0;
52
+ padding-top: 1.5rem;
53
+
54
+ @include respond(laptop) {
55
+ grid-column: 1 / 3;
56
+ grid-row: 2;
57
+ }
58
+
59
+ @include respond(desktop) {
60
+ grid-column: 1 / 4;
61
+ grid-row: 2;
62
+ }
63
+ }
64
+
65
+ // Row 3 — grid item que agrupa body + sidebar.
66
+ .contentRow {
67
+ min-width: 0;
68
+
69
+ @include respond(laptop) {
70
+ grid-column: 1 / 4;
71
+ grid-row: 3;
72
+ display: flex;
73
+ align-items: stretch;
74
+ gap: $col-gap;
75
+ }
76
+
77
+ @include respond(desktop) {
78
+ grid-column: 1 / 4;
79
+ grid-row: 3;
80
+ display: flex;
81
+ align-items: stretch;
82
+ gap: $col-gap;
83
+ }
84
+ }
85
+
86
+ // Body — hijo flex del contentRow (2/3 del ancho)
87
+ .body {
88
+ min-width: 0;
89
+ flex: 2 1 0;
90
+ align-self: flex-start;
91
+ }
92
+
93
+ // Sidebar — ocupa el alto del body para que los sticky internos funcionen
94
+ .sidebarPlacement {
95
+ display: none;
96
+ container-type: inline-size;
97
+
98
+ @include respond(laptop) {
99
+ display: block;
100
+ flex: 1 0 0;
101
+ min-width: 0;
102
+ }
103
+
104
+ @include respond(desktop) {
105
+ display: block;
106
+ flex: 1 0 0;
107
+ min-width: 0;
108
+ }
109
+ }
110
+
111
+ // Widgets dentro del body — separación vertical entre widgets consecutivos
112
+ .inBodyWidget {
113
+ margin-top: 24px;
114
+ margin-bottom: 24px;
115
+ }
116
+
117
+ // ── Imagen del artículo en el body (tablet+) ─────────────────────────────────
118
+ .bodyImage {
119
+ display: none;
120
+ position: relative;
121
+ margin: 0;
122
+
123
+ @include respond(tablet) {
124
+ display: block;
125
+ margin: 0 0 1.5rem;
126
+ }
127
+ }
128
+
129
+ // ── Epígrafe — franja negra sobre el borde inferior de la imagen ─────────────
130
+ .bodyImageCaption {
131
+ position: absolute;
132
+ left: 0;
133
+ right: 0;
134
+ bottom: 0;
135
+ z-index: 1;
136
+ margin: 0;
137
+ padding: 10px 14px;
138
+ font-family: "Inter", "Helvetica", Arial, sans-serif;
139
+ font-size: 0.85rem;
140
+ line-height: 1.4;
141
+ color: #fff;
142
+ background: #000;
143
+ }
144
+
145
+ // ── SpeechButton mobile (en articleHeader) / desktop (en body tras imagen) ───
146
+ .speechMobile {
147
+ @include respond(laptop) {
148
+ display: none;
149
+ }
150
+ }
151
+
152
+ .speechDesktop {
153
+ display: none;
154
+
155
+ @include respond(laptop) {
156
+ display: block;
157
+ margin-bottom: 1rem;
158
+ }
159
+ }
160
+
161
+ // ── Author + share (mobile) ───────────────────────────────────────────────────
162
+ .authorContainer {
163
+ display: flex;
164
+ flex-direction: column;
165
+ margin-bottom: 0.5rem;
166
+
167
+ @include respond(laptop) {
168
+ display: none;
169
+ }
170
+ }
171
+
172
+ // Extras dentro del hero — visible solo en desktop (el ArticleHero lo oculta en mobile)
173
+ .heroExtras {
174
+ display: flex;
175
+ flex-direction: row;
176
+ justify-content: space-between;
177
+ align-items: center;
178
+ padding-top: 0.75rem;
179
+ }
@@ -14,7 +14,7 @@ import V0Desktop from './variants/V0Desktop/V0Desktop'
14
14
 
15
15
  const VARIANTS = { '0': V0, '1': V1, '2': V2, '3': V3, '4': V4, '5': V5 }
16
16
 
17
- export default function ArticleHero({ titulo, volanta, copete, imagen, focalPoint, isAmp = false, extras = null, hideImageOnDesktop = false }) {
17
+ export default function ArticleHero({ titulo, volanta, copete, imagen, imagenEpigrafe, focalPoint, isAmp = false, extras = null, hideImageOnDesktop = false }) {
18
18
  const theme = useTheme()
19
19
  const variant = String(theme.articleHero ?? 1)
20
20
 
@@ -27,10 +27,21 @@ export default function ArticleHero({ titulo, volanta, copete, imagen, focalPoin
27
27
 
28
28
  const ExtrasEl = (!isAmp && extras) ? <div className={styles.extras}>{extras}</div> : null
29
29
 
30
+ // Epígrafe: overlay sutil sobre el borde inferior de la imagen del hero.
31
+ // Solo non-amp (en amp no se aplican los CSS modules).
32
+ const EpigrafeEl = (!isAmp && imagen && imagenEpigrafe)
33
+ ? <p className={styles.epigrafe}>{imagenEpigrafe}</p>
34
+ : null
35
+
30
36
  const ImgEl = imagen
31
37
  ? isAmp
32
38
  ? <img src={imagen} alt={titulo ?? ''} className="article-hero__img" />
33
- : <AspectImage src={imagen} alt={titulo ?? ''} aspect="16:9" fill={true} focalPoint={focalPoint} />
39
+ : (
40
+ <>
41
+ <AspectImage src={imagen} alt={titulo ?? ''} aspect="16:9" fill={true} focalPoint={focalPoint} />
42
+ {EpigrafeEl}
43
+ </>
44
+ )
34
45
  : null
35
46
 
36
47
  const imgWrapClass = isAmp
@@ -87,3 +87,19 @@
87
87
  max-height: none;
88
88
  }
89
89
  }
90
+
91
+ // ── Epígrafe de la imagen — franja negra sobre el borde inferior de la foto ──
92
+ .epigrafe {
93
+ position: absolute;
94
+ left: 0;
95
+ right: 0;
96
+ bottom: 0;
97
+ z-index: 1;
98
+ margin: 0;
99
+ padding: 10px 14px;
100
+ font-family: "Inter", "Helvetica", Arial, sans-serif;
101
+ font-size: 0.8rem;
102
+ line-height: 1.35;
103
+ color: #fff;
104
+ background: #000;
105
+ }
@@ -3,7 +3,7 @@
3
3
  import styles from './ArticleHeroFull.module.scss'
4
4
  import { useSiteConfig } from '../../context/SiteConfigContext.jsx'
5
5
 
6
- export default function ArticleHeroFull({ titulo, copete, imagen, focalPoint, categoria }) {
6
+ export default function ArticleHeroFull({ titulo, copete, imagen, imagenEpigrafe, focalPoint, categoria }) {
7
7
  const { config } = useSiteConfig()
8
8
  const siteName = config?.slots?.header?.settings?.siteName ?? ''
9
9
 
@@ -22,6 +22,9 @@ export default function ArticleHeroFull({ titulo, copete, imagen, focalPoint, ca
22
22
  />
23
23
  )}
24
24
  <div className={styles.gradient} />
25
+ {imagen && imagenEpigrafe && (
26
+ <p className={styles.epigrafe}>{imagenEpigrafe}</p>
27
+ )}
25
28
  <div className={styles.content}>
26
29
  {categoria && (
27
30
  <div className={styles.breadcrumb}>
@@ -3,17 +3,20 @@
3
3
  .hero {
4
4
  position: relative;
5
5
  width: 100%;
6
- min-height: 90vh;
6
+ // Altura via custom property: el portal usa el default en `vh`; un preview
7
+ // dentro de un iframe puede fijar `--ahf-min-h` a un valor estable y evitar
8
+ // el loop de altura (vh → alto del iframe → scrollHeight → vh…).
9
+ min-height: var(--ahf-min-h, 90vh);
7
10
  overflow: hidden;
8
11
  display: flex;
9
12
  align-items: flex-end;
10
13
 
11
14
  @include respond(tablet) {
12
- min-height: 75vh;
15
+ min-height: var(--ahf-min-h, 75vh);
13
16
  }
14
17
 
15
18
  @include respond(desktop) {
16
- min-height: 75vh;
19
+ min-height: var(--ahf-min-h, 75vh);
17
20
  }
18
21
  }
19
22
 
@@ -113,4 +116,24 @@
113
116
  line-height: 1.3em;
114
117
  padding-top: 1.875rem;
115
118
  }
119
+ }
120
+
121
+ // ── Epígrafe de la imagen — crédito sutil en el borde inferior del hero ──────
122
+ .epigrafe {
123
+ position: absolute;
124
+ right: 0;
125
+ bottom: 0;
126
+ left: 0;
127
+ z-index: 1;
128
+ margin: 0;
129
+ padding: 0 5% 0.5rem;
130
+ font-family: var(--font-inter), Inter, sans-serif;
131
+ font-size: 0.78rem;
132
+ line-height: 1.35;
133
+ text-align: right;
134
+ color: rgb(255 255 255 / 0.75);
135
+
136
+ @include respond(tablet) {
137
+ padding: 0 10% 0.6rem;
138
+ }
116
139
  }
@@ -15,7 +15,7 @@ function formatDate(fechaPublicacion, useLongDate) {
15
15
  const fecha = date.toLocaleDateString('es-AR', useLongDate
16
16
  ? { day: '2-digit', month: 'long', year: 'numeric' }
17
17
  : { day: '2-digit', month: '2-digit', year: 'numeric' })
18
- const hora = date.toLocaleTimeString('es-AR', { hour: '2-digit', minute: '2-digit' })
18
+ const hora = date.toLocaleTimeString('es-AR', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
19
19
  return `${fecha} - ${hora}`
20
20
  }
21
21
 
@@ -14,6 +14,8 @@ const ampCls = {
14
14
  listOl: "eo-list-ol",
15
15
  quote: "eo-quote",
16
16
  image: "eo-image",
17
+ imageWrap: "eo-image-wrap",
18
+ epigrafe: "eo-image-epigrafe",
17
19
  delimiter: "eo-delimiter",
18
20
  code: "eo-code",
19
21
  pullquote: "eo-pullquote",
@@ -114,23 +116,25 @@ function Block({ block, cls, isAmp }) {
114
116
  )
115
117
 
116
118
  case "image": {
117
- const src = block.data.url || block.data.file?.url
118
- const alt = block.data.altText || block.data.caption || ""
119
- const credit = block.data.authorCredits || block.data.caption
119
+ const src = block.data.url || block.data.file?.url
120
+ const alt = block.data.altText || block.data.caption || ""
121
+ const epigrafe = block.data.epigrafe
120
122
  return (
121
123
  <figure className={cls.image}>
122
- {isAmp
123
- ? <img src={src} alt={alt} />
124
- : <Image
125
- src={src}
126
- alt={alt}
127
- width={0}
128
- height={0}
129
- sizes="(max-width: 768px) 100vw, 800px"
130
- style={{ width: "100%", height: "auto" }}
131
- />
132
- }
133
- {credit && <figcaption>{credit}</figcaption>}
124
+ <div className={cls.imageWrap}>
125
+ {isAmp
126
+ ? <img src={src} alt={alt} />
127
+ : <Image
128
+ src={src}
129
+ alt={alt}
130
+ width={0}
131
+ height={0}
132
+ sizes="(max-width: 768px) 100vw, 800px"
133
+ style={{ width: "100%", height: "auto" }}
134
+ />
135
+ }
136
+ {epigrafe && <p className={cls.epigrafe}>{epigrafe}</p>}
137
+ </div>
134
138
  </figure>
135
139
  )
136
140
  }
@@ -225,22 +229,27 @@ function Block({ block, cls, isAmp }) {
225
229
  return <div className={cls.raw} dangerouslySetInnerHTML={{ __html: block.data.html }} suppressHydrationWarning />
226
230
 
227
231
  case "pullquote": {
228
- const { variant, text } = block.data
232
+ const { variant, text, color, align } = block.data
229
233
  const hasClose = variant !== "2"
234
+ // color === '' → el CSS cae a var(--primary-color, #af0437).
235
+ // align ausente (bloques viejos) → default 'center'. `data-align` mueve
236
+ // todo el bloque (comillas + texto); `--pq-text-align` alinea el texto.
237
+ const pqStyle = { "--pq-text-align": align || "center" }
238
+ if (color) pqStyle["--eo-pullquote-color"] = color
230
239
  if (isAmp) {
231
240
  return (
232
- <div className={cls.pullquote}>
241
+ <div className={cls.pullquote} style={pqStyle} data-align={align || "center"}>
233
242
  <span className={cls.pullquoteOpen}>&ldquo;</span>
234
- <p>{text}</p>
243
+ <p dangerouslySetInnerHTML={{ __html: text }} suppressHydrationWarning />
235
244
  {hasClose && <span className={cls.pullquoteClose}>&rdquo;</span>}
236
245
  </div>
237
246
  )
238
247
  }
239
248
  const pullCls = [cls.pullquote, cls[`pullquoteV${variant}`]].filter(Boolean).join(" ")
240
249
  return (
241
- <div className={pullCls}>
250
+ <div className={pullCls} style={pqStyle} data-align={align || "center"}>
242
251
  <span className={cls.pullquoteOpen}>&ldquo;</span>
243
- <p>{text}</p>
252
+ <p dangerouslySetInnerHTML={{ __html: text }} suppressHydrationWarning />
244
253
  {hasClose && <span className={cls.pullquoteClose}>&rdquo;</span>}
245
254
  </div>
246
255
  )
@@ -108,13 +108,28 @@
108
108
  height: auto;
109
109
  display: block;
110
110
  }
111
+ }
111
112
 
112
- figcaption {
113
- font-size: 0.8rem;
114
- opacity: 0.6;
115
- margin-top: 0.4rem;
116
- text-align: center;
117
- }
113
+ // Wrapper relativo para anclar el epígrafe sobre la imagen (igual que el hero).
114
+ .imageWrap {
115
+ position: relative;
116
+ display: block;
117
+ }
118
+
119
+ // Epígrafe — franja negra sobre el borde inferior de la foto (estilo ArticleHero).
120
+ .epigrafe {
121
+ position: absolute;
122
+ left: 0;
123
+ right: 0;
124
+ bottom: 0;
125
+ z-index: 1;
126
+ margin: 0;
127
+ padding: 10px 14px;
128
+ font-family: "Inter", "Helvetica", Arial, sans-serif;
129
+ font-size: 0.8rem;
130
+ line-height: 1.35;
131
+ color: #fff;
132
+ background: #000;
118
133
  }
119
134
 
120
135
  .embed {
@@ -209,6 +224,71 @@
209
224
  line-height: 1.6;
210
225
  }
211
226
 
227
+ /* ── Pullquote / Cita destacada ──
228
+ El color usa `--eo-pullquote-color` (parámetro elegido en el CMS);
229
+ si no se setea, cae al color del portal: var(--primary-color, #af0437). */
230
+ .pullquote {
231
+ --pq-color: var(--eo-pullquote-color, var(--primary-color, #af0437));
232
+ /* Comillas a los lados del texto (no arriba/abajo): fila flex con la
233
+ comilla de apertura a la izquierda y la de cierre a la derecha. */
234
+ display: flex;
235
+ align-items: stretch;
236
+ gap: 0.75rem;
237
+ max-width: 500px;
238
+ margin: 2.25rem auto;
239
+ padding: 0.25rem 0;
240
+ }
241
+
242
+ /* La alineación mueve TODO el bloque (comillas + texto) a izq/centro/der. */
243
+ .pullquote[data-align="left"] { margin-inline: 0 auto; }
244
+ .pullquote[data-align="right"] { margin-inline: auto 0; }
245
+ .pullquote[data-align="center"] { margin-inline: auto; }
246
+
247
+ .pullquoteOpen,
248
+ .pullquoteClose {
249
+ flex: 0 0 auto;
250
+ font-family: Georgia, "Times New Roman", serif;
251
+ font-weight: 700;
252
+ font-size: 6.5rem;
253
+ line-height: 1;
254
+ color: var(--pq-color);
255
+ }
256
+
257
+ /* Apertura pegada arriba; cierre pegado abajo — flanqueando el texto.
258
+ line-height 0.1 en el cierre colapsa la caja de línea: así el glifo (que
259
+ se dibuja arriba de su caja) cae hasta el fondo del bloque. */
260
+ .pullquoteOpen { align-self: flex-start; }
261
+ .pullquoteClose { align-self: flex-end; line-height: 0.1; }
262
+
263
+ .pullquote p {
264
+ flex: 1 1 auto;
265
+ /* Permite que el flex item se encoja: sin esto una palabra larga sin
266
+ espacios revienta el max-width del bloque. */
267
+ min-width: 0;
268
+ overflow-wrap: break-word;
269
+ word-break: break-word;
270
+ margin: 0;
271
+ font-family: Georgia, "Times New Roman", serif;
272
+ font-size: 1.6rem;
273
+ font-weight: 700;
274
+ line-height: 1.4;
275
+ color: var(--pq-color);
276
+ text-align: var(--pq-text-align, center);
277
+ }
278
+
279
+ /* Variante 3 — comillas en color, texto en negro. */
280
+ .pullquoteV3 p { color: #111; }
281
+
282
+ /* Variante 2 — fondo oscuro, texto blanco, sólo comilla de apertura. */
283
+ .pullquoteV2 {
284
+ background: #333;
285
+ border-radius: 6px;
286
+ padding: 1.5rem 1.75rem 2rem;
287
+
288
+ p { color: #fff; }
289
+ .pullquoteClose { display: none; }
290
+ }
291
+
212
292
  .table {
213
293
  width: 100%;
214
294
  border-collapse: collapse;
@@ -14,6 +14,8 @@ const ampCls = {
14
14
  listOl: "eo-list-ol",
15
15
  quote: "eo-quote",
16
16
  image: "eo-image",
17
+ imageWrap: "eo-image-wrap",
18
+ epigrafe: "eo-image-epigrafe",
17
19
  delimiter: "eo-delimiter",
18
20
  code: "eo-code",
19
21
  pullquote: "eo-pullquote",
@@ -114,23 +116,25 @@ function Block({ block, cls, isAmp }) {
114
116
  )
115
117
 
116
118
  case "image": {
117
- const src = block.data.url || block.data.file?.url
118
- const alt = block.data.altText || block.data.caption || ""
119
- const credit = block.data.authorCredits || block.data.caption
119
+ const src = block.data.url || block.data.file?.url
120
+ const alt = block.data.altText || block.data.caption || ""
121
+ const epigrafe = block.data.epigrafe
120
122
  return (
121
123
  <figure className={cls.image}>
122
- {isAmp
123
- ? <img src={src} alt={alt} />
124
- : <Image
125
- src={src}
126
- alt={alt}
127
- width={0}
128
- height={0}
129
- sizes="(max-width: 768px) 100vw, 800px"
130
- style={{ width: "100%", height: "auto" }}
131
- />
132
- }
133
- {credit && <figcaption>{credit}</figcaption>}
124
+ <div className={cls.imageWrap}>
125
+ {isAmp
126
+ ? <img src={src} alt={alt} />
127
+ : <Image
128
+ src={src}
129
+ alt={alt}
130
+ width={0}
131
+ height={0}
132
+ sizes="(max-width: 768px) 100vw, 800px"
133
+ style={{ width: "100%", height: "auto" }}
134
+ />
135
+ }
136
+ {epigrafe && <p className={cls.epigrafe}>{epigrafe}</p>}
137
+ </div>
134
138
  </figure>
135
139
  )
136
140
  }
@@ -225,22 +229,27 @@ function Block({ block, cls, isAmp }) {
225
229
  return <div className={cls.raw} dangerouslySetInnerHTML={{ __html: block.data.html }} />
226
230
 
227
231
  case "pullquote": {
228
- const { variant, text } = block.data
232
+ const { variant, text, color, align } = block.data
229
233
  const hasClose = variant !== "2"
234
+ // color === '' → el CSS cae a var(--primary-color, #af0437).
235
+ // align ausente (bloques viejos) → default 'center'. `data-align` mueve
236
+ // todo el bloque (comillas + texto); `--pq-text-align` alinea el texto.
237
+ const pqStyle = { "--pq-text-align": align || "center" }
238
+ if (color) pqStyle["--eo-pullquote-color"] = color
230
239
  if (isAmp) {
231
240
  return (
232
- <div className={cls.pullquote}>
241
+ <div className={cls.pullquote} style={pqStyle} data-align={align || "center"}>
233
242
  <span className={cls.pullquoteOpen}>&ldquo;</span>
234
- <p>{text}</p>
243
+ <p dangerouslySetInnerHTML={{ __html: text }} />
235
244
  {hasClose && <span className={cls.pullquoteClose}>&rdquo;</span>}
236
245
  </div>
237
246
  )
238
247
  }
239
248
  const pullCls = [cls.pullquote, cls[`pullquoteV${variant}`]].filter(Boolean).join(" ")
240
249
  return (
241
- <div className={pullCls}>
250
+ <div className={pullCls} style={pqStyle} data-align={align || "center"}>
242
251
  <span className={cls.pullquoteOpen}>&ldquo;</span>
243
- <p>{text}</p>
252
+ <p dangerouslySetInnerHTML={{ __html: text }} />
244
253
  {hasClose && <span className={cls.pullquoteClose}>&rdquo;</span>}
245
254
  </div>
246
255
  )
@@ -115,13 +115,28 @@
115
115
  height: auto;
116
116
  display: block;
117
117
  }
118
+ }
118
119
 
119
- figcaption {
120
- font-size: 0.8rem;
121
- opacity: 0.6;
122
- margin-top: 0.4rem;
123
- text-align: center;
124
- }
120
+ // Wrapper relativo para anclar el epígrafe sobre la imagen (igual que el hero).
121
+ .imageWrap {
122
+ position: relative;
123
+ display: block;
124
+ }
125
+
126
+ // Epígrafe — franja negra sobre el borde inferior de la foto (estilo ArticleHero).
127
+ .epigrafe {
128
+ position: absolute;
129
+ left: 0;
130
+ right: 0;
131
+ bottom: 0;
132
+ z-index: 1;
133
+ margin: 0;
134
+ padding: 10px 14px;
135
+ font-family: "Inter", "Helvetica", Arial, sans-serif;
136
+ font-size: 0.8rem;
137
+ line-height: 1.35;
138
+ color: #fff;
139
+ background: #000;
125
140
  }
126
141
 
127
142
  .embed {
@@ -216,6 +231,71 @@
216
231
  line-height: 1.6;
217
232
  }
218
233
 
234
+ /* ── Pullquote / Cita destacada ──
235
+ El color usa `--eo-pullquote-color` (parámetro elegido en el CMS);
236
+ si no se setea, cae al color del portal: var(--primary-color, #af0437). */
237
+ .pullquote {
238
+ --pq-color: var(--eo-pullquote-color, var(--primary-color, #af0437));
239
+ /* Comillas a los lados del texto (no arriba/abajo): fila flex con la
240
+ comilla de apertura a la izquierda y la de cierre a la derecha. */
241
+ display: flex;
242
+ align-items: stretch;
243
+ gap: 0.75rem;
244
+ max-width: 500px;
245
+ margin: 2.25rem auto;
246
+ padding: 0.25rem 0;
247
+ }
248
+
249
+ /* La alineación mueve TODO el bloque (comillas + texto) a izq/centro/der. */
250
+ .pullquote[data-align="left"] { margin-inline: 0 auto; }
251
+ .pullquote[data-align="right"] { margin-inline: auto 0; }
252
+ .pullquote[data-align="center"] { margin-inline: auto; }
253
+
254
+ .pullquoteOpen,
255
+ .pullquoteClose {
256
+ flex: 0 0 auto;
257
+ font-family: Georgia, "Times New Roman", serif;
258
+ font-weight: 700;
259
+ font-size: 6.5rem;
260
+ line-height: 1;
261
+ color: var(--pq-color);
262
+ }
263
+
264
+ /* Apertura pegada arriba; cierre pegado abajo — flanqueando el texto.
265
+ line-height 0.1 en el cierre colapsa la caja de línea: así el glifo (que
266
+ se dibuja arriba de su caja) cae hasta el fondo del bloque. */
267
+ .pullquoteOpen { align-self: flex-start; }
268
+ .pullquoteClose { align-self: flex-end; line-height: 0.1; }
269
+
270
+ .pullquote p {
271
+ flex: 1 1 auto;
272
+ /* Permite que el flex item se encoja: sin esto una palabra larga sin
273
+ espacios revienta el max-width del bloque. */
274
+ min-width: 0;
275
+ overflow-wrap: break-word;
276
+ word-break: break-word;
277
+ margin: 0;
278
+ font-family: Georgia, "Times New Roman", serif;
279
+ font-size: 1.6rem;
280
+ font-weight: 700;
281
+ line-height: 1.4;
282
+ color: var(--pq-color);
283
+ text-align: var(--pq-text-align, center);
284
+ }
285
+
286
+ /* Variante 3 — comillas en color, texto en negro. */
287
+ .pullquoteV3 p { color: #111; }
288
+
289
+ /* Variante 2 — fondo oscuro, texto blanco, sólo comilla de apertura. */
290
+ .pullquoteV2 {
291
+ background: #333;
292
+ border-radius: 6px;
293
+ padding: 1.5rem 1.75rem 2rem;
294
+
295
+ p { color: #fff; }
296
+ .pullquoteClose { display: none; }
297
+ }
298
+
219
299
  .table {
220
300
  width: 100%;
221
301
  border-collapse: collapse;
package/src/index.js CHANGED
@@ -57,6 +57,9 @@ export { default as ArticleHero } from './components/ArticleHero/ArticleHero
57
57
  export { default as ArticleHeroFull } from './components/ArticleHeroFull/ArticleHeroFull.jsx'
58
58
  export { default as ArticleSidebar } from './components/ArticleSidebar/ArticleSidebar.jsx'
59
59
 
60
+ // === Screens / vistas de detalle de artículo (réplica portable para previews) ===
61
+ export { default as ArticleDetailView } from './components/ArticleDetailView/ArticleDetailView.jsx'
62
+
60
63
  // === Headers ===
61
64
  export { default as HeaderSimpleSwitch } from './components/Headers/HeaderSimple/HeaderSimpleSwitch/HeaderSimpleSwitch.jsx'
62
65
  export { default as HeaderSimpleDesktop } from './components/Headers/HeaderSimple/HeaderSimpleDesktop/HeaderSimpleDesktop.jsx'