@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edc-motor/ui",
3
- "version": "0.4.2",
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
+ }
@@ -5,6 +5,8 @@
5
5
  @use "form-field";
6
6
  @use "search-select";
7
7
  @use "blocks";
8
+ @use "block-related";
9
+ @use "preview-grid";
8
10
  @use "page-background";
9
11
  @use "numeric-input";
10
12
  @use "checkbox";
@@ -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')
@@ -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 { BlockCta, BlockFaq, BlockHeader, BlockIndex, BlockQuote, BlockText, BlockTextCard }
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 (los cinco de presentación). */
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'