@innertia-solutions/innertia-nuxt 0.1.1

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.
Files changed (108) hide show
  1. package/.github/workflows/auto-publish.yml +64 -0
  2. package/.github/workflows/release.yml +59 -0
  3. package/README.md +60 -0
  4. package/app.config.ts +70 -0
  5. package/components/Admin/Base.vue +144 -0
  6. package/components/Admin/Header.vue +32 -0
  7. package/components/Admin/Page.vue +65 -0
  8. package/components/Admin/PageHeader.vue +31 -0
  9. package/components/App/Button.vue +59 -0
  10. package/components/App/DevEnvironmentBar.vue +43 -0
  11. package/components/App/Dropdown.vue +286 -0
  12. package/components/App/EmptyState.vue +433 -0
  13. package/components/App/LoadingState.vue +40 -0
  14. package/components/App/PageLoadingSpinner.vue +118 -0
  15. package/components/App/PreviewDock.vue +64 -0
  16. package/components/App/SwitchColorTheme.vue +51 -0
  17. package/components/App/Tag.vue +193 -0
  18. package/components/DataTable.vue +713 -0
  19. package/components/Forms/DatePicker.vue +255 -0
  20. package/components/Forms/Input.vue +75 -0
  21. package/components/Forms/Select.vue +100 -0
  22. package/components/Forms/SelectServer.vue +726 -0
  23. package/components/Layout/Admin.vue +32 -0
  24. package/components/Layout/Auth.vue +29 -0
  25. package/components/Layout/SidebarWithAppColumn.vue +388 -0
  26. package/components/Layout/TopBar.vue +113 -0
  27. package/components/MobileBlocker.vue +85 -0
  28. package/components/MobileLoginPicker.vue +83 -0
  29. package/components/Modal/Base.vue +29 -0
  30. package/components/Modal/DeleteConfirm.vue +48 -0
  31. package/components/Modal.vue +103 -0
  32. package/components/Nav/Tabs.vue +55 -0
  33. package/components/PermissionsTree.vue +272 -0
  34. package/components/Table/Database.vue +183 -0
  35. package/components/Table/DownloadDropdown.vue +111 -0
  36. package/components/Table/Enterprise.vue +540 -0
  37. package/components/Table/FilterDropdown.vue +226 -0
  38. package/components/Table/Grid.vue +62 -0
  39. package/components/Table/Kanban.vue +188 -0
  40. package/components/Table/List.vue +128 -0
  41. package/components/Table/PreviewTimeline.vue +118 -0
  42. package/components/Table/Standard.vue +1217 -0
  43. package/components/Table/index.vue +974 -0
  44. package/components/TableExportable.vue +172 -0
  45. package/components/TableFilter.vue +93 -0
  46. package/components/Toast/Alert.vue +113 -0
  47. package/components/Toast/Container.vue +34 -0
  48. package/components/Toast/Notification.vue +45 -0
  49. package/components/Toast/Process.vue +88 -0
  50. package/composables/useApi.js +95 -0
  51. package/composables/useApp.ts +46 -0
  52. package/composables/useAuth.js +82 -0
  53. package/composables/useContext.js +44 -0
  54. package/composables/useDate.js +241 -0
  55. package/composables/useDevice.js +21 -0
  56. package/composables/useDockedPreviews.js +56 -0
  57. package/composables/useDownload.js +87 -0
  58. package/composables/useEntity.js +82 -0
  59. package/composables/useForm.js +119 -0
  60. package/composables/useInnertiaMode.ts +25 -0
  61. package/composables/useMobileGuard.ts +81 -0
  62. package/composables/useNotifications.js +22 -0
  63. package/composables/usePermissions.js +23 -0
  64. package/composables/useRealtime.js +123 -0
  65. package/composables/useRequestInterceptors.js +27 -0
  66. package/composables/useRoles.js +53 -0
  67. package/composables/useRutFormatter.js +39 -0
  68. package/composables/useTable.ts +94 -0
  69. package/composables/useTablePreferences.ts +33 -0
  70. package/composables/useTenant.js +27 -0
  71. package/composables/useTimeAgo.js +37 -0
  72. package/composables/useToast.js +69 -0
  73. package/composables/useUserRealtime.js +17 -0
  74. package/composables/useUsers.js +111 -0
  75. package/css/themes/autumn.css +401 -0
  76. package/css/themes/bubblegum.css +408 -0
  77. package/css/themes/cashmere.css +412 -0
  78. package/css/themes/harvest.css +416 -0
  79. package/css/themes/moon.css +140 -0
  80. package/css/themes/ocean.css +273 -0
  81. package/css/themes/olive.css +413 -0
  82. package/css/themes/retro.css +431 -0
  83. package/css/themes/theme.css +725 -0
  84. package/error.vue +78 -0
  85. package/middleware/01.detect-subdomain.global.ts +43 -0
  86. package/middleware/02.validate-tenant.global.ts +67 -0
  87. package/middleware/03.apps.global.ts +88 -0
  88. package/middleware/auth.ts +9 -0
  89. package/middleware/guest.ts +9 -0
  90. package/nuxt.config.ts +42 -0
  91. package/package.json +60 -0
  92. package/pages/tenant-error.vue +50 -0
  93. package/plugins/api-auth.ts +12 -0
  94. package/plugins/api-tenant.client.ts +21 -0
  95. package/plugins/appearance.ts +8 -0
  96. package/plugins/auth-init.ts +34 -0
  97. package/plugins/dark-state.client.ts +29 -0
  98. package/plugins/dockedPreviewsSync.client.js +17 -0
  99. package/plugins/preline.client.ts +68 -0
  100. package/plugins/theme.client.ts +7 -0
  101. package/plugins/vue-query.ts +29 -0
  102. package/public/init-theme.js +15 -0
  103. package/spark.css +721 -0
  104. package/stores/auth.js +130 -0
  105. package/stores/dockedPreviews.js +34 -0
  106. package/stores/notifications.js +24 -0
  107. package/stores/tenant.js +54 -0
  108. package/stores/toast.js +129 -0
@@ -0,0 +1,172 @@
1
+ <script setup>
2
+ import {
3
+ IconFileTypeXls,
4
+ IconFileTypeCsv,
5
+ IconFileTypePdf,
6
+ IconCodeDots,
7
+ IconDownload,
8
+ } from '@tabler/icons-vue'
9
+
10
+ const props = defineProps({
11
+ tableRef: { type: Object, default: null },
12
+ name: { type: String, default: 'export' },
13
+ columns: { type: Array, default: () => [] },
14
+ })
15
+
16
+ const isOpen = ref(false)
17
+ const format = ref('xlsx')
18
+ const filename = ref(props.name)
19
+ const selectedColumns = ref([])
20
+
21
+ watch(() => props.columns, (cols) => { selectedColumns.value = cols.map(c => c.key) }, { immediate: true })
22
+ watch(() => props.name, (v) => { filename.value = v })
23
+
24
+ const toggleColumn = (key) => {
25
+ const idx = selectedColumns.value.indexOf(key)
26
+ if (idx >= 0) selectedColumns.value.splice(idx, 1)
27
+ else selectedColumns.value.push(key)
28
+ }
29
+ const allSelected = computed(() => selectedColumns.value.length === props.columns.length)
30
+ const toggleAll = () => {
31
+ selectedColumns.value = allSelected.value ? [] : props.columns.map(c => c.key)
32
+ }
33
+
34
+ const formats = [
35
+ { value: 'xlsx', label: 'Excel' },
36
+ { value: 'csv', label: 'CSV' },
37
+ { value: 'pdf', label: 'PDF' },
38
+ { value: 'json', label: 'JSON' },
39
+ ]
40
+
41
+ const doExport = () => {
42
+ props.tableRef?.exportTable(format.value, true, true, selectedColumns.value)
43
+ isOpen.value = false
44
+ }
45
+
46
+ const panelRef = ref(null)
47
+ const triggerRef = ref(null)
48
+
49
+ const onOutsideClick = (e) => {
50
+ if (
51
+ panelRef.value && !panelRef.value.contains(e.target) &&
52
+ triggerRef.value && !triggerRef.value.contains(e.target)
53
+ ) {
54
+ isOpen.value = false
55
+ }
56
+ }
57
+
58
+ watch(isOpen, (v) => {
59
+ if (v) document.addEventListener('mousedown', onOutsideClick)
60
+ else document.removeEventListener('mousedown', onOutsideClick)
61
+ })
62
+
63
+ defineExpose({ open: () => { isOpen.value = true } })
64
+ </script>
65
+
66
+ <template>
67
+ <div class="relative">
68
+
69
+ <!-- Trigger — icon-only, igual al botón de columnas -->
70
+ <button
71
+ ref="triggerRef"
72
+ type="button"
73
+ @click="isOpen = !isOpen"
74
+ title="Exportar"
75
+ :class="[
76
+ 'p-1.5 inline-flex items-center justify-center rounded-lg border transition-colors',
77
+ isOpen
78
+ ? 'border-primary/40 bg-primary/10 text-primary'
79
+ : 'border-transparent text-muted-foreground hover:border-card-line hover:bg-muted-hover hover:text-foreground'
80
+ ]"
81
+ >
82
+ <IconDownload class="size-4" stroke="1.5" />
83
+ </button>
84
+
85
+ <Transition
86
+ enter-active-class="transition ease-out duration-150"
87
+ enter-from-class="opacity-0 translate-y-1 scale-95"
88
+ enter-to-class="opacity-100 translate-y-0 scale-100"
89
+ leave-active-class="transition ease-in duration-100"
90
+ leave-from-class="opacity-100 translate-y-0 scale-100"
91
+ leave-to-class="opacity-0 translate-y-1 scale-95"
92
+ >
93
+ <div
94
+ v-if="isOpen"
95
+ ref="panelRef"
96
+ class="absolute top-full right-0 z-50 mt-1.5 bg-dropdown border border-dropdown-line rounded-xl shadow-2xl p-3 w-64"
97
+ >
98
+ <p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-3 px-1">Exportar</p>
99
+
100
+ <!-- Format -->
101
+ <div class="grid grid-cols-4 gap-1.5 mb-3">
102
+ <button
103
+ v-for="f in formats"
104
+ :key="f.value"
105
+ type="button"
106
+ @click="format = f.value"
107
+ :class="[
108
+ 'flex flex-col items-center gap-1 py-2 rounded-lg border text-xs font-medium transition-colors',
109
+ format === f.value
110
+ ? 'border-primary/40 bg-primary/10 text-primary'
111
+ : 'border-card-line text-muted-foreground-1 hover:bg-muted-hover'
112
+ ]"
113
+ >
114
+ <IconFileTypeXls v-if="f.value === 'xlsx'" class="size-4" stroke="1.5" />
115
+ <IconFileTypeCsv v-else-if="f.value === 'csv'" class="size-4" stroke="1.5" />
116
+ <IconFileTypePdf v-else-if="f.value === 'pdf'" class="size-4" stroke="1.5" />
117
+ <IconCodeDots v-else class="size-4" stroke="1.5" />
118
+ {{ f.label }}
119
+ </button>
120
+ </div>
121
+
122
+ <!-- Filename -->
123
+ <div class="mb-3 px-1">
124
+ <label class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest block mb-1.5">Archivo</label>
125
+ <div class="flex items-center gap-1.5">
126
+ <input
127
+ v-model="filename"
128
+ type="text"
129
+ class="flex-1 rounded-lg border border-card-line bg-card text-foreground py-1.5 px-2.5 text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 min-w-0"
130
+ />
131
+ <span class="text-xs text-muted-foreground shrink-0">.{{ format }}</span>
132
+ </div>
133
+ </div>
134
+
135
+ <!-- Columns -->
136
+ <div v-if="columns.length > 0" class="mb-3 px-1">
137
+ <div class="flex items-center justify-between mb-1.5">
138
+ <label class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">Columnas</label>
139
+ <button type="button" @click="toggleAll" class="text-[10px] text-primary hover:underline">
140
+ {{ allSelected ? 'Ninguna' : 'Todas' }}
141
+ </button>
142
+ </div>
143
+ <div class="max-h-32 overflow-y-auto space-y-0.5">
144
+ <label
145
+ v-for="col in columns"
146
+ :key="col.key"
147
+ class="flex items-center gap-2 py-1 px-1.5 rounded-lg hover:bg-muted-hover cursor-pointer"
148
+ >
149
+ <input
150
+ type="checkbox"
151
+ :checked="selectedColumns.includes(col.key)"
152
+ @change="toggleColumn(col.key)"
153
+ class="rounded border-card-line bg-surface shrink-0 cursor-pointer text-primary"
154
+ />
155
+ <span class="text-xs text-foreground truncate">{{ col.label }}</span>
156
+ </label>
157
+ </div>
158
+ </div>
159
+
160
+ <!-- Export button -->
161
+ <button
162
+ type="button"
163
+ @click="doExport"
164
+ class="w-full py-1.5 px-3 rounded-lg bg-primary hover:bg-primary/90 text-white text-sm font-medium transition-colors inline-flex items-center justify-center gap-2"
165
+ >
166
+ <IconDownload class="size-4" stroke="1.5" />
167
+ Exportar
168
+ </button>
169
+ </div>
170
+ </Transition>
171
+ </div>
172
+ </template>
@@ -0,0 +1,93 @@
1
+ <script setup>
2
+ const props = defineProps({
3
+ modelValue: { type: Object, default: () => ({}) },
4
+ columns: { type: Array, required: true },
5
+ })
6
+
7
+ const emit = defineEmits(['update:modelValue'])
8
+
9
+ const filterableColumns = computed(() => props.columns.filter(c => c.filterType))
10
+
11
+ const localFilters = ref({ ...props.modelValue })
12
+
13
+ watch(() => props.modelValue, (v) => {
14
+ localFilters.value = { ...v }
15
+ }, { deep: true })
16
+
17
+ const updateFilter = (key, value) => {
18
+ localFilters.value[key] = value || null
19
+ emit('update:modelValue', { ...localFilters.value })
20
+ }
21
+
22
+ const clearAll = () => {
23
+ localFilters.value = {}
24
+ emit('update:modelValue', {})
25
+ }
26
+
27
+ const activeCount = computed(() =>
28
+ Object.values(localFilters.value).filter(v => v !== null && v !== undefined && v !== '').length
29
+ )
30
+ </script>
31
+
32
+ <template>
33
+ <div class="space-y-3">
34
+ <template v-for="col in filterableColumns" :key="col.key">
35
+
36
+ <!-- text -->
37
+ <div v-if="col.filterType === 'text'">
38
+ <label class="block text-xs font-medium text-muted-foreground mb-1">{{ col.label }}</label>
39
+ <input
40
+ type="text"
41
+ :value="localFilters[col.key] ?? ''"
42
+ @input="updateFilter(col.key, $event.target.value)"
43
+ :placeholder="`Filtrar ${col.label.toLowerCase()}...`"
44
+ class="w-full rounded-lg border border-card-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
45
+ />
46
+ </div>
47
+
48
+ <!-- select -->
49
+ <div v-else-if="col.filterType === 'select'">
50
+ <Forms.Select
51
+ :model-value="localFilters[col.key] ?? ''"
52
+ @update:model-value="updateFilter(col.key, $event)"
53
+ :options="[{ value: '', label: 'Todos' }, ...(col.filterOptions ?? [])]"
54
+ :label="col.label"
55
+ />
56
+ </div>
57
+
58
+ <!-- daterange -->
59
+ <div v-else-if="col.filterType === 'daterange'">
60
+ <label class="block text-xs font-medium text-muted-foreground mb-1">{{ col.label }}</label>
61
+ <div class="flex items-center gap-1.5">
62
+ <input
63
+ type="date"
64
+ :value="localFilters[col.key]?.from ?? ''"
65
+ @change="updateFilter(col.key, { ...localFilters[col.key], from: $event.target.value || null })"
66
+ class="flex-1 rounded-lg border border-card-line bg-card text-foreground py-1.5 px-2 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
67
+ />
68
+ <span class="text-slate-400 text-xs shrink-0">—</span>
69
+ <input
70
+ type="date"
71
+ :value="localFilters[col.key]?.to ?? ''"
72
+ @change="updateFilter(col.key, { ...localFilters[col.key], to: $event.target.value || null })"
73
+ class="flex-1 rounded-lg border border-card-line bg-card text-foreground py-1.5 px-2 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
74
+ />
75
+ </div>
76
+ </div>
77
+
78
+ </template>
79
+
80
+ <div class="pt-2 border-t border-card-line">
81
+ <button
82
+ v-if="activeCount > 0"
83
+ type="button"
84
+ @click="clearAll"
85
+ class="w-full py-1.5 px-3 text-sm text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10 rounded-lg transition-colors flex items-center justify-center gap-1.5"
86
+ >
87
+ <svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
88
+ Limpiar filtros
89
+ </button>
90
+ <p v-else class="text-xs text-center text-muted-foreground py-0.5">Sin filtros activos</p>
91
+ </div>
92
+ </div>
93
+ </template>
@@ -0,0 +1,113 @@
1
+ <template>
2
+ <div
3
+ class="max-w-xs relative bg-card border border-card-line rounded-xl shadow-lg p-4 pr-10 flex items-start overflow-hidden"
4
+ :class="{
5
+ 'border-green-200': toast.severity === 'success',
6
+ 'border-red-200': toast.severity === 'danger',
7
+ 'border-yellow-200': toast.severity === 'warning',
8
+ 'border-blue-200': toast.severity === 'info',
9
+ }"
10
+ role="alert"
11
+ >
12
+ <div class="mr-3 mt-1">
13
+ <i v-if="toast.icon" :class="toast.icon + ' text-gray-400 text-xl'" />
14
+ </div>
15
+ <div class="flex-1 mt-1">
16
+ <h3 v-if="toast.title" class="font-semibold text-sm text-gray-800">
17
+ {{ toast.title }}
18
+ </h3>
19
+ <div class="text-sm dark:text-white text-gray-600" v-html="toast.message"></div>
20
+ </div>
21
+ <button
22
+ class="ml-3 text-gray-400 hover:text-gray-700 absolute top-2 right-2"
23
+ @click="$emit('close')"
24
+ >
25
+ <span class="sr-only">Cerrar</span>
26
+ <svg
27
+ class="size-4"
28
+ viewBox="0 0 24 24"
29
+ fill="none"
30
+ stroke="currentColor"
31
+ stroke-width="2"
32
+ stroke-linecap="round"
33
+ stroke-linejoin="round"
34
+ >
35
+ <path d="M18 6 6 18" />
36
+ <path d="m6 6 12 12" />
37
+ </svg>
38
+ </button>
39
+
40
+ <!-- Barra de progreso temporal -->
41
+ <div
42
+ v-if="toast.duration && toast.duration > 0"
43
+ class="absolute bottom-0 left-0 w-full h-1 bg-card-line"
44
+ >
45
+ <div
46
+ class="h-full bg-gradient-to-r"
47
+ :class="{
48
+ 'from-green-400 to-green-600': toast.severity === 'success',
49
+ 'from-red-400 to-red-600': toast.severity === 'danger',
50
+ 'from-yellow-400 to-yellow-600': toast.severity === 'warning',
51
+ 'from-blue-400 to-blue-600': toast.severity === 'info',
52
+ }"
53
+ :style="{ width: progressWidth + '%' }"
54
+ ></div>
55
+ </div>
56
+ </div>
57
+ </template>
58
+
59
+ <script setup>
60
+ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
61
+
62
+ const props = defineProps({ toast: Object });
63
+
64
+ const progressWidth = ref(100)
65
+ const startTime = ref(null)
66
+ let animationFrame = null
67
+
68
+ // Computed para detectar cambios en duration
69
+ const duration = computed(() => props.toast?.duration || 0)
70
+
71
+ // Función para actualizar el progreso
72
+ const updateProgress = () => {
73
+ if (!startTime.value || duration.value <= 0) {
74
+ progressWidth.value = 100
75
+ return
76
+ }
77
+
78
+ const elapsed = Date.now() - startTime.value
79
+ const remaining = Math.max(0, duration.value - elapsed)
80
+ progressWidth.value = (remaining / duration.value) * 100
81
+
82
+ if (remaining > 0) {
83
+ animationFrame = requestAnimationFrame(updateProgress)
84
+ }
85
+ }
86
+
87
+ // Watch para reiniciar cuando cambie el duration
88
+ watch(duration, (newDuration) => {
89
+ if (animationFrame) {
90
+ cancelAnimationFrame(animationFrame)
91
+ }
92
+
93
+ if (newDuration && newDuration > 0) {
94
+ startTime.value = Date.now()
95
+ updateProgress()
96
+ } else {
97
+ progressWidth.value = 100
98
+ }
99
+ }, { immediate: true })
100
+
101
+ onMounted(() => {
102
+ if (duration.value > 0) {
103
+ startTime.value = Date.now()
104
+ updateProgress()
105
+ }
106
+ })
107
+
108
+ onUnmounted(() => {
109
+ if (animationFrame) {
110
+ cancelAnimationFrame(animationFrame)
111
+ }
112
+ })
113
+ </script>
@@ -0,0 +1,34 @@
1
+ <script setup>
2
+ const toastStore = useToastStore()
3
+
4
+ const positions = [
5
+ 'top-left', 'top-center', 'top-right',
6
+ 'bottom-left', 'bottom-center', 'bottom-right',
7
+ ]
8
+
9
+ const positionClass = {
10
+ 'top-left': 'top-4 left-4',
11
+ 'top-center': 'top-4 left-1/2 -translate-x-1/2',
12
+ 'top-right': 'top-4 right-4',
13
+ 'bottom-left': 'bottom-4 left-4',
14
+ 'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2',
15
+ 'bottom-right': 'bottom-4 right-4',
16
+ }
17
+ </script>
18
+
19
+ <template>
20
+ <template v-for="pos in positions" :key="pos">
21
+ <div
22
+ v-if="toastStore.toasts[pos]?.length"
23
+ class="fixed z-50 flex flex-col gap-2"
24
+ :class="positionClass[pos]"
25
+ >
26
+ <ToastAlert
27
+ v-for="toast in toastStore.toasts[pos]"
28
+ :key="toast.id"
29
+ :toast="toast"
30
+ @close="toastStore.remove(toast.id)"
31
+ />
32
+ </div>
33
+ </template>
34
+ </template>
@@ -0,0 +1,45 @@
1
+ <template>
2
+ <div
3
+ class="max-w-xs bg-white border border-gray-200 rounded-xl shadow-lg p-4 flex"
4
+ role="alert"
5
+ >
6
+ <div class="shrink-0">
7
+ <svg
8
+ class="size-5 text-gray-600 mt-1"
9
+ xmlns="http://www.w3.org/2000/svg"
10
+ width="24"
11
+ height="24"
12
+ viewBox="0 0 24 24"
13
+ fill="none"
14
+ stroke="currentColor"
15
+ stroke-width="2"
16
+ stroke-linecap="round"
17
+ stroke-linejoin="round"
18
+ >
19
+ <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
20
+ <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
21
+ </svg>
22
+ </div>
23
+ <div class="ms-4">
24
+ <h3 v-if="toast.title" class="text-gray-800 font-semibold">
25
+ {{ toast.title }}
26
+ </h3>
27
+ <div class="mt-1 text-sm text-gray-600" v-html="toast.message"></div>
28
+ <div v-if="toast.actions" class="mt-4">
29
+ <div class="flex gap-x-3">
30
+ <button
31
+ v-for="(action, i) in toast.actions"
32
+ :key="i"
33
+ @click="action.onClick"
34
+ class="text-blue-600 decoration-2 hover:underline font-medium text-sm focus:outline-hidden focus:underline"
35
+ >
36
+ {{ action.label }}
37
+ </button>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ </template>
43
+ <script setup>
44
+ defineProps({ toast: Object });
45
+ </script>
@@ -0,0 +1,88 @@
1
+ <template>
2
+ <div
3
+ class="max-w-xs relative bg-white border border-gray-200 rounded-xl shadow-lg"
4
+ role="alert"
5
+ >
6
+ <div class="flex gap-x-3 p-4">
7
+ <div class="shrink-0">
8
+ <span
9
+ class="m-1 inline-flex justify-center items-center size-8 rounded-full bg-gray-100 text-gray-800"
10
+ >
11
+ <svg
12
+ class="shrink-0 size-4"
13
+ xmlns="http://www.w3.org/2000/svg"
14
+ width="24"
15
+ height="24"
16
+ viewBox="0 0 24 24"
17
+ fill="none"
18
+ stroke="currentColor"
19
+ stroke-width="2"
20
+ stroke-linecap="round"
21
+ stroke-linejoin="round"
22
+ >
23
+ <path
24
+ d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"
25
+ />
26
+ <path d="M12 12v9" />
27
+ <path d="m16 16-4-4-4 4" />
28
+ </svg>
29
+ </span>
30
+ <button
31
+ class="absolute top-3 end-3 inline-flex shrink-0 justify-center items-center size-5 rounded-lg text-gray-800 opacity-50 hover:opacity-100 focus:outline-hidden focus:opacity-100"
32
+ @click="$emit('close')"
33
+ aria-label="Close"
34
+ >
35
+ <span class="sr-only">Close</span>
36
+ <svg
37
+ class="shrink-0 size-4"
38
+ xmlns="http://www.w3.org/2000/svg"
39
+ width="24"
40
+ height="24"
41
+ viewBox="0 0 24 24"
42
+ fill="none"
43
+ stroke="currentColor"
44
+ stroke-width="2"
45
+ stroke-linecap="round"
46
+ stroke-linejoin="round"
47
+ >
48
+ <path d="M18 6 6 18" />
49
+ <path d="m6 6 12 12" />
50
+ </svg>
51
+ </button>
52
+ </div>
53
+ <div class="grow me-5">
54
+ <h3 v-if="toast.title" class="text-gray-800 font-medium text-sm">
55
+ {{ toast.title }}
56
+ </h3>
57
+ <div
58
+ v-if="toast.progress !== undefined"
59
+ class="mt-2 flex flex-col gap-x-3"
60
+ >
61
+ <span class="block mb-1.5 text-xs text-gray-500">{{
62
+ toast.progressLabel
63
+ }}</span>
64
+ <div
65
+ class="flex w-full h-1 bg-gray-200 rounded-full overflow-hidden"
66
+ role="progressbar"
67
+ :aria-valuenow="toast.progress"
68
+ aria-valuemin="0"
69
+ aria-valuemax="100"
70
+ >
71
+ <div
72
+ class="flex flex-col justify-center overflow-hidden bg-blue-600 text-xs text-white text-center whitespace-nowrap"
73
+ :style="{ width: toast.progress + '%' }"
74
+ ></div>
75
+ </div>
76
+ </div>
77
+ <div
78
+ v-else
79
+ class="mt-2 text-sm text-gray-600"
80
+ v-html="toast.message"
81
+ ></div>
82
+ </div>
83
+ </div>
84
+ </div>
85
+ </template>
86
+ <script setup>
87
+ defineProps({ toast: Object });
88
+ </script>
@@ -0,0 +1,95 @@
1
+ // useRequestInterceptors auto-imported from nuxt-core
2
+ // useAuthStore auto-imported from this package
3
+
4
+ export function useApi() {
5
+ const config = useRuntimeConfig()
6
+ // On the server, use the private internal URL (e.g. http://api:80) because relative URLs
7
+ // don't resolve in Node.js. On the client, use the public apiBaseUrl (/api proxy).
8
+ const baseUrl = import.meta.server
9
+ ? (config.apiInternalUrl || config.public.apiBaseUrl || '/api')
10
+ : (config.public.apiBaseUrl || '/api')
11
+ const loginPath = config.public.loginPath || '/login'
12
+
13
+ const { run, add } = useRequestInterceptors()
14
+
15
+ function serializeParams(obj, prefix = '') {
16
+ const parts = []
17
+ for (const [key, val] of Object.entries(obj)) {
18
+ if (val === null || val === undefined) continue
19
+ const fullKey = prefix ? `${prefix}[${key}]` : key
20
+ if (Array.isArray(val)) {
21
+ val.forEach((item, i) => {
22
+ if (item !== null && typeof item === 'object') {
23
+ parts.push(...serializeParams(item, `${fullKey}[${i}]`).split('&').filter(Boolean))
24
+ } else {
25
+ parts.push(`${encodeURIComponent(`${fullKey}[${i}]`)}=${encodeURIComponent(item)}`)
26
+ }
27
+ })
28
+ } else if (typeof val === 'object') {
29
+ parts.push(serializeParams(val, fullKey))
30
+ } else {
31
+ parts.push(`${encodeURIComponent(fullKey)}=${encodeURIComponent(val)}`)
32
+ }
33
+ }
34
+ return parts.join('&')
35
+ }
36
+
37
+ async function makeRequest(method, path, body = null, options = {}) {
38
+ const headers = {
39
+ 'Content-Type': 'application/json',
40
+ 'Accept': 'application/json',
41
+ 'X-Innertia-Source': import.meta.server ? 'ssr' : 'client',
42
+ }
43
+ run(headers, options)
44
+
45
+ const cleanPath = path.startsWith('/') ? path.slice(1) : path
46
+ let url = `${baseUrl}/${cleanPath}`
47
+
48
+ if (options.params && Object.keys(options.params).length) {
49
+ const qs = serializeParams(options.params)
50
+ if (qs) url += '?' + qs
51
+ }
52
+
53
+ const fetchOptions = { method, headers }
54
+ if (body !== null) fetchOptions.body = JSON.stringify(body)
55
+
56
+ const response = await fetch(url, fetchOptions)
57
+
58
+ if (response.status === 401) {
59
+ const authStore = useAuthStore()
60
+ authStore.logout()
61
+ await navigateTo(loginPath)
62
+ return null
63
+ }
64
+
65
+ const contentType = response.headers.get('content-type') ?? ''
66
+ const data = contentType.includes('application/json') ? await response.json() : await response.text()
67
+
68
+ if (!response.ok) {
69
+ const err = new Error(`API error ${response.status}`)
70
+ err.status = response.status
71
+ err.data = data
72
+ throw err
73
+ }
74
+
75
+ return data
76
+ }
77
+
78
+ const get = (path, options = {}) => makeRequest('GET', path, null, options)
79
+ const post = (path, body, options = {}) => makeRequest('POST', path, body, options)
80
+ const put = (path, body, options = {}) => makeRequest('PUT', path, body, options)
81
+ const patch = (path, body, options = {}) => makeRequest('PATCH', path, body, options)
82
+ const del = (path, options = {}) => makeRequest('DELETE', path, null, options)
83
+
84
+ // *Sync aliases — same methods, named for clarity in call sites
85
+ const getSync = get
86
+ const postSync = post
87
+ const putSync = put
88
+ const patchSync = patch
89
+ const deleteSync = del
90
+
91
+ /** Shortcut to add an interceptor from a composable or plugin */
92
+ const addInterceptor = (fn) => add(fn)
93
+
94
+ return { get, post, put, patch, delete: del, getSync, postSync, putSync, patchSync, deleteSync, addInterceptor }
95
+ }
@@ -0,0 +1,46 @@
1
+ import type { AppDefinition } from '../app.config'
2
+
3
+ /**
4
+ * Devuelve metadata del app actual + listas filtradas por permisos del usuario.
5
+ * Lee la declaración de apps desde `appConfig.innertia.apps` (configurable por
6
+ * el producto en su `nuxt.config.ts`).
7
+ *
8
+ * Uso típico:
9
+ * const { current, accessible } = useApp()
10
+ * <h1>Estás en: {{ current?.label }}</h1>
11
+ */
12
+ export function useApp() {
13
+ const route = useRoute()
14
+ const authStore = useAuthStore()
15
+ const appConfig = useAppConfig()
16
+
17
+ /** Diccionario de apps declarados por el producto. */
18
+ const apps = computed<Record<string, AppDefinition>>(() =>
19
+ (appConfig.innertia?.apps ?? {}) as Record<string, AppDefinition>
20
+ )
21
+
22
+ /** Todos los apps declarados, en orden de declaración. */
23
+ const all = computed<AppDefinition[]>(() => Object.values(apps.value))
24
+
25
+ /** App actual, determinado por el prefijo de la ruta. `null` si la URL no cae en ningún app. */
26
+ const current = computed<AppDefinition | null>(() => {
27
+ return all.value.find(app =>
28
+ route.path === app.path || route.path.startsWith(app.path + '/')
29
+ ) ?? null
30
+ })
31
+
32
+ /** Apps a los que el usuario autenticado tiene acceso (filtrado por `availableContexts`). */
33
+ const accessible = computed<AppDefinition[]>(() => {
34
+ const ctxs = (authStore.availableContexts ?? []) as string[]
35
+ return all.value.filter(app => ctxs.includes(app.context))
36
+ })
37
+
38
+ /** Helper: ¿el usuario puede acceder a este app? */
39
+ function canAccess(appKey: string): boolean {
40
+ const app = apps.value[appKey]
41
+ if (!app) return false
42
+ return ((authStore.availableContexts ?? []) as string[]).includes(app.context)
43
+ }
44
+
45
+ return { current, all, accessible, canAccess, apps }
46
+ }