adminforth 2.12.11 → 2.13.0-next.2

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 (49) hide show
  1. package/dist/dataConnectors/baseConnector.js +1 -1
  2. package/dist/dataConnectors/baseConnector.js.map +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +3 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/modules/configValidator.d.ts.map +1 -1
  7. package/dist/modules/configValidator.js +12 -5
  8. package/dist/modules/configValidator.js.map +1 -1
  9. package/dist/modules/restApi.d.ts.map +1 -1
  10. package/dist/modules/restApi.js +30 -3
  11. package/dist/modules/restApi.js.map +1 -1
  12. package/dist/spa/package-lock.json +1406 -749
  13. package/dist/spa/package.json +32 -32
  14. package/dist/spa/src/App.vue +87 -14
  15. package/dist/spa/src/adminforth.ts +2 -2
  16. package/dist/spa/src/afcl/AreaChart.vue +0 -1
  17. package/dist/spa/src/afcl/Dropzone.vue +138 -41
  18. package/dist/spa/src/afcl/Table.vue +114 -15
  19. package/dist/spa/src/afcl/VerticalTabs.vue +5 -0
  20. package/dist/spa/src/components/Filters.vue +2 -2
  21. package/dist/spa/src/components/GroupsTable.vue +1 -1
  22. package/dist/spa/src/components/MenuLink.vue +11 -6
  23. package/dist/spa/src/components/ResourceForm.vue +5 -0
  24. package/dist/spa/src/components/ResourceListTable.vue +12 -16
  25. package/dist/spa/src/components/ResourceListTableVirtual.vue +10 -13
  26. package/dist/spa/src/components/Sidebar.vue +10 -8
  27. package/dist/spa/src/components/UserMenuSettingsButton.vue +2 -2
  28. package/dist/spa/src/components/ValueRenderer.vue +1 -1
  29. package/dist/spa/src/stores/core.ts +2 -0
  30. package/dist/spa/src/types/Back.ts +7 -1
  31. package/dist/spa/src/types/Common.ts +19 -1
  32. package/dist/spa/src/types/FrontendAPI.ts +1 -18
  33. package/dist/spa/src/types/adapters/StorageAdapter.ts +4 -2
  34. package/dist/spa/src/utils.ts +3 -3
  35. package/dist/spa/src/views/CreateView.vue +25 -1
  36. package/dist/spa/src/views/EditView.vue +26 -1
  37. package/dist/spa/src/views/SettingsView.vue +4 -4
  38. package/dist/types/Back.d.ts +6 -0
  39. package/dist/types/Back.d.ts.map +1 -1
  40. package/dist/types/Back.js.map +1 -1
  41. package/dist/types/Common.d.ts +18 -1
  42. package/dist/types/Common.d.ts.map +1 -1
  43. package/dist/types/Common.js.map +1 -1
  44. package/dist/types/FrontendAPI.d.ts +1 -15
  45. package/dist/types/FrontendAPI.d.ts.map +1 -1
  46. package/dist/types/FrontendAPI.js.map +1 -1
  47. package/dist/types/adapters/StorageAdapter.d.ts +2 -0
  48. package/dist/types/adapters/StorageAdapter.d.ts.map +1 -1
  49. package/package.json +1 -1
@@ -13,47 +13,47 @@
13
13
  "i18n:extract": "echo {} > i18n-empty.json && vue-i18n-extract report --vueFiles \"./src/**/*.{js,vue,ts}\" --output ./i18n-messages.json --languageFiles \"i18n-empty.json\" --add"
14
14
  },
15
15
  "dependencies": {
16
- "@iconify-prerendered/vue-flag": "^0.28.1748584105",
16
+ "@iconify-prerendered/vue-flag": "^0.28.1754899047",
17
17
  "@iconify-prerendered/vue-flowbite": "^0.28.1754899090",
18
- "@unhead/vue": "^1.9.12",
19
- "@vueuse/core": "^10.10.0",
18
+ "@unhead/vue": "^1.11.20",
19
+ "@vueuse/core": "^10.11.1",
20
20
  "apexcharts": "^4.7.0",
21
- "dayjs": "^1.11.11",
22
- "debounce": "^2.1.0",
23
- "flowbite-datepicker": "^1.2.6",
24
- "javascript-time-ago": "^2.5.11",
25
- "pinia": "^2.1.7",
26
- "sanitize-html": "^2.13.0",
27
- "unhead": "^1.9.12",
21
+ "dayjs": "^1.11.19",
22
+ "debounce": "^2.2.0",
23
+ "flowbite-datepicker": "^1.3.2",
24
+ "javascript-time-ago": "^2.5.12",
25
+ "pinia": "^2.3.1",
26
+ "sanitize-html": "^2.17.0",
27
+ "unhead": "^1.11.20",
28
28
  "uuid": "^10.0.0",
29
- "vue": "^3.5.12",
29
+ "vue": "^3.5.22",
30
30
  "vue-diff": "^1.2.4",
31
- "vue-i18n": "^10.0.5",
32
- "vue-router": "^4.3.0",
31
+ "vue-i18n": "^10.0.8",
32
+ "vue-router": "^4.6.3",
33
33
  "vue-slider-component": "^4.1.0-beta.7"
34
34
  },
35
35
  "devDependencies": {
36
- "@rushstack/eslint-patch": "^1.8.0",
37
- "@tsconfig/node20": "^20.1.4",
38
- "@types/node": "^20.12.5",
39
- "@vitejs/plugin-vue": "^5.0.4",
36
+ "@rushstack/eslint-patch": "^1.14.1",
37
+ "@tsconfig/node20": "^20.1.6",
38
+ "@types/node": "^20.19.24",
39
+ "@vitejs/plugin-vue": "^6.0.2",
40
40
  "@vue/eslint-config-typescript": "^13.0.0",
41
- "@vue/tsconfig": "^0.5.1",
42
- "autoprefixer": "^10.4.19",
43
- "eslint": "^8.57.0",
44
- "eslint-plugin-vue": "^9.23.0",
45
- "flag-icons": "^7.2.3",
41
+ "@vue/tsconfig": "^0.8.1",
42
+ "autoprefixer": "^10.4.21",
43
+ "eslint": "^8.57.1",
44
+ "eslint-plugin-vue": "^9.33.0",
45
+ "flag-icons": "^7.5.0",
46
46
  "flowbite": "^3.1.2",
47
- "i18n-iso-countries": "^7.12.0",
48
- "npm-run-all2": "^6.1.2",
49
- "portfinder": "^1.0.32",
50
- "postcss": "^8.4.38",
51
- "sass": "^1.77.2",
52
- "tailwindcss": "^3.4.17",
53
- "typescript": "~5.4.0",
54
- "vite": "^5.2.13",
47
+ "i18n-iso-countries": "^7.14.0",
48
+ "npm-run-all2": "^6.2.6",
49
+ "portfinder": "^1.0.38",
50
+ "postcss": "^8.5.6",
51
+ "sass": "^1.93.3",
52
+ "tailwindcss": "^3.4.18",
53
+ "typescript": "~5.9.3",
54
+ "vite": "^7.2.4",
55
55
  "vue-i18n-extract": "^2.0.7",
56
- "vue-tsc": "^2.0.11",
57
- "vue3-json-viewer": "^2.2.2"
56
+ "vue-tsc": "^2.2.12",
57
+ "vue3-json-viewer": "^2.4.1"
58
58
  }
59
59
  }
@@ -52,7 +52,7 @@
52
52
  </div>
53
53
 
54
54
  <ul class="py-1" role="none">
55
- <li v-for="c in coreStore?.config?.globalInjections?.userMenu || []" class="bg-lightUserMenuItemBackground hover:bg-lightUserMenuItemBackgroundHover text-lightUserMenuItemText hover:text-lightUserMenuItemText dark:bg-darkUserMenuItemBackground dark:hover:bg-darkUserMenuItemBackgroundHover dark:text-darkUserMenuItemText dark:hover:darkUserMenuItemTextHover" >
55
+ <li v-for="c in userMenuComponents" class="bg-lightUserMenuItemBackground hover:bg-lightUserMenuItemBackgroundHover text-lightUserMenuItemText hover:text-lightUserMenuItemText dark:bg-darkUserMenuItemBackground dark:hover:bg-darkUserMenuItemBackgroundHover dark:text-darkUserMenuItemText dark:hover:darkUserMenuItemTextHover" >
56
56
  <component
57
57
  :is="getCustomComponent(c)"
58
58
  :meta="c.meta"
@@ -73,7 +73,7 @@
73
73
  </nav>
74
74
 
75
75
  <Sidebar
76
- v-if="loggedIn && routerIsReady && loginRedirectCheckIsReady && defaultLayout"
76
+ v-if="loggedIn && routerIsReady && loginRedirectCheckIsReady && defaultLayout && !headerOnlyLayout"
77
77
  :sideBarOpen="sideBarOpen"
78
78
  :forceIconOnly="route.meta?.sidebarAndHeader === 'preferIconOnly'"
79
79
  @hideSidebar="hideSidebar"
@@ -81,12 +81,10 @@
81
81
  @sidebarStateChange="handleSidebarStateChange"
82
82
  />
83
83
 
84
- <div class="transition-all duration-300 ease-in-out max-w-[100vw]"
85
- :class="{
86
- 'sm:ml-18': isSidebarIconOnly,
87
- 'sm:ml-[264px]': !isSidebarIconOnly,
88
- 'sm:max-w-[calc(100%-4.5rem)]': isSidebarIconOnly,
89
- 'sm:max-w-[calc(100%-16rem)]': !isSidebarIconOnly
84
+ <div class="af-content-wrapper transition-all duration-300 ease-in-out max-w-[100vw]"
85
+ :style="{
86
+ marginLeft: headerOnlyLayout ? 0 : isSidebarIconOnly ? '4.5rem' : expandedWidth,
87
+ maxWidth: headerOnlyLayout ? '100%' : isSidebarIconOnly ? 'calc(100% - 4.5rem)' : `calc(100% - ${expandedWidth})`
90
88
  }"
91
89
  v-if="loggedIn && routerIsReady && loginRedirectCheckIsReady && defaultLayout">
92
90
  <div class="p-0 dark:border-gray-700 mt-14">
@@ -147,12 +145,10 @@
147
145
  @apply opacity-100;
148
146
  }
149
147
 
150
- @media (min-width: 640px) {
151
- .sm\:ml-18 {
152
- margin-left: 4.5rem;
153
- }
154
- .sm\:max-w-\[calc\(100\%-4\.5rem\)\] {
155
- max-width: calc(100% - 4.5rem);
148
+ @media (max-width: 640px) {
149
+ .af-content-wrapper {
150
+ margin-left: 0 !important;
151
+ max-width: 100% !important;
156
152
  }
157
153
  }
158
154
 
@@ -187,6 +183,7 @@ initFrontedAPI()
187
183
  createHead()
188
184
  const sideBarOpen = ref(false);
189
185
  const defaultLayout = ref(true);
186
+ const headerOnlyLayout = ref(false);
190
187
  const route = useRoute();
191
188
  const router = useRouter();
192
189
  const publicConfigLoaded = ref(false);
@@ -200,8 +197,82 @@ const isSidebarIconOnly = ref(localStorage.getItem('afIconOnlySidebar') === 'tru
200
197
 
201
198
  const loggedIn = computed(() => !!coreStore?.adminUser);
202
199
 
200
+ const expandedWidth = computed(() => coreStore.config?.iconOnlySidebar?.expandedSidebarWidth || '16.5rem');
201
+
203
202
  const theme = ref('light');
204
203
 
204
+ const userMenuComponents = computed(() => {
205
+ console.log('🪲🆕 userMenuComponents recomputed', JSON.parse(JSON.stringify(coreStore?.config?.globalInjections?.userMenu)));
206
+ return coreStore?.config?.globalInjections?.userMenu || [];
207
+ })
208
+
209
+ watch(
210
+ () => coreStore.config?.globalInjections?.userMenu,
211
+ (newVal, oldVal) => {
212
+ // Only log when it becomes undefined (you can relax this if needed)
213
+ if (newVal === undefined) {
214
+ const err = new Error('🔍 userMenu changed to undefined');
215
+ console.groupCollapsed(
216
+ '%c[TRACE] userMenu changed to undefined',
217
+ 'color: red; font-weight: bold;'
218
+ );
219
+ console.log('old value:', oldVal);
220
+ console.log('new value:', newVal);
221
+ console.log('coreStore.config.globalInjections:', coreStore.config?.globalInjections);
222
+ console.log('Stack trace:');
223
+ console.log(err.stack);
224
+ console.groupEnd();
225
+ } else {
226
+ // Optional: log ALL changes for debugging
227
+ console.groupCollapsed(
228
+ '%c[DEBUG] userMenu changed',
229
+ 'color: orange; font-weight: bold;'
230
+ );
231
+ console.log('old value:', oldVal);
232
+ console.log('new value:', newVal);
233
+ console.log('coreStore.config.globalInjections:', coreStore.config?.globalInjections);
234
+ console.groupEnd();
235
+ }
236
+ },
237
+ {
238
+ deep: false,
239
+ immediate: false,
240
+ }
241
+ );
242
+
243
+ watch(() => coreStore.config?.globalInjections, (v) => {
244
+ console.log("🔧 globalInjections replaced:", v);
245
+ }, { deep: false });
246
+
247
+ watch(
248
+ () => coreStore.config?.globalInjections?.userMenu,
249
+ (newVal, oldVal) => {
250
+ if (newVal === undefined) {
251
+ const err = new Error('🔍 userMenu changed to undefined');
252
+ console.groupCollapsed(
253
+ '%c[TRACE] userMenu changed to undefined',
254
+ 'color: red; font-weight: bold;'
255
+ );
256
+ console.log('old value:', oldVal);
257
+ console.log('new value:', newVal);
258
+ console.log('coreStore.config.globalInjections:', coreStore.config?.globalInjections);
259
+ console.log('Stack trace:');
260
+ console.log(err.stack);
261
+ console.groupEnd();
262
+ } else {
263
+ console.groupCollapsed(
264
+ '%c[DEBUG] userMenu changed',
265
+ 'color: orange; font-weight: bold;'
266
+ );
267
+ console.log('old value:', oldVal);
268
+ console.log('new value:', newVal);
269
+ console.log('coreStore.config.globalInjections:', coreStore.config?.globalInjections);
270
+ console.groupEnd();
271
+ }
272
+ },
273
+ { deep: false, immediate: false }
274
+ );
275
+
205
276
  function hideSidebar(): void {
206
277
  sideBarOpen.value = false;
207
278
  }
@@ -243,6 +314,8 @@ function handleCustomLayout() {
243
314
  } else if (route.meta?.sidebarAndHeader === 'preferIconOnly') {
244
315
  defaultLayout.value = true;
245
316
  isSidebarIconOnly.value = true;
317
+ } else if (route.meta?.sidebarAndHeader === 'headerOnly') {
318
+ headerOnlyLayout.value = true;
246
319
  } else {
247
320
  defaultLayout.value = true;
248
321
  }
@@ -1,5 +1,5 @@
1
- import type { FilterParams, FrontendAPIInterface, ConfirmParams, AlertParams, } from '@/types/FrontendAPI';
2
- import type { AdminForthFilterOperators, AdminForthResourceColumnCommon } from '@/types/Common';
1
+ import type { FrontendAPIInterface, ConfirmParams, AlertParams, } from '@/types/FrontendAPI';
2
+ import type { AdminForthFilterOperators, AdminForthResourceColumnCommon, FilterParams } from '@/types/Common';
3
3
  import { useToastStore } from '@/stores/toast';
4
4
  import { useModalStore } from '@/stores/modal';
5
5
  import { useCoreStore } from '@/stores/core';
@@ -142,7 +142,6 @@ watch(() => [options.value, chart.value], (value) => {
142
142
  if (!value || !chart.value) {
143
143
  return;
144
144
  }
145
- console.log('options changed', options.value);
146
145
  if (apexChart) {
147
146
  apexChart.updateOptions(options.value);
148
147
  } else {
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <!-- tag form used to reset the input (method .reset() in claer() function) -->
2
+ <!-- tag form used to reset the input (method .reset() in clear() function) -->
3
3
  <form class="flex items-center justify-center w-full"
4
4
  @dragover.prevent="dragging = true"
5
5
  @dragleave.prevent="dragging = false"
@@ -16,42 +16,88 @@
16
16
  'h-32': !props.multiple,
17
17
  }"
18
18
  >
19
- <div class="flex flex-col items-center justify-center pt-5 pb-6">
20
-
21
-
22
- <svg v-if="!selectedFiles.length" class="w-8 h-8 mb-4 text-lightDropzoneIcon dark:text-darkDropzoneIcon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 16">
23
- <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"/>
24
- </svg>
25
- <div v-else class="flex items-center justify-center flex-wrap gap-1 w-full mt-1 mb-4">
26
- <template v-for="file in selectedFiles">
27
- <p class="text-sm text-lightDropzoneIcon dark:text-darkDropzoneIcon flex items-center gap-1">
28
- <IconFileSolid class="w-5 h-5" />
29
- {{ file.name }} ({{ humanifySize(file.size) }})
30
- </p>
31
- </template>
32
-
19
+ <input
20
+ :id="id"
21
+ type="file"
22
+ class="hidden"
23
+ :accept="normalizedExtensions.join(',')"
24
+ @change="$event.target && doEmit(($event.target as HTMLInputElement).files!)"
25
+ :multiple="props.multiple || false"
26
+ />
27
+
28
+ <div class="flex flex-col items-center justify-center pt-5 pb-6">
29
+ <svg
30
+ v-if="!selectedFiles.length"
31
+ class="w-8 h-8 mb-4 text-lightDropzoneIcon dark:text-darkDropzoneIcon"
32
+ xmlns="http://www.w3.org/2000/svg"
33
+ fill="none"
34
+ viewBox="0 0 20 16"
35
+ >
36
+ <path
37
+ stroke="currentColor"
38
+ stroke-linecap="round"
39
+ stroke-linejoin="round"
40
+ stroke-width="2"
41
+ d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5
42
+ 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0
43
+ 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
44
+ />
45
+ </svg>
46
+
47
+ <div
48
+ v-else
49
+ class="flex items-center justify-center py-1 flex-wrap gap-2 w-full mt-1 mb-4 px-4"
50
+ >
51
+ <template v-for="(file, index) in selectedFiles" :key="index">
52
+ <div
53
+ class="text-sm text-lightDropzoneIcon dark:text-darkDropzoneIcon bg-lightDropzoneBackgroundHover dark:bg-darkDropzoneBackgroundHover rounded-md
54
+ flex items-center gap-1 px-2 py-1 group"
55
+ >
56
+ <IconFileSolid class="w-4 h-4 flex-shrink-0" />
57
+ <span
58
+ class="truncate max-w-[200px]"
59
+ :title="file.name"
60
+ >
61
+ {{ shortenFileName(file.name) }}
62
+ </span>
63
+ <span class="text-xs">({{ humanifySize(file.size) }})</span>
64
+ <button
65
+ type="button"
66
+ @click.prevent.stop="removeFile(index)"
67
+ class="text-lightDropzoneIcon dark:text-darkDropzoneIcon hover:text-red-600 dark:hover:text-red-400
68
+ opacity-70 hover:opacity-100 transition-all"
69
+ :title="$t('Remove file')"
70
+ >
71
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
72
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
73
+ </svg>
74
+ </button>
33
75
  </div>
34
-
35
- <p v-if="!selectedFiles.length" class="mb-2 text-sm text-lightDropzoneText dark:text-darkDropzoneText"><span class="font-semibold">{{ $t('Click to upload') }}</span> {{ $t('or drag and drop') }}</p>
36
- <p class="text-xs text-lightDropzoneText dark:text-darkDropzoneText">
37
- {{ props.extensions.join(', ').toUpperCase().replace(/\./g, '') }}
38
- <template v-if="props.maxSizeBytes">
39
- (Max size: {{ humanifySize(props.maxSizeBytes) }})
40
- </template>
41
- </p>
76
+ </template>
42
77
  </div>
43
- <input :id="id" type="file" class="hidden"
44
- :accept="props.extensions.join(', ')"
45
- @change="$event.target && doEmit(($event.target as HTMLInputElement).files!)"
46
- :multiple="props.multiple || false"
47
- />
78
+
79
+ <p
80
+ v-if="!selectedFiles.length"
81
+ class="mb-2 text-sm text-lightDropzoneText dark:text-darkDropzoneText"
82
+ >
83
+ <span class="font-semibold">{{ $t('Click to upload') }}</span>
84
+ {{ $t('or drag and drop') }}
85
+ </p>
86
+
87
+ <p class="text-xs text-lightDropzoneText dark:text-darkDropzoneText">
88
+ {{ normalizedExtensions.join(', ').toUpperCase().replace(/\./g, '') }}
89
+ <template v-if="props.maxSizeBytes">
90
+ (Max size: {{ humanifySize(props.maxSizeBytes) }})
91
+ </template>
92
+ </p>
93
+ </div>
48
94
  </label>
49
- </form>
95
+ </form>
50
96
  </template>
51
97
 
52
98
  <script setup lang="ts">
53
99
  import { humanifySize } from '@/utils';
54
- import { ref, type Ref } from 'vue';
100
+ import { ref, type Ref, computed } from 'vue';
55
101
  import { IconFileSolid } from '@iconify-prerendered/vue-flowbite';
56
102
  import { watch } from 'vue';
57
103
  import adminforth from '@/adminforth';
@@ -67,25 +113,53 @@ const emit = defineEmits(['update:modelValue']);
67
113
 
68
114
  const id = `afcl-dropzone-${Math.random().toString(36).substring(7)}`;
69
115
 
116
+ const normalizedExtensions = computed(() => {
117
+ return props.extensions.map(ext => {
118
+ const trimmed = ext.trim().toLowerCase();
119
+ return trimmed.startsWith('.') ? trimmed : `.${trimmed}`;
120
+ });
121
+ });
122
+
70
123
  const selectedFiles: Ref<{
71
124
  name: string,
72
125
  size: number,
73
126
  mime: string,
74
127
  }[]> = ref([]);
75
128
 
129
+ const storedFiles: Ref<File[]> = ref([]);
130
+
131
+ function shortenFileName(name: string, maxLength = 24) {
132
+ if (name.length <= maxLength) return name;
133
+
134
+ const lastDotIndex = name.lastIndexOf('.');
135
+ const hasExtension = lastDotIndex > 0 && lastDotIndex < name.length - 1;
136
+ const extension = hasExtension ? name.slice(lastDotIndex + 1) : '';
137
+ const baseName = hasExtension ? name.slice(0, lastDotIndex) : name;
138
+
139
+ const startName = baseName.slice(0, 12);
140
+ const endName = baseName.split('').reverse().slice(0, 4).reverse().join('');
141
+ return hasExtension ? `${startName}...${endName}.${extension}` : `${startName}...${endName}`;
142
+ }
143
+
76
144
  watch(() => props.modelValue, (files) => {
77
- selectedFiles.value = Array.from(files).map(file => ({
78
- name: file.name,
79
- size: file.size,
80
- mime: file.type,
81
- }));
82
- });
145
+ if (files && files.length > 0) {
146
+ selectedFiles.value = Array.from(files).map(file => ({
147
+ name: file.name,
148
+ size: file.size,
149
+ mime: file.type,
150
+ }));
151
+ storedFiles.value = Array.from(files);
152
+ } else {
153
+ selectedFiles.value = [];
154
+ storedFiles.value = [];
155
+ }
156
+ }, { immediate: true });
83
157
 
84
158
  function doEmit(filesIn: FileList) {
85
159
 
86
160
  const multiple = props.multiple || false;
87
161
  const files = Array.from(filesIn);
88
- const allowedExtensions = props.extensions.map(ext => ext.toLowerCase());
162
+ const allowedExtensions = normalizedExtensions.value;
89
163
  const maxSizeBytes = props.maxSizeBytes;
90
164
 
91
165
  if (!files.length) return;
@@ -96,6 +170,18 @@ function doEmit(filesIn: FileList) {
96
170
  const extension = file.name.split('.').pop()?.toLowerCase() || '';
97
171
  const size = file.size;
98
172
 
173
+ const isDuplicate = storedFiles.value.some(
174
+ existingFile => existingFile.name === file.name && existingFile.size === file.size
175
+ );
176
+
177
+ if (isDuplicate) {
178
+ adminforth.alert({
179
+ message: `The file "${file.name}" is already selected.`,
180
+ variant: 'warning',
181
+ });
182
+ return;
183
+ }
184
+
99
185
  if (!allowedExtensions.includes(`.${extension}`)) {
100
186
  adminforth.alert({
101
187
  message: `Sorry, the file type .${extension} is not allowed. Please upload a file with one of the following extensions: ${allowedExtensions.join(', ')}`,
@@ -110,26 +196,37 @@ function doEmit(filesIn: FileList) {
110
196
  });
111
197
  return;
112
198
  }
113
-
199
+
114
200
  validFiles.push(file);
115
201
  });
116
202
 
117
203
  if (!multiple) {
118
- validFiles.splice(1);
204
+ storedFiles.value = validFiles.slice(0, 1);
205
+ } else {
206
+ storedFiles.value = [...storedFiles.value, ...validFiles];
119
207
  }
120
- selectedFiles.value = validFiles.map(file => ({
208
+
209
+ selectedFiles.value = storedFiles.value.map(file => ({
121
210
  name: file.name,
122
211
  size: file.size,
123
212
  mime: file.type,
124
213
  }));
125
214
 
126
- emit('update:modelValue', validFiles);
215
+ emit('update:modelValue', storedFiles.value);
216
+
127
217
  }
128
218
 
129
219
  const dragging = ref(false);
130
220
 
221
+ function removeFile(index: number) {
222
+ storedFiles.value = storedFiles.value.filter((_, i) => i !== index);
223
+ selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index);
224
+ emit('update:modelValue', storedFiles.value);
225
+ }
226
+
131
227
  function clear() {
132
228
  selectedFiles.value = [];
229
+ storedFiles.value = [];
133
230
  emit('update:modelValue', []);
134
231
  const form = document.getElementById(id)?.closest('form');
135
232
  form?.reset();