@innertia-solutions/nuxt-theme-spark 0.1.132 → 0.1.134

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.
@@ -0,0 +1,272 @@
1
+ <script setup>
2
+ import { IconSearch, IconFolder, IconFolderOpen, IconKey, IconStack2 } from '@tabler/icons-vue'
3
+
4
+ const props = defineProps({
5
+ apps: { type: Array, default: () => [] }, // [{ app, app_label, groups: [{ category, category_alias, permissions }] }]
6
+ modelValue: { type: Array, default: () => [] }, // selected permission names
7
+ readonly: { type: Boolean, default: false },
8
+ folderColor: { type: String, default: 'gray' },
9
+ })
10
+
11
+ const emit = defineEmits(['update:modelValue'])
12
+
13
+ const folderColorClass = computed(() => ({
14
+ gray: 'text-slate-400 dark:text-slate-500',
15
+ blue: 'text-blue-400 dark:text-blue-500',
16
+ amber: 'text-amber-400 dark:text-amber-500',
17
+ green: 'text-green-400 dark:text-green-500',
18
+ purple: 'text-purple-400 dark:text-purple-500',
19
+ rose: 'text-rose-400 dark:text-rose-500',
20
+ }[props.folderColor] ?? 'text-slate-400 dark:text-slate-500'))
21
+
22
+ const search = ref('')
23
+
24
+ // Flatten all permissions for search
25
+ const filteredApps = computed(() => {
26
+ const q = search.value.trim().toLowerCase()
27
+ if (!q) return props.apps
28
+
29
+ return props.apps
30
+ .map(app => ({
31
+ ...app,
32
+ groups: app.groups
33
+ .map(g => ({
34
+ ...g,
35
+ permissions: g.permissions.filter(p =>
36
+ p.name.toLowerCase().includes(q) || (p.description ?? '').toLowerCase().includes(q)
37
+ ),
38
+ }))
39
+ .filter(g => g.permissions.length > 0),
40
+ }))
41
+ .filter(app => app.groups.length > 0)
42
+ })
43
+
44
+ const visibleApps = computed(() => {
45
+ if (!props.readonly) return filteredApps.value
46
+ return filteredApps.value
47
+ .map(app => ({
48
+ ...app,
49
+ groups: app.groups
50
+ .map(g => ({
51
+ ...g,
52
+ permissions: g.permissions.filter(p => props.modelValue.includes(p.name)),
53
+ }))
54
+ .filter(g => g.permissions.length > 0),
55
+ }))
56
+ .filter(app => app.groups.length > 0)
57
+ })
58
+
59
+ // Collapse state — keyed as "app" or "app::category"
60
+ const collapsed = ref({})
61
+
62
+ watch(search, (q) => {
63
+ if (!q.trim()) return
64
+ filteredApps.value.forEach(app => {
65
+ collapsed.value[app.app] = false
66
+ app.groups.forEach(g => { collapsed.value[`${app.app}::${g.category}`] = false })
67
+ })
68
+ })
69
+
70
+ function isCollapsed(key) { return collapsed.value[key] ?? false }
71
+ function toggle(key) { collapsed.value[key] = !collapsed.value[key] }
72
+
73
+ function isSelected(name) { return props.modelValue.includes(name) }
74
+
75
+ function appPermNames(app) {
76
+ return app.groups.flatMap(g => g.permissions.map(p => p.name))
77
+ }
78
+
79
+ function groupPermNames(group) {
80
+ return group.permissions.map(p => p.name)
81
+ }
82
+
83
+ function appState(app) {
84
+ const names = appPermNames(app)
85
+ const n = names.filter(n => props.modelValue.includes(n)).length
86
+ return n === 0 ? 'none' : n === names.length ? 'all' : 'partial'
87
+ }
88
+
89
+ function groupState(group) {
90
+ const names = groupPermNames(group)
91
+ const n = names.filter(n => props.modelValue.includes(n)).length
92
+ return n === 0 ? 'none' : n === names.length ? 'all' : 'partial'
93
+ }
94
+
95
+ function toggleApp(app) {
96
+ const names = appPermNames(app)
97
+ const next = appState(app) === 'all'
98
+ ? props.modelValue.filter(n => !names.includes(n))
99
+ : [...new Set([...props.modelValue, ...names])]
100
+ emit('update:modelValue', next)
101
+ }
102
+
103
+ function toggleGroup(group) {
104
+ const names = groupPermNames(group)
105
+ const next = groupState(group) === 'all'
106
+ ? props.modelValue.filter(n => !names.includes(n))
107
+ : [...new Set([...props.modelValue, ...names])]
108
+ emit('update:modelValue', next)
109
+ }
110
+
111
+ function togglePermission(name) {
112
+ const next = isSelected(name)
113
+ ? props.modelValue.filter(n => n !== name)
114
+ : [...props.modelValue, name]
115
+ emit('update:modelValue', next)
116
+ }
117
+
118
+ function setIndeterminate(el, state) {
119
+ if (el) el.indeterminate = state === 'partial'
120
+ }
121
+
122
+ function appSelectedCount(app) {
123
+ return appPermNames(app).filter(n => props.modelValue.includes(n)).length
124
+ }
125
+ function appTotalCount(app) { return appPermNames(app).length }
126
+ function groupSelectedCount(group) {
127
+ return groupPermNames(group).filter(n => props.modelValue.includes(n)).length
128
+ }
129
+ </script>
130
+
131
+ <template>
132
+ <div class="w-full space-y-2">
133
+
134
+ <!-- Search -->
135
+ <div class="relative">
136
+ <IconSearch class="absolute left-2.5 top-1/2 -translate-y-1/2 size-3 text-slate-400 pointer-events-none" />
137
+ <input
138
+ v-model="search"
139
+ type="search"
140
+ placeholder="Buscar permiso…"
141
+ class="w-full pl-7 pr-3 py-1.5 text-xs bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-md text-slate-700 dark:text-slate-300 placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-blue-500/40 focus:border-blue-400"
142
+ />
143
+ </div>
144
+
145
+ <!-- Tree -->
146
+ <div class="rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 overflow-hidden text-sm">
147
+
148
+ <div v-if="visibleApps.length === 0" class="px-3 py-6 text-center text-xs text-slate-400 dark:text-slate-500">
149
+ {{ search ? 'Sin resultados.' : readonly ? 'Sin permisos asignados.' : 'Sin permisos disponibles.' }}
150
+ </div>
151
+
152
+ <template v-for="(app, ai) in visibleApps" :key="app.app">
153
+
154
+ <!-- ── App row (level 1) ── -->
155
+ <div
156
+ class="flex items-center gap-1.5 px-2 py-1.5 cursor-pointer select-none bg-slate-100 dark:bg-slate-800 hover:bg-slate-200/60 dark:hover:bg-slate-700/60 transition-colors"
157
+ :class="ai > 0 ? 'border-t border-slate-200 dark:border-slate-700' : ''"
158
+ @click="toggle(app.app)"
159
+ >
160
+ <input
161
+ v-if="!readonly"
162
+ type="checkbox"
163
+ class="size-3 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-0 cursor-pointer shrink-0"
164
+ :checked="appState(app) === 'all'"
165
+ :ref="el => setIndeterminate(el, appState(app))"
166
+ @click.stop
167
+ @change="toggleApp(app)"
168
+ />
169
+ <IconStack2 class="size-3.5 shrink-0" :class="folderColorClass" />
170
+ <span class="font-bold text-slate-700 dark:text-slate-200 flex-1 truncate tracking-wide uppercase text-[11px]">
171
+ {{ app.app_label || app.app }}
172
+ </span>
173
+ <span class="text-[11px] tabular-nums text-slate-400 dark:text-slate-500 shrink-0">
174
+ {{ readonly
175
+ ? appTotalCount(app)
176
+ : `${appSelectedCount(app)}/${appTotalCount(app)}`
177
+ }}
178
+ </span>
179
+ </div>
180
+
181
+ <template v-if="!isCollapsed(app.app)">
182
+ <template v-for="(group, gi) in app.groups" :key="group.category">
183
+
184
+ <!-- ── Category row (level 2) ── -->
185
+ <div
186
+ class="flex items-center gap-1 border-t border-slate-100 dark:border-slate-800 bg-slate-50 dark:bg-slate-800/40 hover:bg-slate-100 dark:hover:bg-slate-800/70 transition-colors cursor-pointer select-none"
187
+ @click="toggle(`${app.app}::${group.category}`)"
188
+ >
189
+ <!-- indent line -->
190
+ <div class="flex items-center shrink-0 pl-3" style="width:32px">
191
+ <div class="flex flex-col items-center h-full" style="width:10px">
192
+ <div class="w-px flex-1 bg-slate-200 dark:bg-slate-700" />
193
+ <div class="w-2.5 h-px bg-slate-200 dark:bg-slate-700" />
194
+ <div class="w-px flex-1 bg-slate-200 dark:bg-slate-700"
195
+ :class="gi === app.groups.length - 1 ? 'opacity-0' : ''" />
196
+ </div>
197
+ </div>
198
+
199
+ <input
200
+ v-if="!readonly"
201
+ type="checkbox"
202
+ class="size-3 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-0 cursor-pointer shrink-0"
203
+ :checked="groupState(group) === 'all'"
204
+ :ref="el => setIndeterminate(el, groupState(group))"
205
+ @click.stop
206
+ @change="toggleGroup(group)"
207
+ />
208
+ <IconFolderOpen v-if="!isCollapsed(`${app.app}::${group.category}`)" class="size-3.5 shrink-0" :class="folderColorClass" />
209
+ <IconFolder v-else class="size-3.5 shrink-0" :class="folderColorClass" />
210
+
211
+ <span class="font-semibold text-slate-600 dark:text-slate-300 flex-1 truncate py-1">
212
+ {{ group.category_alias ?? group.category }}
213
+ </span>
214
+ <span class="text-[11px] tabular-nums text-slate-400 dark:text-slate-500 pr-2 shrink-0">
215
+ {{ readonly
216
+ ? group.permissions.length
217
+ : `${groupSelectedCount(group)}/${group.permissions.length}`
218
+ }}
219
+ </span>
220
+ </div>
221
+
222
+ <!-- ── Permission rows (level 3) ── -->
223
+ <template v-if="!isCollapsed(`${app.app}::${group.category}`)">
224
+ <div
225
+ v-for="(perm, pi) in group.permissions"
226
+ :key="perm.name"
227
+ class="flex items-center gap-1 border-t border-slate-100 dark:border-slate-800 hover:bg-blue-50/50 dark:hover:bg-blue-500/5 transition-colors"
228
+ :class="!readonly ? 'cursor-pointer' : ''"
229
+ @click="!readonly && togglePermission(perm.name)"
230
+ >
231
+ <!-- double indent line -->
232
+ <div class="flex items-center shrink-0 pl-3" style="width:52px">
233
+ <div class="flex flex-col items-center h-full mr-1" style="width:10px">
234
+ <div class="w-px flex-1 bg-slate-200 dark:bg-slate-700" />
235
+ <div class="w-px flex-1 bg-slate-200 dark:bg-slate-700"
236
+ :class="gi === app.groups.length - 1 ? 'opacity-0' : ''" />
237
+ </div>
238
+ <div class="flex flex-col items-center h-full" style="width:10px">
239
+ <div class="w-px flex-1 bg-slate-200 dark:bg-slate-700" />
240
+ <div class="w-2.5 h-px bg-slate-200 dark:bg-slate-700" />
241
+ <div class="w-px flex-1 bg-slate-200 dark:bg-slate-700"
242
+ :class="pi === group.permissions.length - 1 ? 'opacity-0' : ''" />
243
+ </div>
244
+ </div>
245
+
246
+ <div class="shrink-0 flex items-center justify-center w-3.5">
247
+ <input
248
+ v-if="!readonly"
249
+ type="checkbox"
250
+ class="size-3 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-0 cursor-pointer"
251
+ :checked="isSelected(perm.name)"
252
+ @click.stop
253
+ @change="togglePermission(perm.name)"
254
+ />
255
+ <span v-else class="size-1.5 rounded-full bg-green-500 block" />
256
+ </div>
257
+
258
+ <IconKey class="size-3 text-slate-400 dark:text-slate-500 shrink-0" />
259
+ <span class="font-mono text-slate-700 dark:text-slate-300 truncate flex-1 py-1">{{ perm.name }}</span>
260
+ <span class="text-slate-400 dark:text-slate-500 truncate hidden sm:block pr-2" style="max-width:240px">
261
+ {{ perm.description ?? '' }}
262
+ </span>
263
+ </div>
264
+ </template>
265
+
266
+ </template>
267
+ </template>
268
+
269
+ </template>
270
+ </div>
271
+ </div>
272
+ </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/nuxt-theme-spark",
3
- "version": "0.1.132",
3
+ "version": "0.1.134",
4
4
  "description": "Innertia Solutions — Spark theme: backoffice, landing and mobile components and layouts",
5
5
  "keywords": [
6
6
  "nuxt",