@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.
- package/.github/workflows/auto-publish.yml +64 -0
- package/.github/workflows/release.yml +59 -0
- package/README.md +60 -0
- package/app.config.ts +70 -0
- package/components/Admin/Base.vue +144 -0
- package/components/Admin/Header.vue +32 -0
- package/components/Admin/Page.vue +65 -0
- package/components/Admin/PageHeader.vue +31 -0
- package/components/App/Button.vue +59 -0
- package/components/App/DevEnvironmentBar.vue +43 -0
- package/components/App/Dropdown.vue +286 -0
- package/components/App/EmptyState.vue +433 -0
- package/components/App/LoadingState.vue +40 -0
- package/components/App/PageLoadingSpinner.vue +118 -0
- package/components/App/PreviewDock.vue +64 -0
- package/components/App/SwitchColorTheme.vue +51 -0
- package/components/App/Tag.vue +193 -0
- package/components/DataTable.vue +713 -0
- package/components/Forms/DatePicker.vue +255 -0
- package/components/Forms/Input.vue +75 -0
- package/components/Forms/Select.vue +100 -0
- package/components/Forms/SelectServer.vue +726 -0
- package/components/Layout/Admin.vue +32 -0
- package/components/Layout/Auth.vue +29 -0
- package/components/Layout/SidebarWithAppColumn.vue +388 -0
- package/components/Layout/TopBar.vue +113 -0
- package/components/MobileBlocker.vue +85 -0
- package/components/MobileLoginPicker.vue +83 -0
- package/components/Modal/Base.vue +29 -0
- package/components/Modal/DeleteConfirm.vue +48 -0
- package/components/Modal.vue +103 -0
- package/components/Nav/Tabs.vue +55 -0
- package/components/PermissionsTree.vue +272 -0
- package/components/Table/Database.vue +183 -0
- package/components/Table/DownloadDropdown.vue +111 -0
- package/components/Table/Enterprise.vue +540 -0
- package/components/Table/FilterDropdown.vue +226 -0
- package/components/Table/Grid.vue +62 -0
- package/components/Table/Kanban.vue +188 -0
- package/components/Table/List.vue +128 -0
- package/components/Table/PreviewTimeline.vue +118 -0
- package/components/Table/Standard.vue +1217 -0
- package/components/Table/index.vue +974 -0
- package/components/TableExportable.vue +172 -0
- package/components/TableFilter.vue +93 -0
- package/components/Toast/Alert.vue +113 -0
- package/components/Toast/Container.vue +34 -0
- package/components/Toast/Notification.vue +45 -0
- package/components/Toast/Process.vue +88 -0
- package/composables/useApi.js +95 -0
- package/composables/useApp.ts +46 -0
- package/composables/useAuth.js +82 -0
- package/composables/useContext.js +44 -0
- package/composables/useDate.js +241 -0
- package/composables/useDevice.js +21 -0
- package/composables/useDockedPreviews.js +56 -0
- package/composables/useDownload.js +87 -0
- package/composables/useEntity.js +82 -0
- package/composables/useForm.js +119 -0
- package/composables/useInnertiaMode.ts +25 -0
- package/composables/useMobileGuard.ts +81 -0
- package/composables/useNotifications.js +22 -0
- package/composables/usePermissions.js +23 -0
- package/composables/useRealtime.js +123 -0
- package/composables/useRequestInterceptors.js +27 -0
- package/composables/useRoles.js +53 -0
- package/composables/useRutFormatter.js +39 -0
- package/composables/useTable.ts +94 -0
- package/composables/useTablePreferences.ts +33 -0
- package/composables/useTenant.js +27 -0
- package/composables/useTimeAgo.js +37 -0
- package/composables/useToast.js +69 -0
- package/composables/useUserRealtime.js +17 -0
- package/composables/useUsers.js +111 -0
- package/css/themes/autumn.css +401 -0
- package/css/themes/bubblegum.css +408 -0
- package/css/themes/cashmere.css +412 -0
- package/css/themes/harvest.css +416 -0
- package/css/themes/moon.css +140 -0
- package/css/themes/ocean.css +273 -0
- package/css/themes/olive.css +413 -0
- package/css/themes/retro.css +431 -0
- package/css/themes/theme.css +725 -0
- package/error.vue +78 -0
- package/middleware/01.detect-subdomain.global.ts +43 -0
- package/middleware/02.validate-tenant.global.ts +67 -0
- package/middleware/03.apps.global.ts +88 -0
- package/middleware/auth.ts +9 -0
- package/middleware/guest.ts +9 -0
- package/nuxt.config.ts +42 -0
- package/package.json +60 -0
- package/pages/tenant-error.vue +50 -0
- package/plugins/api-auth.ts +12 -0
- package/plugins/api-tenant.client.ts +21 -0
- package/plugins/appearance.ts +8 -0
- package/plugins/auth-init.ts +34 -0
- package/plugins/dark-state.client.ts +29 -0
- package/plugins/dockedPreviewsSync.client.js +17 -0
- package/plugins/preline.client.ts +68 -0
- package/plugins/theme.client.ts +7 -0
- package/plugins/vue-query.ts +29 -0
- package/public/init-theme.js +15 -0
- package/spark.css +721 -0
- package/stores/auth.js +130 -0
- package/stores/dockedPreviews.js +34 -0
- package/stores/notifications.js +24 -0
- package/stores/tenant.js +54 -0
- 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
|
+
}
|