@innertia-solutions/nuxt-theme-spark 0.1.137 → 0.1.139

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.
@@ -25,10 +25,13 @@ const iconColorClass = computed(() => ({
25
25
  </script>
26
26
 
27
27
  <template>
28
- <div class="space-y-4">
28
+ <div class="relative space-y-2">
29
29
 
30
30
  <!-- Page header card -->
31
31
  <div v-if="title" class="sticky top-0 z-20 -mx-3 -mt-3 px-3 pt-3 bg-background-1">
32
+ <div v-if="$slots.breadcrumb" class="flex items-center gap-x-1 px-1 pt-2 pb-0.5">
33
+ <slot name="breadcrumb" />
34
+ </div>
32
35
  <div class="flex items-center justify-between bg-card border border-card-line rounded-2xl shadow-sm px-4 py-3">
33
36
  <div class="flex items-center gap-x-4 min-w-0">
34
37
  <div v-if="iconComponent" class="shrink-0 size-10 rounded-xl flex items-center justify-center border border-current/15" :class="iconColorClass">
@@ -42,9 +45,6 @@ const iconColorClass = computed(() => ({
42
45
  <p class="text-sm text-muted-foreground">{{ description }}</p>
43
46
  </template>
44
47
  </div>
45
- <div v-if="$slots.breadcrumb" class="flex items-center gap-x-1 mt-0.5">
46
- <slot name="breadcrumb" />
47
- </div>
48
48
  </div>
49
49
  </div>
50
50
  <div v-if="$slots.actions" class="flex items-center gap-x-2 shrink-0 ms-4">
@@ -59,7 +59,7 @@ const iconColorClass = computed(() => ({
59
59
  </div>
60
60
 
61
61
  <!-- Page content -->
62
- <slot />
62
+ <div class="relative"><slot /></div>
63
63
 
64
64
  </div>
65
65
  </template>
@@ -22,22 +22,22 @@ const iconSizeClasses = computed(() => ({ xs:"size-2", sm:"size-3", md:"size-4",
22
22
 
23
23
  const severityClasses = computed(() => {
24
24
  if (props.variant === "dropdown") {
25
- const d = { primary:"text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/20", secondary:"text-foreground hover:bg-muted-hover", success:"text-emerald-600 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:bg-emerald-900/20", danger:"text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20", warning:"text-yellow-600 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-900/20", info:"text-cyan-600 hover:bg-cyan-50 dark:text-cyan-400 dark:hover:bg-cyan-900/20" }
25
+ const d = { primary: "text-primary hover:bg-primary/10", secondary:"text-foreground hover:bg-muted-hover", success:"text-emerald-600 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:bg-emerald-900/20", danger:"text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20", warning:"text-yellow-600 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-900/20", info:"text-cyan-600 hover:bg-cyan-50 dark:text-cyan-400 dark:hover:bg-cyan-900/20" }
26
26
  return d[props.severity] || d.secondary
27
27
  }
28
28
  const base = "rounded-lg border transition-colors"
29
- const v = { primary:"border-blue-600 bg-blue-50 text-blue-700 hover:bg-blue-100 dark:border-blue-500 dark:bg-blue-900/20 dark:text-blue-300 dark:hover:bg-blue-900/35", secondary:"border-slate-300 bg-slate-50 text-slate-700 hover:bg-muted-hover dark:border-card-line dark:bg-card dark:text-muted-foreground-1 dark:hover:bg-muted-hover", success:"border-emerald-600 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-500 dark:bg-emerald-900/20 dark:text-emerald-300 dark:hover:bg-emerald-900/35", danger:"border-red-600 bg-red-50 text-red-700 hover:bg-red-100 dark:border-red-500 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/35", warning:"border-yellow-600 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 dark:border-yellow-500 dark:bg-yellow-900/20 dark:text-yellow-300 dark:hover:bg-yellow-900/35", info:"border-cyan-600 bg-cyan-50 text-cyan-700 hover:bg-cyan-100 dark:border-cyan-500 dark:bg-cyan-900/20 dark:text-cyan-300 dark:hover:bg-cyan-900/35" }
29
+ const v = { primary: "border-primary bg-primary/10 text-primary hover:bg-primary/20 dark:bg-primary/15 dark:hover:bg-primary/25", secondary:"border-slate-300 bg-slate-50 text-slate-700 hover:bg-muted-hover dark:border-card-line dark:bg-card dark:text-muted-foreground-1 dark:hover:bg-muted-hover", success:"border-emerald-600 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-500 dark:bg-emerald-900/20 dark:text-emerald-300 dark:hover:bg-emerald-900/35", danger:"border-red-600 bg-red-50 text-red-700 hover:bg-red-100 dark:border-red-500 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/35", warning:"border-yellow-600 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 dark:border-yellow-500 dark:bg-yellow-900/20 dark:text-yellow-300 dark:hover:bg-yellow-900/35", info:"border-cyan-600 bg-cyan-50 text-cyan-700 hover:bg-cyan-100 dark:border-cyan-500 dark:bg-cyan-900/20 dark:text-cyan-300 dark:hover:bg-cyan-900/35" }
30
30
  return `${base} ${v[props.severity] || v.primary}`
31
31
  })
32
32
 
33
33
  const buttonClasses = computed(() => {
34
34
  if (props.variant === "dropdown") {
35
35
  const dis = props.type === "button" ? "disabled:opacity-50 disabled:pointer-events-none" : isDisabled.value ? "opacity-50 pointer-events-none" : ""
36
- return `w-full flex items-center gap-x-3.5 py-2 px-3 rounded-lg text-sm transition-colors text-left ${severityClasses.value} ${dis} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800 ${props.class}`
36
+ return `w-full flex items-center gap-x-3.5 py-2 px-3 rounded-lg text-sm transition-colors text-left ${severityClasses.value} ${dis} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary/40 dark:focus:ring-offset-gray-800 ${props.class}`
37
37
  }
38
38
  const dis = props.type === "button" ? "disabled:opacity-50 disabled:pointer-events-none" : isDisabled.value ? "opacity-50 pointer-events-none" : ""
39
39
  const cursor = props.type === "link" ? "cursor-pointer" : ""
40
- return `${sizeClasses.value} ${severityClasses.value} ${dis} focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800 ${cursor} inline-flex justify-center items-center gap-x-2 whitespace-nowrap ${props.class}`
40
+ return `${sizeClasses.value} ${severityClasses.value} ${dis} focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-primary/40 dark:focus:ring-offset-gray-800 ${cursor} inline-flex justify-center items-center gap-x-2 whitespace-nowrap ${props.class}`
41
41
  })
42
42
 
43
43
  const displayText = computed(() => props.loading ? props.loadingText : props.text)
@@ -0,0 +1,64 @@
1
+ <script setup>
2
+ import { IconX } from '@tabler/icons-vue'
3
+
4
+ const { docked, undock, expandDock, activeDockId } = useDockedPreviews()
5
+ const router = useRouter()
6
+ const route = useRoute()
7
+
8
+ async function open(item, event) {
9
+ // Misma ruta → la tabla está montada, mostrar float encima del tab
10
+ if (route.path === item.route) {
11
+ const rect = event.currentTarget.getBoundingClientRect()
12
+ expandDock(item.id, rect)
13
+ return
14
+ }
15
+ // Ruta diferente → navegar y restaurar como preview completo
16
+ await router.push(item.route)
17
+ await nextTick()
18
+ useNuxtApp().hooks.callHook('preview:restore', item)
19
+ }
20
+ </script>
21
+
22
+ <template>
23
+ <Transition
24
+ enter-active-class="transition ease-out duration-200"
25
+ enter-from-class="opacity-0 translate-y-4"
26
+ enter-to-class="opacity-100 translate-y-0"
27
+ leave-active-class="transition ease-in duration-150"
28
+ leave-from-class="opacity-100 translate-y-0"
29
+ leave-to-class="opacity-0 translate-y-4"
30
+ >
31
+ <div
32
+ v-if="docked.length"
33
+ class="fixed bottom-0 left-0 right-0 z-50 flex items-center gap-2 px-4 py-2 bg-card/95 backdrop-blur-md border-t border-card-line shadow-lg"
34
+ >
35
+ <span class="text-xs text-muted-foreground shrink-0 mr-1">Minimizados</span>
36
+
37
+ <div class="flex items-center gap-2 flex-1 overflow-x-auto">
38
+ <button
39
+ v-for="item in docked"
40
+ :key="item.id"
41
+ type="button"
42
+ class="group inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm transition-all shrink-0"
43
+ :class="activeDockId === item.id
44
+ ? 'border-primary/50 bg-primary/10 text-primary shadow-sm'
45
+ : 'border-card-line bg-surface hover:bg-muted-hover text-foreground'"
46
+ @click="open(item, $event)"
47
+ >
48
+ <span class="size-5 rounded-full bg-primary flex items-center justify-center text-[10px] font-bold text-primary-foreground shrink-0">
49
+ {{ (item.label?.[0] ?? '?').toUpperCase() }}
50
+ </span>
51
+ <span class="font-medium max-w-32 truncate">{{ item.label }}</span>
52
+ <span v-if="item.subtitle" class="text-muted-foreground text-xs max-w-28 truncate hidden sm:inline">{{ item.subtitle }}</span>
53
+
54
+ <span
55
+ class="size-4 inline-flex items-center justify-center rounded hover:bg-red-100 dark:hover:bg-red-900/30 hover:text-red-600 dark:hover:text-red-400 transition-colors ml-0.5"
56
+ @click.stop="undock(item.id)"
57
+ >
58
+ <IconX class="size-3" />
59
+ </span>
60
+ </button>
61
+ </div>
62
+ </div>
63
+ </Transition>
64
+ </template>
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { IconSearch, IconAdjustmentsHorizontal, IconLayoutColumns, IconGripVertical } from '@tabler/icons-vue'
2
+ import { IconSearch, IconAdjustmentsHorizontal, IconLayoutColumns, IconGripVertical, IconMinus, IconMaximize, IconX } from '@tabler/icons-vue'
3
3
 
4
4
  const props = defineProps({
5
5
  table: { type: Object, default: null },
@@ -17,6 +17,7 @@ const props = defineProps({
17
17
  showExport: { type: Boolean, default: true },
18
18
  filters: { type: Array, default: () => [] },
19
19
  splitRatio: { type: Number, default: 60 },
20
+ autoClosePreview: { type: Boolean, default: true },
20
21
  })
21
22
 
22
23
  const resolvedEndpoint = computed(() => props.table?.endpoint ?? props.endpoint)
@@ -61,6 +62,7 @@ const paginationHeight = ref(0)
61
62
  const previewCacheKey = computed(() => `table-preview-${resolvedName.value}`)
62
63
 
63
64
  const previewFromCache = ref(false)
65
+ const previewPanelRef = ref(null)
64
66
  const closePreview = () => { previewRow.value = null }
65
67
 
66
68
  const previewTab = ref('datos')
@@ -76,6 +78,7 @@ watch(previewRow, () => { previewTab.value = 'datos' })
76
78
 
77
79
  const handleRowClick = (row) => {
78
80
  if (previewEnabled.value) {
81
+ collapseDock()
79
82
  previewRow.value = previewRow.value?.id === row.id ? null : row
80
83
  } else {
81
84
  emit('row-click', row)
@@ -129,10 +132,74 @@ const startResize = (e) => {
129
132
  window.addEventListener('mouseup', onUp)
130
133
  }
131
134
 
132
- const onEsc = (e) => { if (e.key === 'Escape' && previewRow.value) closePreview() }
135
+ const onEsc = (e) => { if (e.key === 'Escape') { if (previewRow.value) closePreview(); else collapseDock() } }
136
+
137
+ // ─── Auto-close preview on outside click ──────────────────────────────────────
138
+ const onDocMousedown = (e) => {
139
+ if (props.autoClosePreview && previewRow.value && previewPanelRef.value && !previewPanelRef.value.contains(e.target)) {
140
+ closePreview()
141
+ }
142
+ }
143
+
144
+ // ─── Dock (minimizar preview) ──────────────────────────────────────────────────
145
+ const {
146
+ docked,
147
+ dock, undock: undockItem, isActive,
148
+ activeDockId, activeDockRect,
149
+ expandDock, collapseDock,
150
+ } = useDockedPreviews()
151
+ const route = useRoute()
152
+
153
+ function minimizePreview() {
154
+ if (!previewRow.value) return
155
+ const label = previewRow.value.name ?? previewRow.value.title ?? previewRow.value.email ?? String(previewRow.value.id)
156
+ const subtitle = previewRow.value.email ?? previewRow.value.description ?? null
157
+ dock({
158
+ id: `${resolvedName.value}-${previewRow.value.id}`,
159
+ label,
160
+ subtitle,
161
+ row: { ...previewRow.value },
162
+ tableName: resolvedName.value,
163
+ route: route.path,
164
+ })
165
+ closePreview()
166
+ }
167
+
168
+ // Item que debe mostrarse como mini-preview flotante (pertenece a esta tabla)
169
+ const floatingItem = computed(() =>
170
+ activeDockId.value
171
+ ? docked.value.find(d => d.id === activeDockId.value && d.tableName === resolvedName.value) ?? null
172
+ : null
173
+ )
174
+
175
+ // Posición del panel flotante: centrado sobre el tab que lo abrió
176
+ const floatingPanelStyle = computed(() => {
177
+ const rect = activeDockRect.value
178
+ const panelW = 384
179
+ const bottom = 52
180
+ if (!rect || typeof window === 'undefined') return { bottom: bottom + 'px', right: '16px' }
181
+ const tabCenter = rect.left + rect.width / 2
182
+ let right = window.innerWidth - tabCenter - panelW / 2
183
+ right = Math.max(8, Math.min(right, window.innerWidth - panelW - 8))
184
+ return { bottom: bottom + 'px', right: right + 'px' }
185
+ })
186
+
187
+ function expandToFull(item) {
188
+ previewRow.value = item.row
189
+ undockItem(item.id)
190
+ }
191
+
192
+ // Escuchar evento de restauración (fallback cuando la tabla no estaba montada)
193
+ onMounted(() => {
194
+ useNuxtApp().hooks.hook('preview:restore', (item) => {
195
+ if (item.tableName === resolvedName.value) previewRow.value = item.row
196
+ })
197
+ })
198
+
133
199
  onMounted(async () => {
134
200
  previewEnabled.value = !!slots.preview
135
201
  window.addEventListener('keydown', onEsc)
202
+ document.addEventListener('mousedown', onDocMousedown)
136
203
  // Restore preview from session cache — mark as from-cache to skip enter animation
137
204
  if (props.cached && previewEnabled.value) {
138
205
  try {
@@ -148,6 +215,7 @@ onMounted(async () => {
148
215
  })
149
216
  onBeforeUnmount(() => {
150
217
  window.removeEventListener('keydown', onEsc)
218
+ document.removeEventListener('mousedown', onDocMousedown)
151
219
  paginationObserver?.disconnect()
152
220
  })
153
221
 
@@ -224,7 +292,7 @@ const reload = () => tableRef.value?.reload()
224
292
  const clearCache = () => tableRef.value?.clearCache()
225
293
  const exportTable = (format, allPages, filteredRows) => tableRef.value?.exportTable(format, allPages, filteredRows)
226
294
 
227
- defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef })
295
+ defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef, closePreview })
228
296
  </script>
229
297
 
230
298
  <template>
@@ -332,6 +400,7 @@ defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef })
332
400
  >
333
401
  <div
334
402
  v-if="previewRow && previewEnabled"
403
+ ref="previewPanelRef"
335
404
  class="absolute top-0 right-0 z-30 flex bg-card border-l border-card-line shadow-[-4px_0_16px_rgba(0,0,0,0.06)]"
336
405
  :style="{ width: (100 - currentRatio) + '%', bottom: paginationHeight + 'px' }"
337
406
  >
@@ -343,9 +412,29 @@ defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef })
343
412
  <!-- Preview -->
344
413
  <div class="flex flex-col flex-1 overflow-hidden">
345
414
 
346
- <!-- Fixed header always visible regardless of active tab -->
347
- <div v-if="$slots['preview-header']" class="shrink-0 border-b border-card-line">
348
- <slot name="preview-header" :row="previewRow" :close="closePreview" />
415
+ <!-- Barra de acciones del preview -->
416
+ <div class="shrink-0 flex items-center justify-between gap-2 px-3 py-2 border-b border-card-line">
417
+ <div class="flex-1 min-w-0">
418
+ <slot name="preview-header" :row="previewRow" :close="closePreview" />
419
+ </div>
420
+ <div class="flex items-center gap-1 shrink-0">
421
+ <button
422
+ type="button"
423
+ class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
424
+ title="Minimizar"
425
+ @click.stop="minimizePreview"
426
+ >
427
+ <IconMinus class="size-3.5" />
428
+ </button>
429
+ <button
430
+ type="button"
431
+ class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
432
+ title="Cerrar"
433
+ @click.stop="closePreview"
434
+ >
435
+ <IconX class="size-3.5" />
436
+ </button>
437
+ </div>
349
438
  </div>
350
439
 
351
440
  <!-- Scrollable content -->
@@ -395,6 +484,46 @@ defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef })
395
484
  </div>
396
485
  </div>
397
486
 
487
+ <!-- ── Floating mini-preview (dock expand, estilo Gmail) ── -->
488
+ <Teleport to="body">
489
+ <Transition
490
+ enter-active-class="transition ease-out duration-200"
491
+ enter-from-class="opacity-0 translate-y-4"
492
+ enter-to-class="opacity-100 translate-y-0"
493
+ leave-active-class="transition ease-in duration-150"
494
+ leave-from-class="opacity-100 translate-y-0"
495
+ leave-to-class="opacity-0 translate-y-4"
496
+ >
497
+ <div
498
+ v-if="floatingItem"
499
+ class="fixed z-[60] w-96 flex flex-col bg-card border border-card-line rounded-t-xl shadow-2xl overflow-hidden"
500
+ :style="{ ...floatingPanelStyle, maxHeight: 'min(480px, calc(100vh - 60px))' }"
501
+ >
502
+ <div class="flex items-center gap-2 px-3 py-2.5 border-b border-card-line shrink-0 bg-surface select-none">
503
+ <span class="size-6 rounded-full bg-primary flex items-center justify-center text-[10px] font-bold text-primary-foreground shrink-0">
504
+ {{ (floatingItem.label?.[0] ?? '?').toUpperCase() }}
505
+ </span>
506
+ <div class="flex-1 min-w-0">
507
+ <p class="text-sm font-semibold text-foreground truncate leading-tight">{{ floatingItem.label }}</p>
508
+ <p v-if="floatingItem.subtitle" class="text-xs text-muted-foreground truncate">{{ floatingItem.subtitle }}</p>
509
+ </div>
510
+ <button type="button" title="Expandir" class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors" @click.stop="expandToFull(floatingItem)">
511
+ <IconMaximize class="size-3.5" />
512
+ </button>
513
+ <button type="button" title="Minimizar" class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors" @click.stop="collapseDock()">
514
+ <IconMinus class="size-3.5" />
515
+ </button>
516
+ <button type="button" title="Cerrar" class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors" @click.stop="undockItem(floatingItem.id)">
517
+ <IconX class="size-3.5" />
518
+ </button>
519
+ </div>
520
+ <div class="flex-1 overflow-y-auto min-h-0">
521
+ <slot name="preview" :row="floatingItem.row" :close="() => undockItem(floatingItem.id)" />
522
+ </div>
523
+ </div>
524
+ </Transition>
525
+ </Teleport>
526
+
398
527
  <!-- Column panel — teleported to body to escape overflow-hidden -->
399
528
  <Teleport to="body">
400
529
  <Transition
package/nuxt.config.ts CHANGED
@@ -2,6 +2,10 @@ import tailwindcss from '@tailwindcss/vite'
2
2
 
3
3
  export default defineNuxtConfig({
4
4
  extends: ['@innertia-solutions/nuxt-core'],
5
+ modules: [
6
+ '@pinia/nuxt',
7
+ 'pinia-plugin-persistedstate/nuxt', // required for dockedPreviews store persistence
8
+ ],
5
9
  css: ['@innertia-solutions/nuxt-theme-spark/spark.css'],
6
10
  components: [
7
11
  { path: './components', pathPrefix: true, prefix: '' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/nuxt-theme-spark",
3
- "version": "0.1.137",
3
+ "version": "0.1.139",
4
4
  "description": "Innertia Solutions — Spark theme: backoffice, landing and mobile components and layouts",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -29,6 +29,8 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@innertia-solutions/nuxt-core": "^0.1.4",
32
+ "@pinia/nuxt": "^0.11.3",
33
+ "pinia-plugin-persistedstate": "^4.7.1",
32
34
  "@tabler/icons-vue": "^3.44.0",
33
35
  "@tailwindcss/aspect-ratio": "^0.4.2",
34
36
  "@tailwindcss/forms": "^0.5.10",
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Sincroniza el store de previews minimizados entre pestañas del navegador.
3
+ * Escucha los eventos `storage` que dispara localStorage cuando otra pestaña escribe.
4
+ */
5
+ export default defineNuxtPlugin(() => {
6
+ const store = useDockedPreviewsStore()
7
+
8
+ window.addEventListener('storage', (event) => {
9
+ if (event.key !== 'docked-previews' || !event.newValue) return
10
+ try {
11
+ const persisted = JSON.parse(event.newValue)
12
+ store.hydrate(persisted.items ?? [])
13
+ } catch {
14
+ // JSON inválido — ignorar
15
+ }
16
+ })
17
+ })
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Composable para manejar los previews minimizados.
3
+ *
4
+ * - `docked` / `dock` / `undock` / `isActive` → Pinia store (persiste en localStorage, sync entre tabs)
5
+ * - `activeDockId` / `activeDockRect` / expand / collapse → estado de UI efímero (no persistido)
6
+ */
7
+ export function useDockedPreviews() {
8
+ const store = useDockedPreviewsStore()
9
+
10
+ // ─── UI state (no persiste) ───────────────────────────────────────────────────
11
+ const activeDockId = useState('docked-active-id', () => null)
12
+ const activeDockRect = useState('docked-active-rect', () => null)
13
+
14
+ // ─── Acceso reactivo a los items persistidos ──────────────────────────────────
15
+ const docked = computed(() => store.items)
16
+
17
+ // ─── Dock / undock ────────────────────────────────────────────────────────────
18
+ const dock = (payload) => store.add(payload)
19
+
20
+ const undock = (id) => {
21
+ store.remove(id)
22
+ if (activeDockId.value === id) {
23
+ activeDockId.value = null
24
+ activeDockRect.value = null
25
+ }
26
+ }
27
+
28
+ const isActive = (id) => !!store.items.find(d => d.id === id)
29
+
30
+ // ─── Panel flotante ───────────────────────────────────────────────────────────
31
+ const expandDock = (id, rect = null) => {
32
+ if (activeDockId.value === id) {
33
+ activeDockId.value = null
34
+ activeDockRect.value = null
35
+ } else {
36
+ activeDockId.value = id
37
+ activeDockRect.value = rect ? { left: rect.left, width: rect.width } : null
38
+ }
39
+ }
40
+
41
+ const collapseDock = () => {
42
+ activeDockId.value = null
43
+ activeDockRect.value = null
44
+ }
45
+
46
+ return {
47
+ docked,
48
+ activeDockId,
49
+ activeDockRect,
50
+ dock,
51
+ undock,
52
+ isActive,
53
+ expandDock,
54
+ collapseDock,
55
+ }
56
+ }
@@ -0,0 +1,34 @@
1
+ import { defineStore } from 'pinia'
2
+
3
+ // Storage SSR-safe: null en servidor, localStorage en cliente
4
+ const clientStorage = typeof window !== 'undefined' ? window.localStorage : null
5
+
6
+ /**
7
+ * Store persistido en localStorage para los previews minimizados.
8
+ * Sobrevive recargas y se sincroniza entre pestañas (via plugin dockedPreviewsSync).
9
+ */
10
+ export const useDockedPreviewsStore = defineStore('docked-previews', {
11
+ state: () => ({
12
+ items: [],
13
+ }),
14
+
15
+ actions: {
16
+ add({ id, label, subtitle, row, tableName, route }) {
17
+ if (this.items.find(d => d.id === id)) return
18
+ this.items.push({ id, label, subtitle: subtitle ?? null, row, tableName, route })
19
+ },
20
+
21
+ remove(id) {
22
+ this.items = this.items.filter(d => d.id !== id)
23
+ },
24
+
25
+ /** Sincroniza el estado desde otra pestaña (llamado por el plugin de storage). */
26
+ hydrate(items) {
27
+ this.items = items
28
+ },
29
+ },
30
+
31
+ persist: {
32
+ storage: clientStorage,
33
+ },
34
+ })
package/spark.css CHANGED
@@ -663,6 +663,24 @@
663
663
  scrollbar-gutter: stable;
664
664
  }
665
665
 
666
+ ::-webkit-scrollbar {
667
+ width: 6px;
668
+ height: 6px;
669
+ }
670
+ ::-webkit-scrollbar-track {
671
+ background: var(--scrollbar-track);
672
+ }
673
+ ::-webkit-scrollbar-thumb {
674
+ background: var(--scrollbar-thumb);
675
+ border-radius: 9999px;
676
+ }
677
+ ::-webkit-scrollbar-thumb:hover {
678
+ background: var(--muted-foreground-2);
679
+ }
680
+ ::-webkit-scrollbar-corner {
681
+ background: var(--scrollbar-track);
682
+ }
683
+
666
684
  button:not(:disabled),
667
685
  [role="button"]:not(:disabled) {
668
686
  cursor: pointer;