inicontent 1.0.0

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 (205) hide show
  1. package/.github/pull_request.yml +18 -0
  2. package/CONTRIBUTING.md +67 -0
  3. package/LICENSE +21 -0
  4. package/README.md +142 -0
  5. package/app/app.vue +106 -0
  6. package/app/assets/main.css +974 -0
  7. package/app/components/Api/EndpointCard.vue +169 -0
  8. package/app/components/Api/SchemaDocsTable.vue +208 -0
  9. package/app/components/Asset/Card.vue +1363 -0
  10. package/app/components/Asset/Grid.vue +295 -0
  11. package/app/components/Asset/Icon.vue +38 -0
  12. package/app/components/Asset/Preview.vue +486 -0
  13. package/app/components/Asset/Scanner.vue +700 -0
  14. package/app/components/Asset/Thumb.vue +708 -0
  15. package/app/components/Auth/Reset.vue +99 -0
  16. package/app/components/Auth/index.vue +346 -0
  17. package/app/components/ChatInterface.vue +2326 -0
  18. package/app/components/ChatPagePreview.vue +34 -0
  19. package/app/components/Column/Array.vue +29 -0
  20. package/app/components/Column/Asset.vue +21 -0
  21. package/app/components/Column/Boolean.vue +12 -0
  22. package/app/components/Column/Color.vue +9 -0
  23. package/app/components/Column/Date.vue +57 -0
  24. package/app/components/Column/Edit.vue +215 -0
  25. package/app/components/Column/Email.vue +9 -0
  26. package/app/components/Column/Html.vue +27 -0
  27. package/app/components/Column/Id.vue +20 -0
  28. package/app/components/Column/Locale.vue +32 -0
  29. package/app/components/Column/Object.vue +18 -0
  30. package/app/components/Column/Password.vue +7 -0
  31. package/app/components/Column/Role.vue +15 -0
  32. package/app/components/Column/S.vue +14 -0
  33. package/app/components/Column/Table/Single.vue +49 -0
  34. package/app/components/Column/Table/index.vue +42 -0
  35. package/app/components/Column/Tags.vue +31 -0
  36. package/app/components/Column/Text.vue +9 -0
  37. package/app/components/Column/Url.vue +9 -0
  38. package/app/components/Column/index.vue +41 -0
  39. package/app/components/Dashboard/Editor.vue +221 -0
  40. package/app/components/Dashboard/Grid.vue +270 -0
  41. package/app/components/Dashboard/View.vue +131 -0
  42. package/app/components/Dashboard/Widget/BarChart.vue +73 -0
  43. package/app/components/Dashboard/Widget/Counter.vue +51 -0
  44. package/app/components/Dashboard/Widget/LineChart.vue +76 -0
  45. package/app/components/Dashboard/Widget/PieChart.vue +75 -0
  46. package/app/components/Dashboard/Widget/Table.vue +94 -0
  47. package/app/components/Dashboard/WidgetEditor.vue +274 -0
  48. package/app/components/Data/Array.vue +102 -0
  49. package/app/components/Data/Html.vue +9 -0
  50. package/app/components/Data/Icon.vue +9 -0
  51. package/app/components/Data/Object.vue +10 -0
  52. package/app/components/Data/S.vue +16 -0
  53. package/app/components/Data/Table.vue +53 -0
  54. package/app/components/Data/Value.vue +34 -0
  55. package/app/components/Data/index.vue +14 -0
  56. package/app/components/Field/Array.vue +324 -0
  57. package/app/components/Field/Asset/Actions.vue +97 -0
  58. package/app/components/Field/Asset/index.vue +639 -0
  59. package/app/components/Field/Boolean.vue +26 -0
  60. package/app/components/Field/Checkbox.vue +48 -0
  61. package/app/components/Field/Color.vue +27 -0
  62. package/app/components/Field/Date.vue +30 -0
  63. package/app/components/Field/Email.vue +51 -0
  64. package/app/components/Field/EmailTemplate.vue +420 -0
  65. package/app/components/Field/Html/ColorPicker.vue +127 -0
  66. package/app/components/Field/Html/Editor.vue +544 -0
  67. package/app/components/Field/Html/index.vue +25 -0
  68. package/app/components/Field/Icon.vue +114 -0
  69. package/app/components/Field/JSON.vue +113 -0
  70. package/app/components/Field/Mention.vue +36 -0
  71. package/app/components/Field/Number.vue +27 -0
  72. package/app/components/Field/Object.vue +92 -0
  73. package/app/components/Field/Password.vue +55 -0
  74. package/app/components/Field/Radio.vue +47 -0
  75. package/app/components/Field/S.vue +44 -0
  76. package/app/components/Field/Select.vue +49 -0
  77. package/app/components/Field/Table.vue +348 -0
  78. package/app/components/Field/Tags.vue +113 -0
  79. package/app/components/Field/Text.vue +31 -0
  80. package/app/components/Field/Textarea.vue +33 -0
  81. package/app/components/Field/Url.vue +40 -0
  82. package/app/components/Field/Wrapper.vue +128 -0
  83. package/app/components/Field/index.vue +68 -0
  84. package/app/components/FloatingChatbot.vue +73 -0
  85. package/app/components/Form/Card.vue +151 -0
  86. package/app/components/Form/Drawer.vue +195 -0
  87. package/app/components/Form/index.vue +621 -0
  88. package/app/components/Header.vue +328 -0
  89. package/app/components/Offline/ConflictModal.vue +200 -0
  90. package/app/components/Offline/SyncStatus.vue +249 -0
  91. package/app/components/PlatformFeatureLanding.vue +65 -0
  92. package/app/components/Table/Backups.vue +638 -0
  93. package/app/components/Table/Flows.vue +814 -0
  94. package/app/components/Table/Grid.vue +296 -0
  95. package/app/components/Table/Icon.vue +36 -0
  96. package/app/components/Table/Logs.vue +181 -0
  97. package/app/components/Table/Schedules.vue +722 -0
  98. package/app/components/Table/Search/Button.vue +256 -0
  99. package/app/components/Table/Search/Items.vue +286 -0
  100. package/app/components/Table/Search/index.vue +87 -0
  101. package/app/components/Table/Settings/Schema.vue +801 -0
  102. package/app/components/Table/Settings/index.vue +631 -0
  103. package/app/components/Table/TranslateDrawer.vue +526 -0
  104. package/app/components/Table/Views/Kanban.vue +289 -0
  105. package/app/components/Table/Views/Table.vue +1273 -0
  106. package/app/components/Table/index.vue +890 -0
  107. package/app/composables/Translation/define.ts +41 -0
  108. package/app/composables/Translation/fetch.ts +107 -0
  109. package/app/composables/Translation/languages.ts +1 -0
  110. package/app/composables/Translation/loadCoreTranslations.ts +38 -0
  111. package/app/composables/Translation/translate.ts +250 -0
  112. package/app/composables/databaseCookies.ts +79 -0
  113. package/app/composables/fieldValidator.ts +72 -0
  114. package/app/composables/fieldsList.ts +301 -0
  115. package/app/composables/formatDatabase.ts +101 -0
  116. package/app/composables/index.ts +444 -0
  117. package/app/composables/openDrawer.ts +118 -0
  118. package/app/composables/optimizeFile.ts +287 -0
  119. package/app/composables/renderLabel.ts +88 -0
  120. package/app/composables/schemaClipboard.ts +95 -0
  121. package/app/composables/search.ts +353 -0
  122. package/app/composables/setThemeConfig.ts +48 -0
  123. package/app/composables/translationValue.ts +169 -0
  124. package/app/composables/useAssetPreview.ts +84 -0
  125. package/app/composables/useAssetUploader.ts +56 -0
  126. package/app/composables/useDashboardData.ts +310 -0
  127. package/app/composables/useDocumentThumbnail.ts +351 -0
  128. package/app/composables/useEcharts.ts +33 -0
  129. package/app/composables/useLocalDatabaseConfig.ts +51 -0
  130. package/app/composables/useOcr.ts +300 -0
  131. package/app/composables/useOfflineFetch.ts +194 -0
  132. package/app/composables/useOfflineItem.ts +107 -0
  133. package/app/composables/useOfflineQueue.ts +302 -0
  134. package/app/composables/useOfflineSync.ts +235 -0
  135. package/app/composables/usePasskeyAuth.ts +156 -0
  136. package/app/composables/usePdfCompressor.ts +180 -0
  137. package/app/composables/usePlatformRedirect.ts +72 -0
  138. package/app/composables/useRealtimeSync.ts +233 -0
  139. package/app/composables/useReferencedItems.ts +180 -0
  140. package/app/composables/useScanner.ts +664 -0
  141. package/app/composables/useSubscription.ts +650 -0
  142. package/app/composables/useThumbnailCache.ts +136 -0
  143. package/app/composables/useVideoCompressor.ts +194 -0
  144. package/app/error.vue +112 -0
  145. package/app/layouts/api.vue +188 -0
  146. package/app/layouts/dashboard.vue +91 -0
  147. package/app/layouts/default.vue +228 -0
  148. package/app/layouts/table.vue +471 -0
  149. package/app/locales/ar.ts +996 -0
  150. package/app/locales/en.ts +655 -0
  151. package/app/locales/es.ts +996 -0
  152. package/app/locales/fr.ts +997 -0
  153. package/app/middleware/dashboard.ts +33 -0
  154. package/app/middleware/database.ts +83 -0
  155. package/app/middleware/global.ts +1 -0
  156. package/app/middleware/sid-ingest.global.ts +29 -0
  157. package/app/middleware/table.ts +54 -0
  158. package/app/middleware/user.ts +26 -0
  159. package/app/pages/[[database]]/admin/api/auth/index.vue +306 -0
  160. package/app/pages/[[database]]/admin/api/index.vue +18 -0
  161. package/app/pages/[[database]]/admin/api/tables/[table]/index.vue +497 -0
  162. package/app/pages/[[database]]/admin/api/tables/index.vue +87 -0
  163. package/app/pages/[[database]]/admin/billing/index.vue +16 -0
  164. package/app/pages/[[database]]/admin/dashboards/[id]/edit.vue +66 -0
  165. package/app/pages/[[database]]/admin/dashboards/[id]/index.vue +117 -0
  166. package/app/pages/[[database]]/admin/dashboards/index.vue +20 -0
  167. package/app/pages/[[database]]/admin/index.vue +28 -0
  168. package/app/pages/[[database]]/admin/settings.vue +526 -0
  169. package/app/pages/[[database]]/admin/tables/[table]/[id]/edit.vue +143 -0
  170. package/app/pages/[[database]]/admin/tables/[table]/[id]/index.vue +146 -0
  171. package/app/pages/[[database]]/admin/tables/[table]/flows.vue +10 -0
  172. package/app/pages/[[database]]/admin/tables/[table]/index.vue +10 -0
  173. package/app/pages/[[database]]/admin/tables/[table]/new.vue +12 -0
  174. package/app/pages/[[database]]/admin/tables/[table]/schedules.vue +10 -0
  175. package/app/pages/[[database]]/admin/tables/[table]/settings.vue +30 -0
  176. package/app/pages/[[database]]/admin/tables/assets/[...path].vue +24 -0
  177. package/app/pages/[[database]]/admin/tables/assets/flows.vue +10 -0
  178. package/app/pages/[[database]]/admin/tables/backups/index.vue +10 -0
  179. package/app/pages/[[database]]/admin/tables/blocks/index.vue +16 -0
  180. package/app/pages/[[database]]/admin/tables/index.vue +20 -0
  181. package/app/pages/[[database]]/admin/tables/pages/index.vue +16 -0
  182. package/app/pages/[[database]]/admin/tables/templates/index.vue +16 -0
  183. package/app/pages/[[database]]/api/index.vue +120 -0
  184. package/app/pages/[[database]]/auth/index.vue +12 -0
  185. package/app/pages/[[database]]/auth/reset.vue +10 -0
  186. package/app/pages/[[database]]/index.vue +10 -0
  187. package/app/plugins/offline-warmup.client.ts +53 -0
  188. package/app/plugins/template-globals.ts +9 -0
  189. package/app/spa-loading-template.html +93 -0
  190. package/biome.json +53 -0
  191. package/index.d.ts +380 -0
  192. package/modules/naiveui.ts +41 -0
  193. package/nuxt.config.ts +269 -0
  194. package/package.json +56 -0
  195. package/pnpm-workspace.yaml +5 -0
  196. package/public/apple-touch-icon.png +0 -0
  197. package/public/favicon-16x16.png +0 -0
  198. package/public/favicon-32x32.png +0 -0
  199. package/public/favicon.ico +0 -0
  200. package/public/pwa-192x192.png +0 -0
  201. package/public/pwa-512x512.png +0 -0
  202. package/public/pwa-maskable-192x192.png +0 -0
  203. package/public/pwa-maskable-512x512.png +0 -0
  204. package/public/vendor/opencv.js +48 -0
  205. package/tsconfig.json +9 -0
@@ -0,0 +1,1363 @@
1
+ <template>
2
+ <div style="position: relative;" @dragenter.prevent="onDragEnter" @dragover.prevent="onDragOver"
3
+ @drop.prevent="onDrop" @dragleave="onDragLeave">
4
+ <input ref="folderInputRef" type="file" multiple directory="" webkitdirectory="" mozdirectory=""
5
+ style="display: none;" @change="onFolderInputChange" />
6
+
7
+ <LazyAssetScanner v-model:show="showScanner" :allowOCR="false"
8
+ @scanned="(blob) => uploadScannedDocument(blob)" />
9
+ <NModal v-model:show="showFolderInput" preset="card" :title="t('createFolder')"
10
+ style="width: min(420px, 95vw);"
11
+ @update:show="(show) => { if (!show) cancelFolderCreation(); }">
12
+ <NInput ref="folderInputElRef" v-model:value="folder" size="small"
13
+ @keydown="({ key }: KeyboardEvent) => { if (key === 'Enter') createFolder(); }"
14
+ :placeholder="t('folderName')">
15
+ <template #suffix>
16
+ <NIcon>
17
+ <Icon name="tabler:letter-case" />
18
+ </NIcon>
19
+ </template>
20
+ </NInput>
21
+ <template #footer>
22
+ <NFlex justify="end">
23
+ <NButton secondary @click="cancelFolderCreation">{{ t("cancel") }}</NButton>
24
+ <NButton type="primary" @click="createFolder">{{ t("create") }}</NButton>
25
+ </NFlex>
26
+ </template>
27
+ </NModal>
28
+ <Transition name="drop-fade">
29
+ <div v-if="isDragOver"
30
+ style="position: absolute; inset: 0; z-index: 100; background: rgba(24, 160, 88, 0.1); border: 2px dashed #18a058; border-radius: 3px; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; pointer-events: none;">
31
+ <NIcon size="56" color="#18a058">
32
+ <Icon name="tabler:cloud-upload" />
33
+ </NIcon>
34
+ <span style="color: #18a058; font-size: 16px; font-weight: 500;">{{ t("dropFilesHere") }}</span>
35
+ </div>
36
+ </Transition>
37
+ <NCard :header-style="{ paddingRight: 0, paddingLeft: 0 }" embedded
38
+ id="assetsContainer" style="height: fit-content;background-color: transparent;" :content-style="{ padding: 0 }" :bordered="false">
39
+ <template #header>
40
+ <span v-if="isAssetRoute">{{ t("assets") }}</span>
41
+ <NBreadcrumb v-else>
42
+ <NBreadcrumbItem @click="currentPath = ''">
43
+ {{ t("assets") }}
44
+ </NBreadcrumbItem>
45
+ <NBreadcrumbItem v-for="(singlePath, index) in currentPath?.split('/').slice(1)"
46
+ @click="currentPath = `${currentPath.split('/').slice(0, index + 2).join('/')}`">
47
+ {{ singlePath }}
48
+ </NBreadcrumbItem>
49
+ </NBreadcrumb>
50
+ </template>
51
+ <template #header-extra>
52
+ <NFlex wrap>
53
+ <NButtonGroup v-if="selectedAssetIds.length" round>
54
+ <NTooltip>
55
+ <template #trigger>
56
+ <NButton size="small" round secondary type="info"
57
+ :title="allSelectableSelected ? t('clearAll') : t('selectAll')"
58
+ @click="toggleSelectAllFromGroup">
59
+ <template #icon>
60
+ <NIcon>
61
+ <Icon
62
+ :name="allSelectableSelected ? 'tabler:square-minus' : 'tabler:square-check'" />
63
+ </NIcon>
64
+ </template>
65
+ </NButton>
66
+ </template>
67
+ {{ allSelectableSelected ? t("clearAll") : t("selectAll") }}
68
+ </NTooltip>
69
+ <NTooltip>
70
+ <template #trigger>
71
+ <NButton size="small" round secondary type="warning" :loading="bulkDownloadLoading"
72
+ :title="`${t('download')} ZIP${bulkDownloadLoading ? ` ${bulkZipProgress}%` : ''}`"
73
+ @click="downloadSelectedAsZip">
74
+ <template #icon>
75
+ <NIcon>
76
+ <Icon name="tabler:file-zip" />
77
+ </NIcon>
78
+ </template>
79
+ </NButton>
80
+ </template>
81
+ {{ bulkDownloadLoading ? `${t("download")} ZIP ${bulkZipProgress}%` : `${t("download")} ZIP`
82
+ }}
83
+ </NTooltip>
84
+ <NPopconfirm :show-icon="false" @positive-click="deleteSelectedAssets">
85
+ <template #trigger>
86
+ <NTooltip>
87
+ <template #trigger>
88
+ <NButton size="small" round secondary type="error"
89
+ :loading="bulkDeleteLoading" :title="t('delete')">
90
+ <template #icon>
91
+ <NIcon>
92
+ <Icon name="tabler:trash" />
93
+ </NIcon>
94
+ </template>
95
+ </NButton>
96
+ </template>
97
+ {{ t("delete") }}
98
+ </NTooltip>
99
+ </template>
100
+ {{ t("deleteSelectedAssetsConfirm", { count: selectedAssetIds.length }) }}
101
+ </NPopconfirm>
102
+ </NButtonGroup>
103
+ <NButtonGroup>
104
+ <LazyTableSearchButton v-model:string="searchString" v-model:array="searchArray" :schema
105
+ size="small" />
106
+ <NUpload v-if="table?.allowedMethods?.includes('c')" ref="uploadRef" multiple abstract
107
+ :action="`${config.public.apiBase}${database.slug}/assets${currentPath}?${database.slug}_sid=${sessionID}`"
108
+ @update:file-list="onUpdateFileList" :custom-request @remove="onRemoveUpload">
109
+ <NPopover trigger="manual" placement="top-end" :show="UploadProgress > 0" scrollable
110
+ style="max-height: 160px">
111
+ <template #trigger>
112
+ <NDropdown placement="bottom-end" trigger="click" size="small"
113
+ :options="uploadDropdownOptions" @select="onUploadDropdownSelect">
114
+ <NButton secondary type="primary" round size="small" :title="t('uploadFiles')">
115
+ <template #icon>
116
+ <NProgress v-if="compressionIndicator" type="circle" status="warning"
117
+ :percentage="compressionIndicator" :stroke-width="10">
118
+ <NTooltip v-model:show="showSkipCompressionTooltip" placement="top">
119
+ <template #trigger>
120
+ <Icon @click.stop="skipCompression" :size="10"
121
+ name="tabler:player-track-next-filled" />
122
+ </template>
123
+ {{ t("skipCompression") }}
124
+ </NTooltip>
125
+ </NProgress>
126
+ <NIcon v-else-if="!UploadProgress">
127
+ <Icon name="tabler:upload" />
128
+ </NIcon>
129
+ <NIcon v-else-if="UploadProgress === 10000">
130
+ <Icon name="tabler:check" />
131
+ </NIcon>
132
+ <NSpin v-else-if="UploadProgress === 1000 || UploadProgress === 1001"
133
+ :size="16" />
134
+ <NProgress v-else type="circle" :show-indicator="false"
135
+ :status="UploadProgress === 100 ? 'success' : 'warning'"
136
+ :percentage="UploadProgress" :stroke-width="20" />
137
+ </template>
138
+ </NButton>
139
+ </NDropdown>
140
+ </template>
141
+ <NFlex v-if="UploadProgress === 1001" vertical
142
+ style="gap: 6px; padding: 2px 0; min-width: 160px;">
143
+ <NText style="font-size: 12px;">
144
+ {{ folderUploadProgress.total > 0
145
+ ? `${folderUploadProgress.current} / ${folderUploadProgress.total}`
146
+ : folderUploadProgress.current }}
147
+ </NText>
148
+ <NProgress v-if="folderUploadProgress.total > 0" type="line"
149
+ :percentage="Math.round((folderUploadProgress.current / folderUploadProgress.total) * 100)"
150
+ :show-indicator="false" />
151
+ </NFlex>
152
+ <NUploadFileList v-else />
153
+ </NPopover>
154
+ </NUpload>
155
+ </NButtonGroup>
156
+ </NFlex>
157
+ </template>
158
+ <NCard>
159
+ <NFlex vertical align="center">
160
+ <AssetGrid v-model="assets" :isAssetRoute :table :targetID="!targetID ? 'assetsContainer' : targetID"
161
+ :selected-asset-ids="selectedAssetIds" v-model:path="currentPath">
162
+ <template v-slot="slotProps">
163
+ <NCheckbox :checked="selectedAssetIdsSet.has(slotProps.asset.id)"
164
+ @update:checked="() => toggleAssetSelection(slotProps.asset.id)" />
165
+ <slot v-bind="slotProps"></slot>
166
+ </template>
167
+ </AssetGrid>
168
+ <NPagination v-if="pagination.itemCount && pagination.pageCount > 1" :simple="!!$device.isMobile"
169
+ :page-sizes="getResponsivePageSizes()" :show-size-picker="showSizePicker" style="margin-top: 25px;"
170
+ @update:page-size="onUpdatePageSize" @update:page="onUpdatePage" :page="pagination.page"
171
+ :page-size="pagination.pageSize" :item-count="pagination.itemCount" />
172
+ </NFlex>
173
+ </NCard>
174
+ </NCard>
175
+ </div>
176
+ </template>
177
+
178
+
179
+ <script lang="ts" setup>
180
+ import Inison from "inison";
181
+ import type {
182
+ InputInst,
183
+ UploadCustomRequestOptions,
184
+ UploadFileInfo,
185
+ UploadInst,
186
+ } from "naive-ui";
187
+ import { Icon, NIcon } from "#components";
188
+ import { getFileNameAndExtension } from "~/composables";
189
+ import { useOptimizeFile } from "~/composables/optimizeFile";
190
+ import { generateSearchArray } from "~/composables/search";
191
+ import { useAssetUploader } from "~/composables/useAssetUploader";
192
+ import { usePdfCompressor } from "~/composables/usePdfCompressor";
193
+ import { useVideoCompressor } from "~/composables/useVideoCompressor";
194
+
195
+ const { where, suffix, targetID } = defineProps<{
196
+ targetID?: string;
197
+ where?: any;
198
+ suffix?: string;
199
+ }>();
200
+ const config = useRuntimeConfig();
201
+
202
+ const getResponsivePageSize = () => {
203
+ if (typeof window === "undefined") return 15;
204
+ const width = window.innerWidth;
205
+ if (width < 640) return 10;
206
+ if (width < 1024) return 15;
207
+ if (width < 1440) return 20;
208
+ return 30;
209
+ };
210
+
211
+ const getResponsivePageSizes = () => {
212
+ if (typeof window === "undefined") return [15, 30, 60, 100, 500];
213
+ if (window.innerWidth < 640) return [10, 20, 30, 50];
214
+ if (window.innerWidth < 1024) return [15, 30, 60, 100];
215
+ return [20, 30, 60, 100, 500];
216
+ };
217
+
218
+ const LARGE_VIDEO_BYTES = 512 * 1024 * 1024;
219
+ const HUGE_VIDEO_BYTES = 2 * 1024 * 1024 * 1024;
220
+ const LARGE_PDF_BYTES = 50 * 1024 * 1024;
221
+ const HUGE_PDF_BYTES = 200 * 1024 * 1024;
222
+
223
+ const {
224
+ compressVideo,
225
+ loading: videoLoading,
226
+ progress: videoProgress,
227
+ abort: abortVideo,
228
+ resetSkip: resetVideoSkip,
229
+ } = useVideoCompressor();
230
+ const {
231
+ compressPdf,
232
+ loading: pdfLoading,
233
+ progress: pdfProgress,
234
+ abort: abortPdf,
235
+ resetSkip: resetPdfSkip,
236
+ } = usePdfCompressor();
237
+
238
+ const compressionIndicator = ref<number | null>(null);
239
+ const skipCompressionRequested = ref(false);
240
+ const processingFileId = ref<string | null>(null); // Track which file is being processed
241
+
242
+ const skipCompression = () => {
243
+ if (compressionIndicator.value === null) return;
244
+ skipCompressionRequested.value = true;
245
+ abortVideo();
246
+ abortPdf();
247
+ };
248
+
249
+ const getIndicatorPercent = (progress: number) => {
250
+ const raw = Math.round(progress * 100);
251
+ if (!Number.isFinite(raw)) return 1;
252
+ return Math.min(99, Math.max(1, raw));
253
+ };
254
+
255
+ watchEffect(() => {
256
+ if (videoLoading.value) {
257
+ compressionIndicator.value = getIndicatorPercent(videoProgress.value);
258
+ } else if (pdfLoading.value) {
259
+ compressionIndicator.value = getIndicatorPercent(pdfProgress.value);
260
+ } else {
261
+ compressionIndicator.value = null;
262
+ }
263
+ });
264
+
265
+ const notifyVideoSize = (size: number) => {
266
+ if (!import.meta.client) return;
267
+ const messageApi = window?.$message;
268
+ if (!messageApi) return;
269
+ const formattedSize = humanFileSize(size);
270
+ if (size >= HUGE_VIDEO_BYTES)
271
+ messageApi.warning(t("compression.videoHuge", { size: formattedSize }));
272
+ else if (size >= LARGE_VIDEO_BYTES)
273
+ messageApi.info(t("compression.videoLarge", { size: formattedSize }));
274
+ };
275
+
276
+ const notifyPdfSize = (size: number) => {
277
+ if (!import.meta.client) return;
278
+ const messageApi = window?.$message;
279
+ if (!messageApi) return;
280
+ const formattedSize = humanFileSize(size);
281
+ if (size >= HUGE_PDF_BYTES)
282
+ messageApi.warning(t("compression.pdfHuge", { size: formattedSize }));
283
+ else if (size >= LARGE_PDF_BYTES)
284
+ messageApi.info(t("compression.pdfLarge", { size: formattedSize }));
285
+ };
286
+
287
+ const route = useRoute();
288
+ const router = useRouter();
289
+ const isAssetRoute = computed(() => route.path.includes("/tables/assets"));
290
+
291
+ const table = useState<Table>("table");
292
+ const currentItem = useState<Item>("currentItem");
293
+ const assetsTable = ref<Table>(table.value);
294
+
295
+ const currentPath = ref<string>(
296
+ `${suffix ? renderLabel({ ...assetsTable.value, label: suffix }, currentItem.value) : ""}${
297
+ route.params.path
298
+ ? `/${([] as string[]).concat(route.params.path).join("/")}`
299
+ : ""
300
+ }`,
301
+ );
302
+
303
+ const database = useState<Database>("database");
304
+
305
+ const sessionID = useScopedCookie<string>("sid", database.value?.slug);
306
+
307
+ const Loading = useState<Record<string, boolean>>("Loading", () => ({}));
308
+ if (!assetsTable.value || assetsTable.value.slug !== "assets")
309
+ assetsTable.value = (
310
+ await $fetch<apiResponse<Table>>(
311
+ `${config.public.apiBase}inicontent/databases/${database.value.slug}/assets`,
312
+ {
313
+ credentials: "include",
314
+ params: {
315
+ [`${database.value.slug}_sid`]: sessionID.value,
316
+ },
317
+ },
318
+ )
319
+ ).result;
320
+
321
+ const Language = useScopedCookie<LanguagesType>("language", database.value?.slug);
322
+
323
+ const assets = ref<Asset[]>();
324
+ const selectedAssetIds = ref<Asset["id"][]>([]);
325
+ const showScanner = ref(false);
326
+ const bulkDeleteLoading = ref(false);
327
+ const bulkDownloadLoading = ref(false);
328
+ const bulkZipProgress = ref(0);
329
+ const folderUploadProgress = reactive({ current: 0, total: 0 });
330
+
331
+ const selectedAssetIdsSet = computed(() => new Set(selectedAssetIds.value));
332
+ const selectedAssets = computed(() =>
333
+ (assets.value ?? []).filter((asset) =>
334
+ selectedAssetIdsSet.value.has(asset.id),
335
+ ),
336
+ );
337
+ const allSelectableSelected = computed(
338
+ () =>
339
+ !!(assets.value ?? []).length &&
340
+ selectedAssetIds.value.length === (assets.value ?? []).length,
341
+ );
342
+
343
+ function toggleAssetSelection(id: Asset["id"]) {
344
+ if (!selectedAssetIdsSet.value.has(id))
345
+ selectedAssetIds.value = [...selectedAssetIds.value, id];
346
+ else
347
+ selectedAssetIds.value = selectedAssetIds.value.filter(
348
+ (selectedId) => selectedId !== id,
349
+ );
350
+ }
351
+
352
+ function toggleSelectAllAssets(checked: boolean) {
353
+ if (!checked) {
354
+ selectedAssetIds.value = [];
355
+ return;
356
+ }
357
+ selectedAssetIds.value = (assets.value ?? []).map((asset) => asset.id);
358
+ }
359
+
360
+ function toggleSelectAllFromGroup() {
361
+ if (allSelectableSelected.value) {
362
+ selectedAssetIds.value = [];
363
+ return;
364
+ }
365
+ toggleSelectAllAssets(true);
366
+ }
367
+
368
+ watch(assets, (value) => {
369
+ const currentIds = new Set((value ?? []).map((asset) => asset.id));
370
+ selectedAssetIds.value = selectedAssetIds.value.filter((id) =>
371
+ currentIds.has(id),
372
+ );
373
+ });
374
+
375
+ async function runWithConcurrency<T>(
376
+ items: T[],
377
+ concurrency: number,
378
+ handler: (item: T, index: number) => Promise<void>,
379
+ ) {
380
+ const queue = items.map((item, index) => ({ item, index }));
381
+ const workerCount = Math.max(1, Math.min(concurrency, queue.length));
382
+
383
+ await Promise.all(
384
+ Array.from({ length: workerCount }, async () => {
385
+ while (queue.length) {
386
+ const entry = queue.shift();
387
+ if (!entry) break;
388
+ await handler(entry.item, entry.index);
389
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
390
+ }
391
+ }),
392
+ );
393
+ }
394
+
395
+ async function deleteSelectedAssets() {
396
+ if (!selectedAssets.value.length || bulkDeleteLoading.value) return;
397
+
398
+ bulkDeleteLoading.value = true;
399
+ const deletedIds: Asset["id"][] = [];
400
+ let deletedSize = 0;
401
+ let failedCount = 0;
402
+
403
+ try {
404
+ await runWithConcurrency(selectedAssets.value, 4, async (asset) => {
405
+ try {
406
+ await $fetch<apiResponse>(
407
+ `${config.public.apiBase}${database.value.slug}/assets${currentPath.value}/${asset.type === "dir" ? asset.name : asset.id}`,
408
+ {
409
+ method: "DELETE",
410
+ params: {
411
+ locale: Language.value,
412
+ [`${database.value.slug}_sid`]: sessionID.value,
413
+ },
414
+ credentials: "include",
415
+ },
416
+ );
417
+
418
+ deletedIds.push(asset.id);
419
+ deletedSize += asset.size ?? 0;
420
+ } catch {
421
+ failedCount += 1;
422
+ }
423
+ });
424
+
425
+ if (deletedIds.length) {
426
+ const deletedSet = new Set(deletedIds);
427
+ assets.value = (assets.value ?? []).filter(
428
+ (asset) => !deletedSet.has(asset.id),
429
+ );
430
+ selectedAssetIds.value = selectedAssetIds.value.filter(
431
+ (id) => !deletedSet.has(id),
432
+ );
433
+ if (database.value.size)
434
+ database.value.size = Math.max(0, database.value.size - deletedSize);
435
+ }
436
+
437
+ if (deletedIds.length)
438
+ window.$message.success(`${deletedIds.length} ${t("success")}`);
439
+ if (failedCount) window.$message.error(`${failedCount} ${t("error")}`);
440
+ } catch (error) {
441
+ console.error("Failed to delete selected assets", error);
442
+ window.$message.error(t("error"));
443
+ } finally {
444
+ bulkDeleteLoading.value = false;
445
+ }
446
+ }
447
+
448
+ function getZipSafeName(asset: Asset, usedNames: Map<string, number>) {
449
+ const extension = asset.extension ? `.${asset.extension}` : "";
450
+ const rawName = `${asset.name}${extension}`;
451
+ const sanitized =
452
+ rawName.replace(/[\\/:*?"<>|]+/g, "_").trim() ||
453
+ `asset-${asset.id}${extension}`;
454
+ const currentCount = usedNames.get(sanitized) ?? 0;
455
+ usedNames.set(sanitized, currentCount + 1);
456
+ if (!currentCount) return sanitized;
457
+ const nameWithoutExt = sanitized.slice(
458
+ 0,
459
+ sanitized.length - extension.length,
460
+ );
461
+ return `${nameWithoutExt}-${currentCount + 1}${extension}`;
462
+ }
463
+
464
+ async function fetchAllAssetsInPath(apiPath: string): Promise<Asset[]> {
465
+ const all: Asset[] = [];
466
+ let page = 1;
467
+ while (true) {
468
+ const data = await $fetch<apiResponse<Asset[]>>(
469
+ `${config.public.apiBase}${database.value.slug}/assets${apiPath}`,
470
+ {
471
+ credentials: "include",
472
+ params: {
473
+ options: Inison.stringify({ page, perPage: 500 }),
474
+ locale: Language.value,
475
+ [`${database.value.slug}_sid`]: sessionID.value,
476
+ },
477
+ },
478
+ );
479
+ all.push(...(data.result ?? []));
480
+ const totalPages = (data.options?.totalPages as number) ?? 0;
481
+ if (page >= totalPages) break;
482
+ page++;
483
+ }
484
+ return all;
485
+ }
486
+
487
+ async function collectZipFiles(
488
+ assetsToProcess: Asset[],
489
+ apiBasePath: string,
490
+ zipBasePath: string,
491
+ ): Promise<{ asset: Asset; zipPath: string }[]> {
492
+ const result: { asset: Asset; zipPath: string }[] = [];
493
+ const usedNames = new Map<string, number>();
494
+ for (const asset of assetsToProcess) {
495
+ if (asset.type === "dir") {
496
+ const subApiPath = `${apiBasePath}/${asset.name}`;
497
+ const subZipPath = zipBasePath
498
+ ? `${zipBasePath}/${asset.name}`
499
+ : asset.name;
500
+ const subAssets = await fetchAllAssetsInPath(subApiPath);
501
+ result.push(
502
+ ...(await collectZipFiles(subAssets, subApiPath, subZipPath)),
503
+ );
504
+ } else if (asset.publicURL) {
505
+ const fileName = getZipSafeName(asset, usedNames);
506
+ result.push({
507
+ asset,
508
+ zipPath: zipBasePath ? `${zipBasePath}/${fileName}` : fileName,
509
+ });
510
+ }
511
+ }
512
+ return result;
513
+ }
514
+
515
+ async function downloadSelectedAsZip() {
516
+ if (
517
+ !import.meta.client ||
518
+ !selectedAssets.value.length ||
519
+ bulkDownloadLoading.value
520
+ )
521
+ return;
522
+
523
+ bulkDownloadLoading.value = true;
524
+ bulkZipProgress.value = 0;
525
+
526
+ try {
527
+ const JSZip = (await import("jszip")).default;
528
+ const zip = new JSZip();
529
+ const selected = [...selectedAssets.value];
530
+
531
+ const allFiles = await collectZipFiles(selected, currentPath.value, "");
532
+
533
+ if (!allFiles.length) throw new Error("No files could be added to zip");
534
+
535
+ let addedFiles = 0;
536
+
537
+ await runWithConcurrency(allFiles, 3, async ({ asset, zipPath }) => {
538
+ try {
539
+ const response = await fetch(asset.publicURL);
540
+ if (!response.ok) return;
541
+ const blob = await response.blob();
542
+ zip.file(zipPath, blob, { binary: true });
543
+ addedFiles += 1;
544
+ bulkZipProgress.value = Math.round((addedFiles / allFiles.length) * 80);
545
+ } catch {
546
+ // Ignore individual asset failures and continue with the rest.
547
+ }
548
+ });
549
+
550
+ if (!addedFiles) throw new Error("No files could be added to zip");
551
+
552
+ const zipBlob = await zip.generateAsync(
553
+ {
554
+ type: "blob",
555
+ compression: "STORE",
556
+ },
557
+ (metadata) => {
558
+ bulkZipProgress.value = Math.max(
559
+ 80,
560
+ Math.round(80 + metadata.percent * 0.2),
561
+ );
562
+ },
563
+ );
564
+
565
+ const downloadURL = URL.createObjectURL(zipBlob);
566
+ const anchor = document.createElement("a");
567
+ const zipName = `assets-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`;
568
+ anchor.href = downloadURL;
569
+ anchor.download = zipName;
570
+ anchor.click();
571
+ setTimeout(() => URL.revokeObjectURL(downloadURL), 1000);
572
+ bulkZipProgress.value = 100;
573
+ } catch (error) {
574
+ console.error("Failed to download selected assets as zip", error);
575
+ window.$message.error(t("error"));
576
+ } finally {
577
+ bulkDownloadLoading.value = false;
578
+ bulkZipProgress.value = 0;
579
+ }
580
+ }
581
+
582
+ const searchString = ref<string | undefined>(
583
+ (route.query.search as string | undefined) ?? "",
584
+ );
585
+
586
+ const searchArray = ref<searchType>(
587
+ route.query.search
588
+ ? generateSearchArray(Inison.unstringify(route.query.search as string))
589
+ : { and: [[null, "=", null]] },
590
+ );
591
+
592
+ watch(Language, () => {
593
+ searchString.value = "";
594
+ searchArray.value = { and: [[null, "=", null]] };
595
+ });
596
+
597
+ watch(searchString, (v) => {
598
+ const { search, page, ...Query }: any = route.query;
599
+
600
+ router.push({
601
+ query: {
602
+ ...Query,
603
+ search: v,
604
+ },
605
+ });
606
+ });
607
+
608
+ const schema: Schema = [
609
+ {
610
+ key: "name",
611
+ type: "string",
612
+ },
613
+ {
614
+ key: "extension",
615
+ type: "array",
616
+ children: "string",
617
+ subType: "select",
618
+ options: [
619
+ {
620
+ label: t("image"),
621
+ value: [
622
+ "png",
623
+ "jpg",
624
+ "jpeg",
625
+ "gif",
626
+ "webp",
627
+ "svg",
628
+ "bmp",
629
+ "tiff",
630
+ "heic",
631
+ "avif",
632
+ ],
633
+ icon: "tabler:photo",
634
+ },
635
+ {
636
+ label: t("video"),
637
+ value: ["mp4", "mov", "wmv", "avi", "mkv", "flv", "webm", "rmvb"],
638
+ icon: "tabler:video",
639
+ },
640
+ {
641
+ label: t("audio"),
642
+ value: ["mp3", "wav", "aac", "flac", "ogg", "m4a"],
643
+ icon: "tabler:audio",
644
+ },
645
+ {
646
+ label: t("document"),
647
+ value: ["docx", "pdf", "txt", "pptx", "xlsx"],
648
+ icon: "tabler:file-text",
649
+ },
650
+ {
651
+ label: t("archive"),
652
+ value: ["zip", "rar", "tar", "gz"],
653
+ icon: "tabler:archive",
654
+ },
655
+ ],
656
+ },
657
+ ];
658
+
659
+ const syncPaginationPageSize = () => {
660
+ if (route.query.perPage) return;
661
+ const nextPageSize = getResponsivePageSize();
662
+ if (pagination.pageSize !== nextPageSize) {
663
+ pagination.pageSize = nextPageSize;
664
+ }
665
+ };
666
+
667
+ const pagination = reactive({
668
+ page: isAssetRoute && route.query.page ? Number(route.query.page) : 1,
669
+ pageCount: 1,
670
+ pageSize:
671
+ isAssetRoute && route.query.perPage
672
+ ? Number(route.query.perPage)
673
+ : getResponsivePageSize(),
674
+ itemCount: 0,
675
+ async onUpdatePage(currentPage: number) {
676
+ pagination.page = currentPage;
677
+ if (!isAssetRoute) return;
678
+ let { page, ...Query }: any = route.query;
679
+ Query = {
680
+ ...Query,
681
+ page: currentPage !== 1 ? currentPage : undefined,
682
+ };
683
+ router.push({ query: Query });
684
+ },
685
+ async onUpdatePageSize(pageSize: number) {
686
+ const OLD_pageSize = toRaw(pagination.pageSize);
687
+ pagination.pageSize = pageSize;
688
+ if (!isAssetRoute) return;
689
+ let { perPage, page, ...Query }: any = route.query;
690
+ const defaultPageSize = Number(
691
+ route.query.perPage ?? getResponsivePageSize(),
692
+ );
693
+ if (pageSize !== defaultPageSize) {
694
+ pagination.page = Math.round(
695
+ OLD_pageSize < pageSize
696
+ ? page / (pageSize / OLD_pageSize)
697
+ : page * (pageSize / OLD_pageSize),
698
+ );
699
+ if (Number.isNaN(pagination.page)) pagination.page = 1;
700
+ Query = {
701
+ ...Query,
702
+ perPage: pageSize,
703
+ page: pagination.page === 1 ? undefined : pagination.page,
704
+ };
705
+ router.push({
706
+ query: Query,
707
+ });
708
+ }
709
+ },
710
+ });
711
+
712
+ onMounted(() => {
713
+ syncPaginationPageSize();
714
+ window.addEventListener("resize", syncPaginationPageSize);
715
+ });
716
+
717
+ onBeforeUnmount(() => {
718
+ window.removeEventListener("resize", syncPaginationPageSize);
719
+ });
720
+
721
+ const showSizePicker = ref(false);
722
+ async function onUpdatePage(currentPage: number) {
723
+ pagination.page = currentPage;
724
+ let Query = route.query;
725
+ if (currentPage !== 1) Query = { ...Query, page: currentPage as any };
726
+ if (isAssetRoute)
727
+ router.push({ query: (({ page, ...rest }) => rest)(Query) });
728
+ return refresh();
729
+ }
730
+ async function onUpdatePageSize(currentPageSize: number) {
731
+ const OLD_pageSize = toRaw(pagination.pageSize);
732
+ pagination.pageSize = currentPageSize;
733
+ const newPage = Math.round(
734
+ OLD_pageSize < pagination.pageSize
735
+ ? pagination.page / (pagination.pageSize / OLD_pageSize)
736
+ : pagination.page * (pagination.pageSize / OLD_pageSize),
737
+ );
738
+ pagination.page = Number.isNaN(newPage) ? 1 : newPage;
739
+ const Query = {
740
+ ...route.query,
741
+ perPage: pagination.pageSize,
742
+ page: pagination.page,
743
+ };
744
+ if (isAssetRoute)
745
+ router.push({ query: Query });
746
+ return refresh();
747
+ }
748
+
749
+ const queryOptions = computed(() =>
750
+ Inison.stringify({
751
+ page: pagination.page,
752
+ perPage: pagination.pageSize,
753
+ }),
754
+ );
755
+
756
+ const { refresh } = await useLazyFetch<apiResponse<Asset[]>>(
757
+ () =>
758
+ `${config.public.apiBase}${database.value.slug}/assets${currentPath.value}`,
759
+ {
760
+ onRequest: () => {
761
+ Loading.value.AssetData = true;
762
+ },
763
+ onResponse: ({ response: { _data } }) => {
764
+ Loading.value.AssetData = false;
765
+
766
+ _data = _data as apiResponse<Asset[]>;
767
+
768
+ assets.value = _data.result;
769
+
770
+ showSizePicker.value = !!_data.options?.total;
771
+
772
+ pagination.pageCount = _data.options.totalPages ?? 0;
773
+ pagination.itemCount = _data.options.total ?? 0;
774
+
775
+ return _data.result;
776
+ },
777
+ query: {
778
+ options: queryOptions,
779
+ where: searchString,
780
+ locale: Language.value,
781
+ [`${database.value.slug}_sid`]: sessionID.value,
782
+ },
783
+ credentials: "include",
784
+ },
785
+ );
786
+
787
+ const UploadProgress = ref<number>(0);
788
+ let uploadCompleteTimer: ReturnType<typeof setTimeout> | undefined;
789
+ function onUpdateFileList(fileList: Required<UploadFileInfo>[]) {
790
+ if (!fileList.length) return;
791
+
792
+ const allFinished = fileList.every((f) => f.status === "finished");
793
+
794
+ if (allFinished) {
795
+ // Guard against multiple concurrent completion sequences
796
+ if (uploadCompleteTimer !== undefined) return;
797
+ uploadCompleteTimer = setTimeout(() => {
798
+ UploadProgress.value = 10000;
799
+ uploadCompleteTimer = setTimeout(() => {
800
+ UploadProgress.value = 0;
801
+ uploadCompleteTimer = undefined;
802
+ }, 5000);
803
+ }, 2000);
804
+ return;
805
+ }
806
+
807
+ // New uploads arrived – cancel any pending completion animation
808
+ if (uploadCompleteTimer !== undefined) {
809
+ clearTimeout(uploadCompleteTimer);
810
+ uploadCompleteTimer = undefined;
811
+ }
812
+
813
+ const nonFinished = fileList.filter((f) => f.status !== "finished");
814
+ if (!nonFinished.length) {
815
+ UploadProgress.value = 1000;
816
+ return;
817
+ }
818
+
819
+ // Cap at 99 so the circle never shows "100% success" before the check animation
820
+ const avg =
821
+ nonFinished.reduce((sum, f) => sum + (f.percentage ?? 0), 0) /
822
+ nonFinished.length;
823
+ UploadProgress.value = Math.max(1, Math.min(99, Math.round(avg)));
824
+ }
825
+
826
+ const { optimizeFile } = useOptimizeFile();
827
+ const { uploadAssetWithProgress } = useAssetUploader();
828
+ const showSkipCompressionTooltip = ref(false);
829
+
830
+ async function customRequest({
831
+ file,
832
+ headers,
833
+ action,
834
+ onFinish,
835
+ onError,
836
+ onProgress,
837
+ }: UploadCustomRequestOptions) {
838
+ // Track this file as being processed
839
+ processingFileId.value = file.id;
840
+
841
+ try {
842
+ const originalFile = file.file;
843
+ if (!originalFile) throw new Error("Missing file payload");
844
+
845
+ let fileToUpload: File = originalFile;
846
+ const isVideo = originalFile.type?.startsWith("video/") ?? false;
847
+ const isPdf =
848
+ originalFile.type === "application/pdf" ||
849
+ originalFile.name.toLowerCase().endsWith(".pdf");
850
+
851
+ if (isVideo || isPdf) {
852
+ showSkipCompressionTooltip.value = true;
853
+ setTimeout(() => {
854
+ showSkipCompressionTooltip.value = false;
855
+ }, 800);
856
+ }
857
+
858
+ if (isVideo) {
859
+ notifyVideoSize(originalFile.size);
860
+ try {
861
+ fileToUpload = await compressVideo(originalFile);
862
+ } catch (compressionError) {
863
+ // If compression was aborted/terminated, use original file
864
+ if (String(compressionError).includes("terminated")) {
865
+ fileToUpload = originalFile;
866
+ compressionIndicator.value = null;
867
+ } else throw compressionError;
868
+ }
869
+ } else if (isPdf) {
870
+ notifyPdfSize(originalFile.size);
871
+ try {
872
+ fileToUpload = await compressPdf(fileToUpload);
873
+ } catch (pdfError) {
874
+ // If compression was aborted, use original file
875
+ if (String(pdfError).toLowerCase().includes("abort")) {
876
+ fileToUpload = originalFile;
877
+ compressionIndicator.value = null;
878
+ } else throw pdfError;
879
+ }
880
+ } else if (originalFile.type?.startsWith("image/")) {
881
+ const optimizationResult = await optimizeFile(originalFile);
882
+ if (optimizationResult.optimized) fileToUpload = optimizationResult.file;
883
+ }
884
+
885
+ // IMPORTANT: Completely replace the file object to prevent uploading both files
886
+ // Create a new File instance to ensure no reference to original
887
+ if (fileToUpload !== originalFile) {
888
+ fileToUpload = new File([fileToUpload], fileToUpload.name, {
889
+ type: fileToUpload.type,
890
+ lastModified: fileToUpload.lastModified,
891
+ });
892
+ }
893
+
894
+ // Update the file reference in the upload info
895
+ file.file = fileToUpload;
896
+ file.name = fileToUpload.name;
897
+ const mimeType = fileToUpload.type || file.type;
898
+ const { name, extension } = getFileNameAndExtension(
899
+ fileToUpload.name || file.name,
900
+ );
901
+
902
+ onProgress?.({ percent: 10 });
903
+
904
+ const { result } = await $fetch<apiResponse<Asset>>(action as string, {
905
+ method: "POST",
906
+ credentials: "include",
907
+ headers: headers as Record<string, string>,
908
+ body: { name, size: fileToUpload.size, type: mimeType, extension },
909
+ });
910
+
911
+ if (!result.uploadURL) {
912
+ if (result.id)
913
+ await $fetch<apiResponse<Asset>>(
914
+ `${action}/${result.id}?${database.value.slug}_sid=${sessionID.value}`,
915
+ {
916
+ method: "DELETE",
917
+ credentials: "include",
918
+ headers: headers as Record<string, string>,
919
+ },
920
+ );
921
+ throw new Error("Failed to get upload URL");
922
+ }
923
+
924
+ await uploadAssetWithProgress({
925
+ url: result.uploadURL as string,
926
+ method: result.uploadURL.includes("s3") ? "PUT" : "POST",
927
+ headers: { "Content-Type": mimeType as string },
928
+ file: fileToUpload,
929
+ onProgress,
930
+ });
931
+ compressionIndicator.value = null;
932
+
933
+ file.url = result.publicURL;
934
+ if (assets.value) assets.value.unshift(result);
935
+ else assets.value = [result];
936
+ if (!database.value.size) database.value.size = 0;
937
+ database.value.size += result.size ?? 0;
938
+
939
+ onFinish();
940
+ } catch (error) {
941
+ // Handle terminated/aborted compression
942
+ if (
943
+ String(error).includes("terminated") ||
944
+ String(error).includes("abort")
945
+ ) {
946
+ console.log("Compression skipped for:", file.name);
947
+ // Current file was being compressed when skip was clicked
948
+ // Mark as error status instead of removed to prevent re-upload triggers
949
+ file.status = "error";
950
+ compressionIndicator.value = null;
951
+ onFinish(); // Call onFinish to let the upload component know we're done
952
+ return;
953
+ }
954
+ compressionIndicator.value = null;
955
+ console.error("Error in customRequest:", error);
956
+ onError();
957
+ } finally {
958
+ // Clean up
959
+ if (processingFileId.value === file.id) {
960
+ processingFileId.value = null;
961
+ }
962
+ // Reset skip flag only after current file is done
963
+ if (skipCompressionRequested.value) {
964
+ skipCompressionRequested.value = false;
965
+ }
966
+ }
967
+ }
968
+
969
+ async function onRemoveUpload({ file }: { file: Required<UploadFileInfo> }) {
970
+ if (file.status !== "finished") {
971
+ abortVideo();
972
+ abortPdf();
973
+ return false;
974
+ }
975
+ const data = await $fetch<apiResponse<Asset>>(
976
+ `${config.public.apiBase}${
977
+ database.value.slug
978
+ }/assets${currentPath.value}/${file.name}`,
979
+ {
980
+ method: "DELETE",
981
+ params: {
982
+ locale: Language.value,
983
+ [`${database.value.slug}_sid`]: sessionID.value,
984
+ },
985
+ credentials: "include",
986
+ },
987
+ ),
988
+ singleAsset = assets.value?.find((asset) => asset.name === file.name);
989
+ if (data.result) {
990
+ if (assets.value)
991
+ assets.value = assets.value.filter((asset) => asset.name !== file.name);
992
+ window.$message.success(data?.message ?? t("success"));
993
+ if (database.value.size) database.value.size -= singleAsset?.size ?? 0;
994
+ return true;
995
+ }
996
+ window.$message.error(data?.message ?? t("error"));
997
+ return false;
998
+ }
999
+
1000
+ async function uploadScannedDocument(blob: Blob) {
1001
+ const fileName = `scan-${new Date().toISOString().replace(/[:.]/g, "-")}.png`;
1002
+ const file = new File([blob], fileName, { type: "image/png" });
1003
+
1004
+ if (uploadCompleteTimer !== undefined) {
1005
+ clearTimeout(uploadCompleteTimer);
1006
+ uploadCompleteTimer = undefined;
1007
+ }
1008
+
1009
+ try {
1010
+ await uploadFileToPath(file, currentPath.value);
1011
+ UploadProgress.value = 10000;
1012
+ await new Promise<void>((resolve) => setTimeout(resolve, 3000));
1013
+ UploadProgress.value = 0;
1014
+ window.$message?.success(t("success"));
1015
+ } catch (err) {
1016
+ console.error("Scan upload failed:", err);
1017
+ window.$message?.error(t("error"));
1018
+ UploadProgress.value = 0;
1019
+ } finally {
1020
+ await refresh();
1021
+ }
1022
+ }
1023
+
1024
+ const folder = ref();
1025
+ const showFolderInput = ref(false);
1026
+ const folderInputElRef = ref<InputInst>();
1027
+ const uploadRef = ref<UploadInst>();
1028
+
1029
+ const uploadDropdownOptions = computed(() => [
1030
+ {
1031
+ label: t("uploadFiles"),
1032
+ key: "uploadFiles",
1033
+ icon: () => h(NIcon, () => h(Icon, { name: "tabler:upload" })),
1034
+ },
1035
+ {
1036
+ label: t("uploadFolder"),
1037
+ key: "uploadFolder",
1038
+ icon: () => h(NIcon, () => h(Icon, { name: "tabler:folder-up" })),
1039
+ },
1040
+ {
1041
+ label: t("createFolder"),
1042
+ key: "createFolder",
1043
+ icon: () => h(NIcon, () => h(Icon, { name: "tabler:folder-plus" })),
1044
+ },
1045
+ {
1046
+ label: t("scanDocument"),
1047
+ key: "scanDocument",
1048
+ icon: () => h(NIcon, () => h(Icon, { name: "tabler:scan" })),
1049
+ },
1050
+ ]);
1051
+
1052
+ function onUploadDropdownSelect(key: string) {
1053
+ switch (key) {
1054
+ case "uploadFiles":
1055
+ uploadRef.value?.openOpenFileDialog();
1056
+ break;
1057
+ case "uploadFolder":
1058
+ folderInputRef.value?.click();
1059
+ break;
1060
+ case "createFolder":
1061
+ folder.value = "";
1062
+ showFolderInput.value = true;
1063
+ setTimeout(() => folderInputElRef.value?.focus(), 100);
1064
+ break;
1065
+ case "scanDocument":
1066
+ showScanner.value = true;
1067
+ break;
1068
+ }
1069
+ }
1070
+
1071
+ function cancelFolderCreation() {
1072
+ folder.value = "";
1073
+ showFolderInput.value = false;
1074
+ }
1075
+
1076
+ async function createFolder() {
1077
+ if (folder.value) {
1078
+ currentPath.value += `/${folder.value}`;
1079
+ await $fetch<apiResponse>(
1080
+ `${config.public.apiBase}${database.value.slug}/assets${currentPath.value}`,
1081
+ {
1082
+ method: "POST",
1083
+ credentials: "include",
1084
+ params: {
1085
+ locale: Language.value,
1086
+ [`${database.value.slug}_sid`]: sessionID.value,
1087
+ },
1088
+ },
1089
+ );
1090
+ window.$message.success(t("folderCreatedSuccessfully"));
1091
+ await nextTick();
1092
+ if (isAssetRoute)
1093
+ await navigateTo(
1094
+ `${route.params.database ? `/${database.value.slug}` : ""}/admin/tables/assets${currentPath.value}`,
1095
+ );
1096
+ folder.value = "";
1097
+ showFolderInput.value = false;
1098
+ } else window.$message.error(t("folderNameRequired"));
1099
+ }
1100
+
1101
+ watch(currentPath, () => {
1102
+ pagination.onUpdatePage(1);
1103
+ pagination.itemCount = 0;
1104
+ });
1105
+
1106
+ // --- Drag & Drop ---
1107
+ const isDragOver = ref(false);
1108
+ const folderInputRef = ref<HTMLInputElement>();
1109
+ const dragCounter = ref(0);
1110
+
1111
+ function onDragEnter(e: DragEvent) {
1112
+ if (!table.value?.allowedMethods?.includes("c")) return;
1113
+ const hasFiles = Array.from(e.dataTransfer?.items ?? []).some(
1114
+ (item) => item.kind === "file",
1115
+ );
1116
+ if (!hasFiles) return;
1117
+ dragCounter.value++;
1118
+ isDragOver.value = true;
1119
+ }
1120
+
1121
+ function onDragLeave() {
1122
+ dragCounter.value--;
1123
+ if (dragCounter.value <= 0) {
1124
+ isDragOver.value = false;
1125
+ dragCounter.value = 0;
1126
+ }
1127
+ }
1128
+
1129
+ function onDragOver(e: DragEvent) {
1130
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
1131
+ }
1132
+
1133
+ async function readAllDirectoryEntries(
1134
+ reader: FileSystemDirectoryReader,
1135
+ ): Promise<FileSystemEntry[]> {
1136
+ const all: FileSystemEntry[] = [];
1137
+ let batch: FileSystemEntry[];
1138
+ do {
1139
+ batch = await new Promise<FileSystemEntry[]>((resolve, reject) => {
1140
+ reader.readEntries(resolve, reject);
1141
+ });
1142
+ all.push(...batch);
1143
+ } while (batch.length > 0);
1144
+ return all;
1145
+ }
1146
+
1147
+ async function uploadFileToPath(file: File, targetPath: string) {
1148
+ const isVideo = file.type?.startsWith("video/") ?? false;
1149
+ const isPdf =
1150
+ file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
1151
+
1152
+ let fileToUpload: File = file;
1153
+
1154
+ if (isVideo) {
1155
+ notifyVideoSize(file.size);
1156
+ try {
1157
+ fileToUpload = await compressVideo(file);
1158
+ } catch {
1159
+ fileToUpload = file;
1160
+ compressionIndicator.value = null;
1161
+ }
1162
+ } else if (isPdf) {
1163
+ notifyPdfSize(file.size);
1164
+ try {
1165
+ fileToUpload = await compressPdf(file);
1166
+ } catch {
1167
+ fileToUpload = file;
1168
+ compressionIndicator.value = null;
1169
+ }
1170
+ } else if (file.type?.startsWith("image/")) {
1171
+ const optimizationResult = await optimizeFile(file);
1172
+ if (optimizationResult.optimized) fileToUpload = optimizationResult.file;
1173
+ }
1174
+
1175
+ const { name, extension } = getFileNameAndExtension(fileToUpload.name);
1176
+ const mimeType = fileToUpload.type || file.type;
1177
+ const action = `${config.public.apiBase}${database.value.slug}/assets${targetPath}?${database.value.slug}_sid=${sessionID.value}`;
1178
+
1179
+ const { result } = await $fetch<apiResponse<Asset>>(action, {
1180
+ method: "POST",
1181
+ credentials: "include",
1182
+ body: { name, size: fileToUpload.size, type: mimeType, extension },
1183
+ });
1184
+
1185
+ if (!result.uploadURL) throw new Error("Failed to get upload URL");
1186
+
1187
+ await uploadAssetWithProgress({
1188
+ url: result.uploadURL as string,
1189
+ method: result.uploadURL.includes("s3") ? "PUT" : "POST",
1190
+ headers: { "Content-Type": mimeType as string },
1191
+ file: fileToUpload,
1192
+ });
1193
+
1194
+ compressionIndicator.value = null;
1195
+
1196
+ if (targetPath === currentPath.value) {
1197
+ if (assets.value) assets.value.unshift(result);
1198
+ else assets.value = [result];
1199
+ }
1200
+ if (!database.value.size) database.value.size = 0;
1201
+ database.value.size += result.size ?? 0;
1202
+ }
1203
+
1204
+ async function ensureFolderExists(folderPath: string) {
1205
+ try {
1206
+ await $fetch<apiResponse>(
1207
+ `${config.public.apiBase}${database.value.slug}/assets${folderPath}`,
1208
+ {
1209
+ method: "POST",
1210
+ credentials: "include",
1211
+ params: {
1212
+ locale: Language.value,
1213
+ [`${database.value.slug}_sid`]: sessionID.value,
1214
+ },
1215
+ },
1216
+ );
1217
+ } catch {
1218
+ // Folder may already exist — ignore
1219
+ }
1220
+ }
1221
+
1222
+ async function processDroppedEntries(
1223
+ entries: FileSystemEntry[],
1224
+ basePath: string,
1225
+ ) {
1226
+ for (const entry of entries) {
1227
+ if (entry.isFile) {
1228
+ const file = await new Promise<File>((resolve, reject) => {
1229
+ (entry as FileSystemFileEntry).file(resolve, reject);
1230
+ });
1231
+ try {
1232
+ await uploadFileToPath(file, basePath);
1233
+ } catch (err) {
1234
+ console.error("Drop upload failed for:", entry.name, err);
1235
+ } finally {
1236
+ folderUploadProgress.current++;
1237
+ }
1238
+ } else if (entry.isDirectory) {
1239
+ const dirPath = `${basePath}/${entry.name}`;
1240
+ await ensureFolderExists(dirPath);
1241
+ const reader = (entry as FileSystemDirectoryEntry).createReader();
1242
+ const subEntries = await readAllDirectoryEntries(reader);
1243
+ await processDroppedEntries(subEntries, dirPath);
1244
+ }
1245
+ }
1246
+ }
1247
+
1248
+ async function onDrop(e: DragEvent) {
1249
+ isDragOver.value = false;
1250
+ dragCounter.value = 0;
1251
+
1252
+ if (!table.value?.allowedMethods?.includes("c")) return;
1253
+
1254
+ const items = e.dataTransfer?.items;
1255
+ if (!items?.length) return;
1256
+
1257
+ const entries: FileSystemEntry[] = [];
1258
+ for (let i = 0; i < items.length; i++) {
1259
+ const item = items[i];
1260
+ if (!item) continue;
1261
+ const entry = item.webkitGetAsEntry?.();
1262
+ if (entry) entries.push(entry);
1263
+ }
1264
+ if (!entries.length) return;
1265
+
1266
+ if (uploadCompleteTimer !== undefined) {
1267
+ clearTimeout(uploadCompleteTimer);
1268
+ uploadCompleteTimer = undefined;
1269
+ }
1270
+ folderUploadProgress.current = 0;
1271
+ folderUploadProgress.total = 0;
1272
+ UploadProgress.value = 1001;
1273
+ try {
1274
+ await processDroppedEntries(entries, currentPath.value);
1275
+ UploadProgress.value = 10000;
1276
+ await new Promise<void>((resolve) => setTimeout(resolve, 3000));
1277
+ UploadProgress.value = 0;
1278
+ } catch (err) {
1279
+ console.error("Drop upload error:", err);
1280
+ UploadProgress.value = 0;
1281
+ } finally {
1282
+ await refresh();
1283
+ }
1284
+ }
1285
+
1286
+ async function onFolderInputChange(e: Event) {
1287
+ const input = e.target as HTMLInputElement;
1288
+ const files = Array.from(input.files ?? []);
1289
+ input.value = "";
1290
+ if (!files.length) return;
1291
+
1292
+ if (uploadCompleteTimer !== undefined) {
1293
+ clearTimeout(uploadCompleteTimer);
1294
+ uploadCompleteTimer = undefined;
1295
+ }
1296
+ UploadProgress.value = 1001;
1297
+ try {
1298
+ const dirsNeeded = new Set<string>();
1299
+ const uploads: { file: File; targetPath: string }[] = files.map((file) => {
1300
+ const relativePath = file.webkitRelativePath || file.name;
1301
+ const parts = relativePath.split("/");
1302
+ // parts[0] is the root folder name — create it and upload content inside it
1303
+ const rootPath = `${currentPath.value}/${parts[0]}`;
1304
+ dirsNeeded.add(rootPath);
1305
+ const folderParts = parts.slice(1, -1);
1306
+
1307
+ let pathAcc = rootPath;
1308
+ for (const part of folderParts) {
1309
+ pathAcc = `${pathAcc}/${part}`;
1310
+ dirsNeeded.add(pathAcc);
1311
+ }
1312
+
1313
+ const targetPath =
1314
+ folderParts.length > 0
1315
+ ? `${rootPath}/${folderParts.join("/")}`
1316
+ : rootPath;
1317
+
1318
+ return { file, targetPath };
1319
+ });
1320
+
1321
+ // Create directories shallowest-first
1322
+ const sortedDirs = Array.from(dirsNeeded).sort(
1323
+ (a, b) => a.split("/").length - b.split("/").length,
1324
+ );
1325
+ folderUploadProgress.current = 0;
1326
+ folderUploadProgress.total = uploads.length;
1327
+ for (const dir of sortedDirs) {
1328
+ await ensureFolderExists(dir);
1329
+ }
1330
+
1331
+ for (const { file, targetPath } of uploads) {
1332
+ try {
1333
+ await uploadFileToPath(file, targetPath);
1334
+ } catch (err) {
1335
+ console.error("Folder upload failed for:", file.name, err);
1336
+ } finally {
1337
+ folderUploadProgress.current++;
1338
+ }
1339
+ }
1340
+
1341
+ UploadProgress.value = 10000;
1342
+ await new Promise<void>((resolve) => setTimeout(resolve, 3000));
1343
+ UploadProgress.value = 0;
1344
+ } catch (err) {
1345
+ console.error("Folder upload error:", err);
1346
+ UploadProgress.value = 0;
1347
+ } finally {
1348
+ await refresh();
1349
+ }
1350
+ }
1351
+ </script>
1352
+
1353
+ <style scoped>
1354
+ .drop-fade-enter-active,
1355
+ .drop-fade-leave-active {
1356
+ transition: opacity 0.15s ease;
1357
+ }
1358
+
1359
+ .drop-fade-enter-from,
1360
+ .drop-fade-leave-to {
1361
+ opacity: 0;
1362
+ }
1363
+ </style>