@edc-motor/ui 0.4.2 → 0.4.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/scss/components/_block-related.scss +42 -0
- package/scss/components/_index.scss +2 -0
- package/scss/components/_preview-grid.scss +126 -0
- package/src/blocks/BlockRelated.vue +63 -0
- package/src/blocks/catalogRoutes.ts +26 -0
- package/src/blocks/index.ts +20 -2
- package/src/components/PreviewGrid.vue +101 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edc-motor/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "EdC Motor — componentes públicos Vue 3 + tokens SCSS para webs de juegos de mesa (paquete fuente: lo compila el consumidor con Vite)",
|
|
5
5
|
"license": "GPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
@use "tokens" as *;
|
|
2
|
+
|
|
3
|
+
// Bloque `related` (BlockRelated.vue): cabecera en rejilla (títulos + botón
|
|
4
|
+
// al índice a la derecha en ≥sm, patrón del relateds de CDL) sobre una
|
|
5
|
+
// PreviewGrid compacta.
|
|
6
|
+
.block--related {
|
|
7
|
+
.block__related-header {
|
|
8
|
+
display: grid;
|
|
9
|
+
grid-template-columns: 1fr;
|
|
10
|
+
gap: $space-4;
|
|
11
|
+
|
|
12
|
+
@media (min-width: $bp-sm) {
|
|
13
|
+
grid-template-columns: 1fr auto;
|
|
14
|
+
align-items: center;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.block__related-titles {
|
|
19
|
+
min-width: 0; // que los títulos largos no desborden la rejilla
|
|
20
|
+
display: grid;
|
|
21
|
+
gap: $space-2;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.block__related-button {
|
|
25
|
+
justify-self: start;
|
|
26
|
+
|
|
27
|
+
@media (min-width: $bp-sm) {
|
|
28
|
+
justify-self: end;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Centrado/derecha del campo común `align`: la cabecera acompaña.
|
|
33
|
+
&.block--align-center .block__related-header {
|
|
34
|
+
@media (min-width: $bp-sm) {
|
|
35
|
+
grid-template-columns: 1fr;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
&.block--align-center .block__related-button {
|
|
40
|
+
justify-self: center;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
@use "tokens" as *;
|
|
2
|
+
|
|
3
|
+
// Rejilla de previews del catálogo público (PreviewGrid.vue). Mobile-first:
|
|
4
|
+
// 2 columnas → 3 → 4 (patrón CDL). Media queries y no container queries:
|
|
5
|
+
// también se monta fuera de las páginas del CRM (índices, singles).
|
|
6
|
+
.preview-grid {
|
|
7
|
+
display: grid;
|
|
8
|
+
gap: $space-5;
|
|
9
|
+
|
|
10
|
+
&__grid {
|
|
11
|
+
display: grid;
|
|
12
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
13
|
+
gap: $space-4;
|
|
14
|
+
|
|
15
|
+
@media (min-width: $bp-md) {
|
|
16
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@media (min-width: $bp-lg) {
|
|
20
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
&--loading &__grid {
|
|
25
|
+
opacity: 0.55;
|
|
26
|
+
pointer-events: none;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Hueco de un ítem: la tarjeta + acciones opcionales (slot actions)
|
|
30
|
+
// superpuestas en la esquina.
|
|
31
|
+
&__slot {
|
|
32
|
+
position: relative;
|
|
33
|
+
min-width: 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Tarjeta (enlace o div): hover suave con elevación y halo de acento.
|
|
37
|
+
&__item {
|
|
38
|
+
display: block;
|
|
39
|
+
width: 100%;
|
|
40
|
+
color: inherit;
|
|
41
|
+
text-decoration: none;
|
|
42
|
+
border-radius: $radius-md;
|
|
43
|
+
overflow: hidden;
|
|
44
|
+
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
a.preview-grid__item:hover,
|
|
48
|
+
a.preview-grid__item:focus-visible {
|
|
49
|
+
transform: translateY(-2px);
|
|
50
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, $accent-500 55%, transparent), $shadow-md;
|
|
51
|
+
outline: none;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// El PNG de la preview marca su propia proporción; el fallback (sin PNG
|
|
55
|
+
// generado) reserva proporción de carta (~63×88 mm ⇒ 5/7) con el nombre.
|
|
56
|
+
&__image {
|
|
57
|
+
display: block;
|
|
58
|
+
width: 100%;
|
|
59
|
+
height: auto;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
&__fallback {
|
|
63
|
+
display: grid;
|
|
64
|
+
place-items: center;
|
|
65
|
+
aspect-ratio: 5 / 7;
|
|
66
|
+
padding: $space-3;
|
|
67
|
+
text-align: center;
|
|
68
|
+
font-weight: $k-fw-semibold;
|
|
69
|
+
color: $text-2;
|
|
70
|
+
background: color-mix(in srgb, $surface 65%, transparent);
|
|
71
|
+
border: 1px dashed $border-strong;
|
|
72
|
+
border-radius: $radius-md;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
&__empty {
|
|
76
|
+
margin: 0;
|
|
77
|
+
padding: $space-6 0;
|
|
78
|
+
text-align: center;
|
|
79
|
+
color: $text-2;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Paginación: prev / "x de y" / next.
|
|
83
|
+
&__pagination {
|
|
84
|
+
display: flex;
|
|
85
|
+
align-items: center;
|
|
86
|
+
justify-content: center;
|
|
87
|
+
gap: $space-3;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
&__page-btn {
|
|
91
|
+
padding: $space-2 $space-4;
|
|
92
|
+
font-size: $fs-14;
|
|
93
|
+
font-weight: $k-fw-semibold;
|
|
94
|
+
color: $text-1;
|
|
95
|
+
background: color-mix(in srgb, $surface 65%, transparent);
|
|
96
|
+
border: 1px solid $border;
|
|
97
|
+
border-radius: $radius-md;
|
|
98
|
+
cursor: pointer;
|
|
99
|
+
transition: border-color 0.2s ease, color 0.2s ease;
|
|
100
|
+
|
|
101
|
+
&:hover:not(:disabled) {
|
|
102
|
+
border-color: $accent-500;
|
|
103
|
+
color: $accent-500;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
&:disabled {
|
|
107
|
+
opacity: $disabled-opacity;
|
|
108
|
+
cursor: default;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
&__page-state {
|
|
113
|
+
color: $text-2;
|
|
114
|
+
font-size: $fs-14;
|
|
115
|
+
white-space: nowrap;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Variante compacta (bloque related): tarjetas y huecos más contenidos.
|
|
119
|
+
&--compact &__grid {
|
|
120
|
+
gap: $space-3;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
&--compact &__fallback {
|
|
124
|
+
font-size: $fs-14;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, inject } from 'vue'
|
|
3
|
+
import { RouterLink, useRoute } from 'vue-router'
|
|
4
|
+
import BlockShell from './BlockShell.vue'
|
|
5
|
+
import PreviewGrid, { type PreviewGridItem } from '../components/PreviewGrid.vue'
|
|
6
|
+
import { catalogRoutesKey, type CatalogItem } from './catalogRoutes'
|
|
7
|
+
|
|
8
|
+
// Bloque `related` (categoría data): título/subtítulo + rejilla compacta de
|
|
9
|
+
// previews relacionadas (data.items del catálogo) + botón opcional al índice.
|
|
10
|
+
// El paquete ui no conoce las rutas del juego: la app las provee con
|
|
11
|
+
// `app.provide(catalogRoutesKey, mapa)`; sin entrada para data.key los ítems
|
|
12
|
+
// se pintan sin enlace y el botón no aparece. Agnóstico de i18n (DC-29): el
|
|
13
|
+
// texto por defecto del botón llega por prop (castellano).
|
|
14
|
+
const props = withDefaults(
|
|
15
|
+
defineProps<{
|
|
16
|
+
settings: Record<string, unknown>
|
|
17
|
+
data?: { key?: string | null; items?: CatalogItem[] }
|
|
18
|
+
/** Texto del botón si el bloque no trae `button_label`. */
|
|
19
|
+
defaultButtonLabel?: string
|
|
20
|
+
}>(),
|
|
21
|
+
{ data: () => ({}), defaultButtonLabel: 'Ver todos' },
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const route = useRoute()
|
|
25
|
+
const routes = inject(catalogRoutesKey, {})
|
|
26
|
+
|
|
27
|
+
const locale = computed(() => String(route.params.locale ?? ''))
|
|
28
|
+
const entry = computed(() => (props.data?.key ? routes[props.data.key] : undefined))
|
|
29
|
+
|
|
30
|
+
const items = computed<PreviewGridItem[]>(() =>
|
|
31
|
+
(props.data?.items ?? []).map((item) => ({
|
|
32
|
+
...item,
|
|
33
|
+
to: entry.value?.single?.(item, locale.value) ?? undefined,
|
|
34
|
+
})),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const indexRoute = computed(() =>
|
|
38
|
+
props.settings.with_button && entry.value?.index ? entry.value.index(locale.value) : null,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const buttonLabel = computed(
|
|
42
|
+
() => String(props.settings.button_label ?? '') || props.defaultButtonLabel,
|
|
43
|
+
)
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<BlockShell :settings="settings" class="block--related">
|
|
48
|
+
<div class="block__related-header">
|
|
49
|
+
<div class="block__related-titles">
|
|
50
|
+
<h2 v-if="settings.title" class="block__title">{{ settings.title }}</h2>
|
|
51
|
+
<p v-if="settings.subtitle" class="block__subtitle">{{ settings.subtitle }}</p>
|
|
52
|
+
</div>
|
|
53
|
+
<RouterLink
|
|
54
|
+
v-if="indexRoute"
|
|
55
|
+
class="block-button block-button--secondary block__related-button"
|
|
56
|
+
:to="indexRoute"
|
|
57
|
+
>
|
|
58
|
+
{{ buttonLabel }}
|
|
59
|
+
</RouterLink>
|
|
60
|
+
</div>
|
|
61
|
+
<PreviewGrid :items="items" compact />
|
|
62
|
+
</BlockShell>
|
|
63
|
+
</template>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { InjectionKey } from 'vue'
|
|
2
|
+
import type { RouteLocationRaw } from 'vue-router'
|
|
3
|
+
|
|
4
|
+
// Resolución de enlaces del catálogo público: el paquete ui no conoce las
|
|
5
|
+
// rutas del juego, así que la app PROVEE este mapa (clave del PreviewRegistry
|
|
6
|
+
// => fábricas de ruta) con `app.provide(catalogRoutesKey, mapa)`. BlockRelated
|
|
7
|
+
// lo inyecta; sin mapa (o sin entrada) los ítems se pintan sin enlace.
|
|
8
|
+
|
|
9
|
+
/** Ítem del catálogo público del motor (`GET /api/catalog/{key}`). */
|
|
10
|
+
export interface CatalogItem {
|
|
11
|
+
id: number
|
|
12
|
+
name: string
|
|
13
|
+
slug: string | null
|
|
14
|
+
preview: string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CatalogRouteEntry {
|
|
18
|
+
/** Ruta del índice de la entidad (botón "ver todos"). */
|
|
19
|
+
index?: (locale: string) => RouteLocationRaw
|
|
20
|
+
/** Ruta del detalle de un ítem; null = ese ítem no enlaza. */
|
|
21
|
+
single?: (item: CatalogItem, locale: string) => RouteLocationRaw | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type CatalogRoutes = Record<string, CatalogRouteEntry>
|
|
25
|
+
|
|
26
|
+
export const catalogRoutesKey: InjectionKey<CatalogRoutes> = Symbol('catalogRoutes')
|
package/src/blocks/index.ts
CHANGED
|
@@ -6,14 +6,31 @@ import BlockFaq from './BlockFaq.vue'
|
|
|
6
6
|
import BlockHeader from './BlockHeader.vue'
|
|
7
7
|
import BlockIndex from './BlockIndex.vue'
|
|
8
8
|
import BlockQuote from './BlockQuote.vue'
|
|
9
|
+
import BlockRelated from './BlockRelated.vue'
|
|
9
10
|
import BlockText from './BlockText.vue'
|
|
10
11
|
import BlockTextCard from './BlockTextCard.vue'
|
|
11
12
|
|
|
12
|
-
export {
|
|
13
|
+
export {
|
|
14
|
+
BlockCta,
|
|
15
|
+
BlockFaq,
|
|
16
|
+
BlockHeader,
|
|
17
|
+
BlockIndex,
|
|
18
|
+
BlockQuote,
|
|
19
|
+
BlockRelated,
|
|
20
|
+
BlockText,
|
|
21
|
+
BlockTextCard,
|
|
22
|
+
}
|
|
13
23
|
export { default as BlockShell } from './BlockShell.vue'
|
|
14
24
|
export { default as PageBackground } from './PageBackground.vue'
|
|
25
|
+
// Mapa de rutas del catálogo (BlockRelated): la app lo provee por inject.
|
|
26
|
+
export {
|
|
27
|
+
catalogRoutesKey,
|
|
28
|
+
type CatalogItem,
|
|
29
|
+
type CatalogRouteEntry,
|
|
30
|
+
type CatalogRoutes,
|
|
31
|
+
} from './catalogRoutes'
|
|
15
32
|
|
|
16
|
-
/** Clave del BlockType => componente
|
|
33
|
+
/** Clave del BlockType => componente. */
|
|
17
34
|
export const motorBlockComponents: Record<string, Component> = {
|
|
18
35
|
header: BlockHeader,
|
|
19
36
|
text: BlockText,
|
|
@@ -22,4 +39,5 @@ export const motorBlockComponents: Record<string, Component> = {
|
|
|
22
39
|
index: BlockIndex,
|
|
23
40
|
cta: BlockCta,
|
|
24
41
|
faq: BlockFaq,
|
|
42
|
+
related: BlockRelated,
|
|
25
43
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { RouterLink } from 'vue-router'
|
|
3
|
+
import type { RouteLocationRaw } from 'vue-router'
|
|
4
|
+
import type { CatalogItem } from '../blocks/catalogRoutes'
|
|
5
|
+
|
|
6
|
+
// Rejilla PRESENTACIONAL de previews del catálogo público: el fetch lo hace
|
|
7
|
+
// la vista del juego (o el bloque `related` con sus datos ya resueltos).
|
|
8
|
+
// Los ítems con `to` son RouterLink. La tarjeta por defecto es el PNG de la
|
|
9
|
+
// preview con proporción de carta; si no hay PNG (el motor no genera
|
|
10
|
+
// placeholder), fallback con el nombre. Agnóstico de i18n (DC-29): textos
|
|
11
|
+
// por prop, defaults en castellano.
|
|
12
|
+
export interface PreviewGridItem extends CatalogItem {
|
|
13
|
+
to?: RouteLocationRaw | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const props = withDefaults(
|
|
17
|
+
defineProps<{
|
|
18
|
+
items: PreviewGridItem[]
|
|
19
|
+
loading?: boolean
|
|
20
|
+
/** Página actual y total (meta del catálogo). Sin `pages` (o con una
|
|
21
|
+
* sola página) no se pinta la paginación. */
|
|
22
|
+
page?: number
|
|
23
|
+
pages?: number
|
|
24
|
+
/** Variante compacta (bloque `related`): tarjetas más pequeñas. */
|
|
25
|
+
compact?: boolean
|
|
26
|
+
emptyText?: string
|
|
27
|
+
prevLabel?: string
|
|
28
|
+
nextLabel?: string
|
|
29
|
+
}>(),
|
|
30
|
+
{
|
|
31
|
+
loading: false,
|
|
32
|
+
page: 1,
|
|
33
|
+
pages: 0,
|
|
34
|
+
compact: false,
|
|
35
|
+
emptyText: 'No hay resultados.',
|
|
36
|
+
prevLabel: 'Anterior',
|
|
37
|
+
nextLabel: 'Siguiente',
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const emit = defineEmits<{ (e: 'page', page: number): void }>()
|
|
42
|
+
|
|
43
|
+
function go(page: number) {
|
|
44
|
+
if (props.loading || page < 1 || page > props.pages || page === props.page) return
|
|
45
|
+
emit('page', page)
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<div
|
|
51
|
+
class="preview-grid"
|
|
52
|
+
:class="{ 'preview-grid--compact': compact, 'preview-grid--loading': loading }"
|
|
53
|
+
>
|
|
54
|
+
<p v-if="!loading && !items.length" class="preview-grid__empty">
|
|
55
|
+
<slot name="empty">{{ emptyText }}</slot>
|
|
56
|
+
</p>
|
|
57
|
+
|
|
58
|
+
<div v-else class="preview-grid__grid">
|
|
59
|
+
<div v-for="item in items" :key="item.id" class="preview-grid__slot">
|
|
60
|
+
<component
|
|
61
|
+
:is="item.to ? RouterLink : 'div'"
|
|
62
|
+
class="preview-grid__item"
|
|
63
|
+
v-bind="item.to ? { to: item.to } : {}"
|
|
64
|
+
>
|
|
65
|
+
<slot name="item" :item="item">
|
|
66
|
+
<img
|
|
67
|
+
v-if="item.preview"
|
|
68
|
+
class="preview-grid__image"
|
|
69
|
+
:src="item.preview"
|
|
70
|
+
:alt="item.name"
|
|
71
|
+
loading="lazy"
|
|
72
|
+
/>
|
|
73
|
+
<span v-else class="preview-grid__fallback">{{ item.name }}</span>
|
|
74
|
+
</slot>
|
|
75
|
+
</component>
|
|
76
|
+
<!-- Acciones por ítem (p. ej. añadir a la colección) -->
|
|
77
|
+
<slot name="actions" :item="item" />
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<nav v-if="pages > 1" class="preview-grid__pagination">
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
class="preview-grid__page-btn"
|
|
85
|
+
:disabled="loading || page <= 1"
|
|
86
|
+
@click="go(page - 1)"
|
|
87
|
+
>
|
|
88
|
+
{{ prevLabel }}
|
|
89
|
+
</button>
|
|
90
|
+
<span class="preview-grid__page-state">{{ page }} / {{ pages }}</span>
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
class="preview-grid__page-btn"
|
|
94
|
+
:disabled="loading || page >= pages"
|
|
95
|
+
@click="go(page + 1)"
|
|
96
|
+
>
|
|
97
|
+
{{ nextLabel }}
|
|
98
|
+
</button>
|
|
99
|
+
</nav>
|
|
100
|
+
</div>
|
|
101
|
+
</template>
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ export { default as ConfirmDialog } from './components/ConfirmDialog.vue'
|
|
|
29
29
|
export { default as ThemeSelector } from './components/ThemeSelector.vue'
|
|
30
30
|
export { default as LocaleSelector } from './components/LocaleSelector.vue'
|
|
31
31
|
export { default as AppBreadcrumbs, type Crumb } from './components/AppBreadcrumbs.vue'
|
|
32
|
+
export { default as PreviewGrid, type PreviewGridItem } from './components/PreviewGrid.vue'
|
|
32
33
|
export { createApi, type CreateApiOptions } from './lib/createApi'
|
|
33
34
|
export { useToast, type Toast } from './composables/useToast'
|
|
34
35
|
export { useConfirm, type ConfirmOptions } from './composables/useConfirm'
|