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,639 @@
1
+ <template>
2
+ <FieldWrapper :field :rule v-model="modelValue">
3
+ <template #label>
4
+ <NFlex inline align="center" size="small" :style="`margin-${(Language === 'ar' ? 'right' : 'left')}: 5px`">
5
+ <LazyFieldAssetActions v-model:showAssetsModal="showAssetsModal" :field
6
+ :callback="importAssetCallback" />
7
+ <NButton v-if="acceptsImages" circle secondary size="tiny" :title="t('scanDocument')"
8
+ @click.prevent.stop="showScanner = true">
9
+ <template #icon>
10
+ <NIcon>
11
+ <Icon name="tabler:scan" />
12
+ </NIcon>
13
+ </template>
14
+ </NButton>
15
+ </NFlex>
16
+ </template>
17
+ <NUpload ref="uploadRef" directory-dnd :max="!field.isArray ? 1 : undefined" :multiple="!!field.isArray"
18
+ :accept="acceptedFileType"
19
+ :action="`${config.public.apiBase}${database.slug ?? 'inicontent'}/assets${field.suffix ? renderLabel({ ...table, label: field.suffix }, currentItem) : ''}`"
20
+ :fileList @update:file-list="setModelValue" :custom-request @remove="handleRemoveUpload"
21
+ :list-type="!field.isTable ? 'image' : 'image-card'" :renderIcon :shouldUseThumbnailUrl="() => false">
22
+ <NUploadDragger v-if="compressionIndicator">
23
+ <NProgress type="circle" status="warning" :percentage="compressionIndicator" :stroke-width="10"
24
+ style="width:54px" indicator-placement="inside">
25
+ <NTooltip v-model:show="showSkipCompressionTooltip" placement="top">
26
+ <template #trigger>
27
+ <Icon @click.stop="skipCompression" name="tabler:player-track-next-filled" />
28
+ </template>
29
+ {{ t("skipCompression") }}
30
+ </NTooltip>
31
+ </NProgress>
32
+ </NUploadDragger>
33
+ <NUploadDragger v-else-if="!field.isTable">
34
+ <NFlex vertical align="center">
35
+ <NIcon size="48" depth="3">
36
+ <Icon name="tabler:upload" />
37
+ </NIcon>
38
+ <NFlex v-if="field.labelProps?.showLabel === false">
39
+ <LazyFieldAssetActions v-model:showAssetsModal="showAssetsModal" :field
40
+ :callback="importAssetCallback" />
41
+ <NButton v-if="acceptsImages" circle secondary size="tiny" :title="t('scanDocument')"
42
+ @click.prevent.stop="showScanner = true">
43
+ <template #icon>
44
+ <NIcon>
45
+ <Icon name="tabler:scan" />
46
+ </NIcon>
47
+ </template>
48
+ </NButton>
49
+ </NFlex>
50
+ </NFlex>
51
+ </NUploadDragger>
52
+ <NFlex v-else align="center" size="small">
53
+ <LazyFieldAssetActions v-model:showAssetsModal="showAssetsModal" :field
54
+ :callback="importAssetCallback" />
55
+ <NButton v-if="acceptsImages" circle secondary size="tiny" :title="t('scanDocument')"
56
+ @click.prevent.stop="showScanner = true">
57
+ <template #icon>
58
+ <NIcon>
59
+ <Icon name="tabler:scan" />
60
+ </NIcon>
61
+ </template>
62
+ </NButton>
63
+ </NFlex>
64
+ </NUpload>
65
+ <NDrawer v-model:show="showAssetsModal" defaultHeight="50%" placement="bottom" resizable>
66
+ <NDrawerContent id="assetsModal" :nativeScrollbar="false" :bodyContentStyle="{ paddingTop: 0, paddingBottom: 0 }">
67
+ <AssetCard targetID="assetsModal" :where="assetWhere" :suffix="field.suffix">
68
+ <template v-slot="{ asset }">
69
+ <template v-if="asset.type !== 'dir'">
70
+ <NCheckbox v-if="field.isArray" :checked="getChecked(asset)"
71
+ @update:checked="handleSelectAsset(asset)" />
72
+ <NRadio v-else :checked="getChecked(asset)" @update:checked="handleSelectAsset(asset)" />
73
+ </template>
74
+ </template>
75
+ </AssetCard>
76
+ </NDrawerContent>
77
+ </NDrawer>
78
+
79
+ <LazyAssetScanner v-model:show="showScanner" :allowOCR="false"
80
+ @scanned="(blob) => uploadScannedDocument(blob)" />
81
+ </FieldWrapper>
82
+ </template>
83
+
84
+ <script lang="ts" setup>
85
+ import { isArrayOfObjects, isObject } from "inibase/utils";
86
+ import type {
87
+ FormItemRule,
88
+ UploadCustomRequestOptions,
89
+ UploadFileInfo,
90
+ } from "naive-ui";
91
+ import { Icon, LazyAssetThumb } from "#components";
92
+ import { getFileNameAndExtension } from "~/composables";
93
+ import { useOptimizeFile } from "~/composables/optimizeFile";
94
+ import renderLabel from "~/composables/renderLabel";
95
+ import { useAssetUploader } from "~/composables/useAssetUploader";
96
+ import { usePdfCompressor } from "~/composables/usePdfCompressor";
97
+ import { useVideoCompressor } from "~/composables/useVideoCompressor";
98
+
99
+ const { field } = defineProps<{ field: Field }>();
100
+
101
+ const modelValue = defineModel<string | Asset | (string | Asset)[]>();
102
+
103
+ const database = useState<Database>("database");
104
+
105
+ const Language = useScopedCookie<LanguagesType>("language", database.value?.slug);
106
+
107
+ const config = useRuntimeConfig();
108
+ const LARGE_VIDEO_BYTES = 512 * 1024 * 1024;
109
+ const HUGE_VIDEO_BYTES = 2 * 1024 * 1024 * 1024;
110
+ const LARGE_PDF_BYTES = 50 * 1024 * 1024;
111
+ const HUGE_PDF_BYTES = 200 * 1024 * 1024;
112
+
113
+ const {
114
+ compressVideo,
115
+ loading: videoLoading,
116
+ progress: videoProgress,
117
+ abort: abortVideo,
118
+ resetSkip: resetVideoSkip,
119
+ } = useVideoCompressor();
120
+ const {
121
+ compressPdf,
122
+ loading: pdfLoading,
123
+ progress: pdfProgress,
124
+ abort: abortPdf,
125
+ resetSkip: resetPdfSkip,
126
+ } = usePdfCompressor();
127
+
128
+ const compressionIndicator = ref<number | null>(null);
129
+ const skipCompressionRequested = ref(false);
130
+ const processingFileId = ref<string | null>(null); // Track which file is being processed
131
+
132
+ const skipCompression = () => {
133
+ if (compressionIndicator.value === null) return;
134
+ skipCompressionRequested.value = true;
135
+ abortVideo();
136
+ abortPdf();
137
+ };
138
+
139
+ async function handleRemoveUpload({
140
+ file,
141
+ }: {
142
+ file: Required<UploadFileInfo>;
143
+ }) {
144
+ if (file.status !== "finished") {
145
+ // If this is the file currently being processed, abort and reset
146
+ if (processingFileId.value === file.id) {
147
+ skipCompressionRequested.value = true;
148
+ abortVideo();
149
+ abortPdf();
150
+ processingFileId.value = null;
151
+ }
152
+
153
+ fileList.value = (fileList.value || []).filter(
154
+ (item) => item.id !== file.id,
155
+ );
156
+ await nextTick();
157
+ setModelValue(fileList.value);
158
+ return false;
159
+ }
160
+
161
+ // Only assets uploaded in this session (tracked in fileIdObject) should be hard-deleted;
162
+ // pre-existing assets (loaded on mount or picked from the library) are just detached.
163
+ const uploadedAssetId = fileIdObject.value[file.id];
164
+ if (uploadedAssetId) {
165
+ try {
166
+ const path = field.suffix
167
+ ? renderLabel(
168
+ { ...table.value, label: field.suffix },
169
+ currentItem.value,
170
+ )
171
+ : "";
172
+ await $fetch(
173
+ `${config.public.apiBase}${database.value.slug}/assets${path}/${uploadedAssetId}`,
174
+ {
175
+ method: "DELETE",
176
+ params: {
177
+ locale: Language.value,
178
+ [`${database.value.slug}_sid`]: sessionID.value,
179
+ },
180
+ credentials: "include",
181
+ },
182
+ );
183
+
184
+ const asset = Array.isArray(modelValue.value)
185
+ ? (modelValue.value as Asset[]).find((a) => a.id === uploadedAssetId)
186
+ : (modelValue.value as Asset | undefined);
187
+
188
+ if (asset && asset.id === uploadedAssetId && database.value.size) {
189
+ database.value.size -= asset.size ?? 0;
190
+ }
191
+
192
+ delete fileIdObject.value[file.id];
193
+ } catch (e) {
194
+ console.error("Failed to delete asset", e);
195
+ }
196
+ }
197
+
198
+ return true;
199
+ }
200
+
201
+ const getIndicatorPercent = (progress: number) => {
202
+ const raw = Math.round(progress * 100);
203
+ if (!Number.isFinite(raw)) return 1;
204
+ return Math.min(99, Math.max(1, raw));
205
+ };
206
+
207
+ watchEffect(() => {
208
+ if (videoLoading.value) {
209
+ compressionIndicator.value = getIndicatorPercent(videoProgress.value);
210
+ } else if (pdfLoading.value) {
211
+ compressionIndicator.value = getIndicatorPercent(pdfProgress.value);
212
+ } else {
213
+ compressionIndicator.value = null;
214
+ }
215
+ });
216
+
217
+ const notifyVideoSize = (size: number) => {
218
+ if (!import.meta.client) return;
219
+ const messageApi = window?.$message;
220
+ if (!messageApi) return;
221
+ const formattedSize = humanFileSize(size);
222
+ if (size >= HUGE_VIDEO_BYTES)
223
+ messageApi.warning(t("compression.videoHuge", { size: formattedSize }));
224
+ else if (size >= LARGE_VIDEO_BYTES)
225
+ messageApi.info(t("compression.videoLarge", { size: formattedSize }));
226
+ };
227
+
228
+ const notifyPdfSize = (size: number) => {
229
+ if (!import.meta.client) return;
230
+ const messageApi = window?.$message;
231
+ if (!messageApi) return;
232
+ const formattedSize = humanFileSize(size);
233
+ if (size >= HUGE_PDF_BYTES)
234
+ messageApi.warning(t("compression.pdfHuge", { size: formattedSize }));
235
+ else if (size >= LARGE_PDF_BYTES)
236
+ messageApi.info(t("compression.pdfLarge", { size: formattedSize }));
237
+ };
238
+
239
+ const rule: FormItemRule = {
240
+ type: field.isArray ? "array" : "object",
241
+ required: field.required,
242
+ min: field.isArray ? field.min : undefined,
243
+ max: field.isArray ? field.max : undefined,
244
+ validator: async () => {
245
+ await nextTick();
246
+ return fieldValidator(field, modelValue.value);
247
+ },
248
+ };
249
+
250
+ const showAssetsModal = ref(false);
251
+ const showScanner = ref(false);
252
+ const uploadRef = ref<{ submit?: (options?: { fileId?: string }) => void }>();
253
+
254
+ const acceptsImages = computed(
255
+ () => !field.accept || field.accept.includes("image"),
256
+ );
257
+
258
+ function uploadScannedDocument(blob: Blob) {
259
+ const fileName = `scan-${new Date().toISOString().replace(/[:.]/g, "-")}.png`;
260
+ const file = new File([blob], fileName, { type: "image/png" });
261
+
262
+ const newFile: UploadFileInfo = {
263
+ id: `scan-${Date.now()}`,
264
+ name: fileName,
265
+ file,
266
+ status: "pending",
267
+ type: "image/png",
268
+ };
269
+
270
+ // Prepend to the controlled list so NUpload renders it, then submit it
271
+ fileList.value = [newFile, ...(fileList.value ?? [])];
272
+ showScanner.value = false;
273
+
274
+ nextTick(() => {
275
+ uploadRef.value?.submit?.();
276
+ });
277
+ }
278
+
279
+ const acceptedFileType = computed(() => {
280
+ if (!field.accept) return undefined;
281
+ const RETURN = [];
282
+ for (const type of field.accept) {
283
+ switch (type) {
284
+ case "image":
285
+ RETURN.push("image/*");
286
+ break;
287
+ case "video":
288
+ RETURN.push("video/*");
289
+ break;
290
+ case "audio":
291
+ RETURN.push("audio/*");
292
+ break;
293
+ case "document":
294
+ RETURN.push(".doc, .docx, .pdf, .txt, .rtf, .odt");
295
+ break;
296
+ case "archive":
297
+ RETURN.push(".zip, .rar, .7z, .tar, .gz");
298
+ break;
299
+ }
300
+ }
301
+ return RETURN.join(",");
302
+ });
303
+
304
+ const assetWhere = computed(() => {
305
+ if (!field.accept) return undefined;
306
+ let whereType = "[]";
307
+ for (const type of field.accept) {
308
+ switch (type) {
309
+ case "image":
310
+ whereType += "image/png,image/jpeg,image/webp,image/avif,";
311
+ break;
312
+ case "video":
313
+ whereType +=
314
+ "video/mp4, video/mpeg, video/quicktime, video/x-msvideo, video/x-flv, video/webm, video/ogg, video/3gpp, video/3gpp2, video/x-matroska, video/vnd.mpegurl, video/h264, video/x-ms-wmv, video/x-ms-asf,";
315
+ break;
316
+ case "audio":
317
+ whereType +=
318
+ "audio/mpeg, audio/wav, audio/ogg, audio/aac, audio/flac, audio/mp4, audio/x-wav, audio/midi, audio/x-midi, audio/x-pn-wav, audio/x-aiff, audio/x-flac, audio/webm, audio/3gpp, audio/3gpp2, audio/amr, audio/x-ms-wma, audio/vnd.rn-realaudio, audio/vnd.wave,";
319
+ break;
320
+ case "document":
321
+ whereType +=
322
+ "application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/pdf, text/plain, application/rtf, application/vnd.oasis.opendocument.text,";
323
+ break;
324
+ case "archive":
325
+ whereType +=
326
+ "application/zip, application/x-rar-compressed, application/x-7z-compressed, application/x-tar, application/gzip,";
327
+ break;
328
+ }
329
+ }
330
+ return { type: whereType.slice(0, -1) };
331
+ });
332
+
333
+ function importAssetCallback(assets: Asset | Asset[]) {
334
+ const isModelValueTable = field.type !== "url" && field.children !== "url";
335
+ const value = ([] as Asset[])
336
+ .concat(assets)
337
+ .map((asset) => (isModelValueTable ? asset : asset.publicURL));
338
+
339
+ if (field.isArray)
340
+ modelValue.value = [
341
+ ...(modelValue.value
342
+ ? Array.isArray(modelValue.value)
343
+ ? modelValue.value
344
+ : [modelValue.value]
345
+ : []),
346
+ ...value,
347
+ ];
348
+ else modelValue.value = value[0];
349
+
350
+ nextTick(() => {
351
+ fileList.value = getFileList();
352
+ });
353
+ }
354
+
355
+ function handleSelectAsset(asset?: Asset) {
356
+ if (!asset) return;
357
+
358
+ const value =
359
+ field.type === "url" || field.children === "url" ? asset.publicURL : asset;
360
+
361
+ if (!field.isArray) {
362
+ if (
363
+ modelValue.value &&
364
+ !Array.isArray(modelValue.value) &&
365
+ ((typeof modelValue.value === "string" &&
366
+ modelValue.value === asset.publicURL) ||
367
+ (isObject(modelValue.value) && modelValue.value.id === asset.id))
368
+ )
369
+ modelValue.value = undefined;
370
+ else modelValue.value = value;
371
+ } else {
372
+ if (modelValue.value && Array.isArray(modelValue.value)) {
373
+ const index = isArrayOfObjects(modelValue.value)
374
+ ? (modelValue.value as Asset[]).findIndex(
375
+ (value) => value.id === asset.id,
376
+ )
377
+ : (modelValue.value as string[]).indexOf(asset.publicURL);
378
+ if (index > -1) modelValue.value.splice(index, 1);
379
+ else modelValue.value.push(value);
380
+ } else modelValue.value = [value];
381
+ }
382
+
383
+ nextTick(() => {
384
+ fileList.value = getFileList();
385
+ });
386
+ }
387
+
388
+ function getFileList() {
389
+ if (!modelValue.value) return;
390
+
391
+ return ([] as (Asset | string)[]).concat(modelValue.value).map((asset) =>
392
+ typeof asset === "string"
393
+ ? {
394
+ id: asset,
395
+ name: asset.split("/").pop(),
396
+ status: "finished",
397
+ url: asset,
398
+ type: field.accept?.includes("image") ? "image/jpeg" : undefined,
399
+ }
400
+ : {
401
+ id: asset.id,
402
+ name: asset.name || asset.id,
403
+ status: "finished",
404
+ url: (asset as Asset).publicURL,
405
+ type: asset.type,
406
+ },
407
+ ) as UploadFileInfo[];
408
+ }
409
+
410
+ const fileList = ref<undefined | UploadFileInfo[]>(getFileList());
411
+ async function setModelValue(value?: UploadFileInfo[]) {
412
+ fileList.value = value;
413
+
414
+ if (value) {
415
+ if (value.length) {
416
+ if (
417
+ value.length ===
418
+ value.filter(({ status }) => status === "finished").length
419
+ ) {
420
+ const finalFileList = value
421
+ .filter(({ status }) => status === "finished")
422
+ .map((asset) =>
423
+ !asset.file
424
+ ? field.isArray
425
+ ? (modelValue.value as Asset[]).find(
426
+ (item) => item.id === asset.id,
427
+ )
428
+ : modelValue.value
429
+ : {
430
+ id: fileIdObject.value[asset.id],
431
+ name: asset.name,
432
+ type: asset.type,
433
+ publicURL: asset.url,
434
+ size: asset.file?.size ?? 0,
435
+ createdAt: asset.file?.lastModified ?? 0,
436
+ },
437
+ ) as Asset[];
438
+ if (finalFileList.length) {
439
+ modelValue.value = field.isArray ? finalFileList : finalFileList[0];
440
+ await nextTick();
441
+ fileList.value = getFileList();
442
+ } else modelValue.value = undefined;
443
+ }
444
+ } else modelValue.value = undefined;
445
+ } else modelValue.value = undefined;
446
+ }
447
+
448
+ const fileIdObject = ref<Record<string, string>>({});
449
+
450
+ const { optimizeFile } = useOptimizeFile();
451
+ const { uploadAssetWithProgress } = useAssetUploader();
452
+
453
+ // Determine if optimization is enabled (undefined or true = enabled)
454
+ const shouldOptimize = computed(() => field.optimize !== false);
455
+ const showSkipCompressionTooltip = ref(false);
456
+
457
+ async function customRequest({
458
+ file,
459
+ headers,
460
+ action,
461
+ onFinish,
462
+ onError,
463
+ onProgress,
464
+ }: UploadCustomRequestOptions) {
465
+ // Track this file as being processed
466
+ processingFileId.value = file.id;
467
+
468
+ try {
469
+ if (shouldOptimize.value) {
470
+ const originalFile = file.file;
471
+ if (!originalFile) throw new Error("Missing file payload");
472
+
473
+ let fileToUpload: File = originalFile;
474
+ let compressionSkipped = false;
475
+ const isVideo = originalFile.type?.startsWith("video/") ?? false;
476
+ const isPdf =
477
+ originalFile.type === "application/pdf" ||
478
+ originalFile.name.toLowerCase().endsWith(".pdf");
479
+
480
+ if (isVideo || isPdf) {
481
+ showSkipCompressionTooltip.value = true;
482
+ setTimeout(() => {
483
+ showSkipCompressionTooltip.value = false;
484
+ }, 800);
485
+ }
486
+
487
+ if (isVideo) {
488
+ notifyVideoSize(originalFile.size);
489
+ try {
490
+ fileToUpload = await compressVideo(
491
+ new File([originalFile], originalFile.name, {
492
+ type: originalFile.type,
493
+ }),
494
+ );
495
+ } catch (videoError) {
496
+ // If compression was aborted/terminated, use original file
497
+ if (String(videoError).includes("terminated")) {
498
+ fileToUpload = originalFile;
499
+ compressionSkipped = true;
500
+ compressionIndicator.value = null;
501
+ } else throw videoError;
502
+ }
503
+ } else if (isPdf && false) {
504
+ notifyPdfSize(originalFile.size);
505
+ try {
506
+ fileToUpload = await compressPdf(fileToUpload);
507
+ } catch (pdfError) {
508
+ // If compression was aborted, use original file
509
+ if (String(pdfError).toLowerCase().includes("abort")) {
510
+ fileToUpload = originalFile;
511
+ compressionSkipped = true;
512
+ compressionIndicator.value = null;
513
+ } else throw pdfError;
514
+ }
515
+ } else if (originalFile.type?.startsWith("image/")) {
516
+ const optimizationResult = await optimizeFile(
517
+ new File([originalFile], originalFile.name, {
518
+ type: originalFile.type,
519
+ }),
520
+ );
521
+ if (optimizationResult.optimized)
522
+ fileToUpload = optimizationResult.file;
523
+ }
524
+
525
+ // IMPORTANT: Completely replace the file object to prevent uploading both files
526
+ // Create a new File instance to ensure no reference to original
527
+ if (fileToUpload !== originalFile) {
528
+ fileToUpload = new File([fileToUpload], fileToUpload.name, {
529
+ type: fileToUpload.type,
530
+ lastModified: fileToUpload.lastModified,
531
+ });
532
+ }
533
+ // Update the file reference in the upload info
534
+ file.file = fileToUpload;
535
+ file.name = fileToUpload.name;
536
+ file.type = fileToUpload.type;
537
+ }
538
+
539
+ const { name, extension } = getFileNameAndExtension(file.name);
540
+
541
+ onProgress?.({ percent: 10 });
542
+
543
+ const { result } = await $fetch<apiResponse<Asset>>(
544
+ `${action}?${database.value.slug}_sid=${sessionID.value}`,
545
+ {
546
+ method: "POST",
547
+ credentials: "include",
548
+ headers: headers as Record<string, string>,
549
+ body: { name, size: file.file?.size, type: file.type, extension },
550
+ },
551
+ );
552
+
553
+ if (!result.uploadURL) {
554
+ if (result.id)
555
+ await $fetch<apiResponse<Asset>>(
556
+ `${action}/${result.id}?${database.value.slug}_sid=${sessionID.value}`,
557
+ {
558
+ method: "DELETE",
559
+ credentials: "include",
560
+ headers: headers as Record<string, string>,
561
+ },
562
+ );
563
+ throw new Error("Failed to get upload URL");
564
+ }
565
+
566
+ await uploadAssetWithProgress({
567
+ url: result.uploadURL as string,
568
+ method: result.uploadURL.includes("s3") ? "PUT" : "POST",
569
+ headers: { "Content-Type": file.type as string },
570
+ file: file.file,
571
+ onProgress,
572
+ });
573
+ compressionIndicator.value = null;
574
+
575
+ file.url = result.publicURL;
576
+ file.status = "finished";
577
+ fileIdObject.value[file.id] = result.id as string;
578
+ // fileList.value = [...(fileList.value || []), file] // Do not manually append to fileList, NUpload handles it
579
+ if (!database.value.size) database.value.size = 0;
580
+ database.value.size += result.size ?? 0;
581
+
582
+ setModelValue(fileList.value);
583
+
584
+ onFinish();
585
+ } catch (error) {
586
+ // Handle terminated/aborted compression
587
+ if (
588
+ String(error).includes("terminated") ||
589
+ String(error).includes("abort")
590
+ ) {
591
+ // Current file was being compressed when skip was clicked
592
+ // DON'T modify fileList as this might trigger re-uploads of finished files
593
+ // Just mark as error and finish silently
594
+ file.status = "error";
595
+ compressionIndicator.value = null;
596
+ console.log("Compression skipped for:", file.name);
597
+ onFinish(); // Finish without calling onError to prevent error message
598
+ return;
599
+ }
600
+ compressionIndicator.value = null;
601
+ console.error("Error in customRequest:", error);
602
+ onError();
603
+ } finally {
604
+ // Clean up
605
+ if (processingFileId.value === file.id) {
606
+ processingFileId.value = null;
607
+ }
608
+ // Reset skip flag only after current file is done
609
+ if (skipCompressionRequested.value) {
610
+ skipCompressionRequested.value = false;
611
+ }
612
+ }
613
+ }
614
+
615
+ const table = useState<Table>("table");
616
+ const currentItem = useState<Item>("currentItem");
617
+
618
+ function renderIcon(file: UploadFileInfo) {
619
+ const asset = Array.isArray(modelValue.value)
620
+ ? (modelValue.value as Asset[]).find((item) => item.id === file.id)
621
+ : (modelValue.value as Asset);
622
+ if (!asset) return;
623
+ return h(LazyAssetThumb, {
624
+ asset,
625
+ });
626
+ }
627
+
628
+ const getChecked = (asset: Asset) =>
629
+ !!modelValue.value &&
630
+ ([] as (Asset | string)[])
631
+ .concat(modelValue.value)
632
+ .findIndex((value) =>
633
+ typeof value === "string"
634
+ ? value === asset.publicURL
635
+ : value.id === asset.id,
636
+ ) > -1;
637
+
638
+ const sessionID = useScopedCookie<string>("sid", database.value?.slug);
639
+ </script>
@@ -0,0 +1,26 @@
1
+ <template>
2
+ <FieldWrapper :field :rule v-model="modelValue">
3
+ <NSwitch v-model:value="modelValue" v-bind="field.inputProps
4
+ ? typeof field.inputProps === 'function'
5
+ ? field.inputProps(modelValue) ?? {}
6
+ : field.inputProps
7
+ : {}" />
8
+ </FieldWrapper>
9
+ </template>
10
+
11
+ <script lang="ts" setup>
12
+ import type { FormItemRule } from "naive-ui"
13
+
14
+ const { field } = defineProps<{ field: Field }>()
15
+
16
+ const modelValue = defineModel<boolean>()
17
+ if (modelValue.value === null || modelValue.value === undefined) modelValue.value = false;
18
+
19
+ const rule: FormItemRule = {
20
+ required: field.required,
21
+ validator: async () => {
22
+ await nextTick()
23
+ return fieldValidator(field, modelValue.value)
24
+ },
25
+ }
26
+ </script>