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,708 @@
1
+ <template>
2
+ <NTooltip :disabled="hideTooltip || !asset.name">
3
+ <template #trigger>
4
+ <NTooltip :disabled="hideTooltip || !asset.name || !asset.createdAt" :delay="1500">
5
+ <template #trigger>
6
+ <!-- Image -->
7
+ <NImage v-if="asset.publicURL && imageExtensions.includes(asset.extension)" class="asset"
8
+ :src="asset.publicURL" :intersectionObserverOptions lazy
9
+ :preview-disabled="previewDisabled || useSharedPreview"
10
+ :renderToolbar="(props) => renderToolbar(props, asset)" @click="handleAssetClick"
11
+ :width="size" :height="size"
12
+ :style="size ? `width: ${size}px; height: ${size}px;` : undefined"
13
+ :img-props="size ? { style: `width: ${size}px; height: ${size}px;` } : undefined" />
14
+
15
+ <!-- PDF with thumbnail -->
16
+ <NImage v-else-if="asset.extension === 'pdf' && pdfThumbs[assetKey]" class="asset"
17
+ :src="pdfThumbs[assetKey]" :intersectionObserverOptions lazy preview-disabled
18
+ @click="handleAssetClick"
19
+ :width="size" :height="size"
20
+ :style="size ? `width: ${size}px; height: ${size}px;` : undefined"
21
+ :img-props="size ? { style: `width: ${size}px; height: ${size}px;` } : undefined" />
22
+
23
+ <!-- PDF generating thumbnail -->
24
+ <NSpin v-else-if="asset.extension === 'pdf'" :show="generatingPdf[assetKey]" size="small">
25
+ <NIcon class="asset" v-intersect="() => !pdfThumbs[assetKey] && generatePdfThumb()"
26
+ @click="handleAssetClick" :width="size" :height="size">
27
+ <LazyAssetIcon :type="asset.type" class="icon" />
28
+ </NIcon>
29
+ </NSpin>
30
+
31
+ <!-- Video with thumbnail -->
32
+ <NImage v-else-if="videoExtensions.includes(asset.extension) && videoThumbs[assetKey]" class="asset"
33
+ :src="videoThumbs[assetKey]" :intersectionObserverOptions lazy preview-disabled
34
+ @click="handleAssetClick"
35
+ :width="size" :height="size"
36
+ :style="size ? `width: ${size}px; height: ${size}px;` : undefined"
37
+ :img-props="size ? { style: `width: ${size}px; height: ${size}px;` } : undefined" />
38
+
39
+ <!-- Video generating thumbnail -->
40
+ <NSpin v-else-if="videoExtensions.includes(asset.extension)"
41
+ :show="videoExtensions.includes(asset.extension) && generatingVideo[assetKey]" size="small">
42
+ <NIcon class="asset"
43
+ v-intersect="() => videoExtensions.includes(asset.extension) && !videoThumbs[assetKey] && generateVideoThumb()"
44
+ @click="handleAssetClick" :size>
45
+ <LazyAssetIcon :type="asset.type" class="icon" />
46
+ </NIcon>
47
+ </NSpin>
48
+
49
+ <!-- Document with thumbnail (docx, xlsx, pptx, etc.) -->
50
+ <NImage v-else-if="officeExtensions.includes(asset.extension) && docThumbs[assetKey]" class="asset"
51
+ :src="docThumbs[assetKey]" :intersectionObserverOptions lazy preview-disabled
52
+ @click="handleAssetClick"
53
+ :width="size" :height="size"
54
+ :style="size ? `width: ${size}px; height: ${size}px;` : undefined"
55
+ :img-props="size ? { style: `width: ${size}px; height: ${size}px;` } : undefined" />
56
+
57
+ <!-- Document generating thumbnail -->
58
+ <NSpin v-else-if="officeExtensions.includes(asset.extension)"
59
+ :show="generatingDoc[assetKey]" size="small">
60
+ <NIcon class="asset"
61
+ v-intersect="() => officeExtensions.includes(asset.extension) && !docThumbs[assetKey] && generateDocThumb()"
62
+ @click="handleAssetClick" :size>
63
+ <LazyAssetIcon :type="asset.type" class="icon" />
64
+ </NIcon>
65
+ </NSpin>
66
+
67
+ <!-- Other file types as icon -->
68
+ <NIcon v-else class="asset" @click="handleAssetClick" :size="size">
69
+ <LazyAssetIcon :type="asset.type" class="icon" />
70
+ </NIcon>
71
+ </template>
72
+ <div :style="{ direction: Language === 'ar' ? 'rtl' : 'ltr' }">
73
+ <div>
74
+ <strong>{{ t('name') }} :</strong>
75
+ <span>{{ asset.name }}{{ asset.extension ? `.${asset.extension}` :
76
+ ''
77
+ }}</span>
78
+ </div>
79
+ <div>
80
+ <strong>{{ t("size") }}: </strong>
81
+ <span>{{ humanFileSize(asset.size) }}</span>
82
+ </div>
83
+ <div>
84
+ <strong>{{ t("type") }}: </strong>
85
+ <span>{{ asset.type }}</span>
86
+ </div>
87
+ <div>
88
+ <strong>{{ t('createdAt') }} :</strong>
89
+ <span>{{ asset.createdAt ? formatDate(asset.createdAt) : '' }}</span>
90
+ </div>
91
+ </div>
92
+ </NTooltip>
93
+ </template>
94
+ {{ asset.name }}{{ asset.extension ? `.${asset.extension}` : '' }}
95
+ </NTooltip>
96
+ </template>
97
+
98
+ <script lang="ts" setup>
99
+ import type { ImageRenderToolbarProps } from "naive-ui";
100
+ import type { Directive, VNodeChild } from "vue";
101
+ import { Icon, NIcon, NTooltip } from "#components";
102
+ import { imageExtensions, videoExtensions, officeExtensions } from "~/composables";
103
+ import {
104
+ getCachedThumbnail,
105
+ cacheThumbnail,
106
+ } from "~/composables/useThumbnailCache";
107
+ import { generateDocumentThumbnail } from "~/composables/useDocumentThumbnail";
108
+ import { useAssetPreview } from "~/composables/useAssetPreview";
109
+
110
+ interface Props {
111
+ asset: Asset;
112
+ containerSelector?: string | null;
113
+ size?: number;
114
+ hideTooltip?: boolean;
115
+ previewDisabled?: boolean;
116
+ disableDefaultClickAction?: boolean;
117
+ previewAssets?: Asset[];
118
+ useSharedPreview?: boolean;
119
+ }
120
+
121
+ const {
122
+ asset,
123
+ containerSelector,
124
+ size,
125
+ hideTooltip,
126
+ previewDisabled = false,
127
+ disableDefaultClickAction = false,
128
+ previewAssets,
129
+ useSharedPreview = true,
130
+ } = defineProps<Props>();
131
+ const emit = defineEmits<{
132
+ (e: "click", event: MouseEvent): void;
133
+ }>();
134
+
135
+ const database = useState<Database>("database");
136
+ const Language = useScopedCookie<LanguagesType>("language", database.value?.slug);
137
+
138
+ const formatDate = (date: string | Date | number) => {
139
+ const d = new Date(date);
140
+ return d.toLocaleString(Language.value, {
141
+ year: 'numeric',
142
+ month: 'short',
143
+ day: 'numeric',
144
+ hour: '2-digit',
145
+ minute: '2-digit'
146
+ });
147
+ };
148
+
149
+ const intersectionObserverOptions = containerSelector
150
+ ? {
151
+ root: containerSelector,
152
+ }
153
+ : undefined;
154
+ // Video and PDF thumbnail generation state
155
+ const videoThumbs = reactive<Record<string, string | undefined>>({});
156
+ const generatingVideo = reactive<Record<string, boolean>>({});
157
+ const pdfThumbs = reactive<Record<string, string | undefined>>({});
158
+ const generatingPdf = reactive<Record<string, boolean>>({});
159
+
160
+ // Document (Office) thumbnail generation state
161
+ const docThumbs = reactive<Record<string, string | undefined>>({});
162
+ const generatingDoc = reactive<Record<string, boolean>>({});
163
+
164
+ const assetKey = computed(() =>
165
+ String(asset.id ?? asset.publicURL ?? asset.name),
166
+ );
167
+
168
+ function assetKeyValue(asset: Asset): string {
169
+ return String(asset.id ?? asset.publicURL ?? asset.name);
170
+ }
171
+
172
+ async function generateVideoThumb(timeSec = 0.5) {
173
+ if (!videoExtensions.includes(asset.extension)) return;
174
+ const key = assetKeyValue(asset);
175
+ if (videoThumbs[key] !== undefined) return videoThumbs[key];
176
+
177
+ try {
178
+ generatingVideo[key] = true;
179
+
180
+ // Try to get from cache first
181
+ const cached = await getCachedThumbnail(`video-${key}`);
182
+ if (cached) {
183
+ videoThumbs[key] = cached;
184
+ return cached;
185
+ }
186
+
187
+ const dataUrl = await new Promise<string | undefined>((resolve) => {
188
+ const video = document.createElement("video");
189
+ // Requires proper CORS headers on the asset URL
190
+ video.crossOrigin = "anonymous";
191
+ video.preload = "metadata";
192
+ video.muted = true;
193
+ // iOS specific: required for inline playback
194
+ video.playsInline = true;
195
+ // iOS specific: disable picture-in-picture
196
+ video.setAttribute("playsinline", "");
197
+ video.setAttribute("webkit-playsinline", "");
198
+
199
+ const cleanup = () => {
200
+ video.removeEventListener("loadedmetadata", onLoadedMetadata);
201
+ video.removeEventListener("loadeddata", onLoadedData);
202
+ video.removeEventListener("seeked", onSeeked);
203
+ video.removeEventListener("error", onError);
204
+ video.removeEventListener("canplay", onCanPlay);
205
+ video.pause();
206
+ video.src = "";
207
+ video.load();
208
+ };
209
+
210
+ const onError = (error: Event) => {
211
+ console.warn("Video thumbnail generation error:", error);
212
+ cleanup();
213
+ resolve(undefined);
214
+ };
215
+
216
+ let metadataLoaded = false;
217
+ let dataLoaded = false;
218
+
219
+ const onLoadedMetadata = () => {
220
+ metadataLoaded = true;
221
+ trySeek();
222
+ };
223
+
224
+ const onLoadedData = () => {
225
+ dataLoaded = true;
226
+ trySeek();
227
+ };
228
+
229
+ const onCanPlay = () => {
230
+ if (!metadataLoaded || !dataLoaded) {
231
+ trySeek();
232
+ }
233
+ };
234
+
235
+ const trySeek = () => {
236
+ // Wait for both metadata and data to be loaded (or canplay event)
237
+ if (!video.duration || video.duration === Infinity) {
238
+ // Duration not available yet, wait
239
+ return;
240
+ }
241
+
242
+ try {
243
+ const target = Number.isFinite(video.duration)
244
+ ? Math.min(timeSec, Math.max(0, video.duration - 0.1))
245
+ : 0;
246
+
247
+ // iOS: Try to play briefly to trigger frame loading
248
+ const playPromise = video.play();
249
+ if (playPromise !== undefined) {
250
+ playPromise
251
+ .then(() => {
252
+ // Pause immediately after play succeeds
253
+ video.pause();
254
+ // Now seek to target time
255
+ if (target > 0 && video.currentTime !== target) {
256
+ video.currentTime = target;
257
+ } else {
258
+ // Already at position 0 or target, capture now
259
+ captureFrame();
260
+ }
261
+ })
262
+ .catch(() => {
263
+ // Play failed (might be restricted), try direct seek
264
+ if (target > 0) {
265
+ video.currentTime = target;
266
+ } else {
267
+ captureFrame();
268
+ }
269
+ });
270
+ } else {
271
+ // Old browsers without promise
272
+ video.pause();
273
+ if (target > 0) {
274
+ video.currentTime = target;
275
+ } else {
276
+ captureFrame();
277
+ }
278
+ }
279
+ } catch (error) {
280
+ console.warn("Error during video seek:", error);
281
+ // Try to capture current frame anyway
282
+ captureFrame();
283
+ }
284
+ };
285
+
286
+ const captureFrame = () => {
287
+ try {
288
+ const canvas = document.createElement("canvas");
289
+ canvas.width = video.videoWidth || 320;
290
+ canvas.height = video.videoHeight || 180;
291
+
292
+ // Handle case where dimensions are still not available
293
+ if (canvas.width === 0 || canvas.height === 0) {
294
+ canvas.width = 320;
295
+ canvas.height = 180;
296
+ }
297
+
298
+ const ctx = canvas.getContext("2d");
299
+ if (!ctx) {
300
+ cleanup();
301
+ resolve(undefined);
302
+ return;
303
+ }
304
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
305
+ const url = canvas.toDataURL("image/jpeg", 0.7);
306
+ cleanup();
307
+ resolve(url);
308
+ } catch (error) {
309
+ console.warn("Error capturing video frame:", error);
310
+ cleanup();
311
+ resolve(undefined);
312
+ }
313
+ };
314
+
315
+ const onSeeked = () => {
316
+ captureFrame();
317
+ };
318
+
319
+ video.addEventListener("loadedmetadata", onLoadedMetadata, {
320
+ once: false,
321
+ });
322
+ video.addEventListener("loadeddata", onLoadedData, { once: false });
323
+ video.addEventListener("canplay", onCanPlay, { once: false });
324
+ video.addEventListener("seeked", onSeeked, { once: true });
325
+ video.addEventListener("error", onError, { once: true });
326
+
327
+ // iOS: Must set src before load()
328
+ video.src = asset.publicURL;
329
+ // iOS: Explicitly call load() to start loading metadata
330
+ video.load();
331
+
332
+ // Fallback timeout in case events don't fire
333
+ setTimeout(() => {
334
+ if (videoThumbs[key] === undefined) {
335
+ console.warn("Video thumbnail generation timeout");
336
+ cleanup();
337
+ resolve(undefined);
338
+ }
339
+ }, 15000); // 15 second timeout
340
+ });
341
+
342
+ videoThumbs[key] = dataUrl;
343
+
344
+ // Cache the generated thumbnail
345
+ if (dataUrl) await cacheThumbnail(`video-${key}`, dataUrl);
346
+ } catch (error) {
347
+ console.warn("Error in generateVideoThumb:", error);
348
+ videoThumbs[key] = undefined;
349
+ } finally {
350
+ generatingVideo[key] = false;
351
+ }
352
+ return videoThumbs[key];
353
+ }
354
+
355
+ async function generatePdfThumb() {
356
+ if (asset.extension !== "pdf") return;
357
+ const key = assetKeyValue(asset);
358
+ if (pdfThumbs[key] !== undefined) return pdfThumbs[key];
359
+
360
+ try {
361
+ generatingPdf[key] = true;
362
+
363
+ // Try to get from cache first
364
+ const cached = await getCachedThumbnail(`pdf-${key}`);
365
+ if (cached) {
366
+ pdfThumbs[key] = cached;
367
+ return cached;
368
+ }
369
+
370
+ // Check file size before attempting to load
371
+ // Limit to 50MB to prevent memory issues with large PDFs
372
+ const maxFileSizeBytes = 50 * 1024 * 1024;
373
+ if (asset.size && asset.size > maxFileSizeBytes) {
374
+ console.warn(
375
+ `PDF file too large (${Math.round(asset.size / 1024 / 1024)}MB). Skipping thumbnail generation.`,
376
+ );
377
+ pdfThumbs[key] = undefined;
378
+ return;
379
+ }
380
+
381
+ // Dynamically import pdfjs-dist to avoid bundling issues
382
+ const pdfjsLib = await import("pdfjs-dist");
383
+ // Set worker source from CDN to avoid loading local worker file
384
+ pdfjsLib.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.mjs`;
385
+
386
+ // Create a timeout promise to abort if loading takes too long
387
+ const timeoutPromise: Promise<never> = new Promise((_resolve, reject) => {
388
+ setTimeout(() => reject(new Error("PDF loading timeout")), 30000); // 30 second timeout
389
+ });
390
+
391
+ const pdf = await Promise.race([
392
+ pdfjsLib.getDocument({
393
+ url: asset.publicURL as string,
394
+ // Needed so non-embedded/base-14 fonts get correct glyph widths instead of garbled spacing.
395
+ cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjsLib.version}/cmaps/`,
396
+ cMapPacked: true,
397
+ standardFontDataUrl: `https://unpkg.com/pdfjs-dist@${pdfjsLib.version}/standard_fonts/`,
398
+ // Fall back to the system's Arabic/CJK fonts when they aren't embedded in the PDF.
399
+ useSystemFonts: true,
400
+ // Draw embedded glyph outlines directly instead of via @font-face+fillText, which breaks Arabic letter joining for some font subsets.
401
+ disableFontFace: true,
402
+ }).promise as Promise<unknown>,
403
+ timeoutPromise,
404
+ ]);
405
+
406
+ if (!pdf || !(pdf as any).numPages || (pdf as any).numPages === 0) {
407
+ pdfThumbs[key] = undefined;
408
+ return;
409
+ }
410
+
411
+ const page = await Promise.race([
412
+ (pdf as any).getPage(1) as Promise<unknown>,
413
+ timeoutPromise,
414
+ ]);
415
+
416
+ // Create a smaller square thumbnail (200x200px)
417
+ const thumbnailSize = 200;
418
+ // Render at device pixel ratio so text, images and icons stay sharp instead of blurry.
419
+ const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
420
+ const viewport = (page as any).getViewport({ scale: 1 });
421
+
422
+ // Calculate scale to cover the square (like CSS cover) - use MAX instead of MIN
423
+ const scale = Math.max(
424
+ thumbnailSize / viewport.width,
425
+ thumbnailSize / viewport.height,
426
+ );
427
+ const scaledViewport = (page as any).getViewport({ scale: scale * pixelRatio });
428
+
429
+ const canvas = document.createElement("canvas");
430
+ // Make the canvas square at full resolution, scaled back down via CSS
431
+ canvas.width = thumbnailSize * pixelRatio;
432
+ canvas.height = thumbnailSize * pixelRatio;
433
+ canvas.style.width = `${thumbnailSize}px`;
434
+ canvas.style.height = `${thumbnailSize}px`;
435
+
436
+ const ctx = canvas.getContext("2d");
437
+ if (!ctx) {
438
+ pdfThumbs[key] = undefined;
439
+ return;
440
+ }
441
+
442
+ // Center the scaled PDF content in the square canvas (crop excess)
443
+ const offsetX = (canvas.width - scaledViewport.width) / 2;
444
+ const offsetY = (canvas.height - scaledViewport.height) / 2;
445
+ ctx.translate(offsetX, offsetY);
446
+
447
+ await Promise.race([
448
+ (page as any).render({
449
+ canvasContext: ctx,
450
+ viewport: scaledViewport,
451
+ canvas: canvas,
452
+ }).promise as Promise<unknown>,
453
+ timeoutPromise,
454
+ ]);
455
+
456
+ const dataUrl = canvas.toDataURL("image/jpeg", 0.85);
457
+ pdfThumbs[key] = dataUrl;
458
+
459
+ // Cache the generated thumbnail
460
+ if (dataUrl) await cacheThumbnail(`pdf-${key}`, dataUrl);
461
+ } catch (error) {
462
+ console.warn(
463
+ `Failed to generate PDF thumbnail for ${asset.publicURL}:`,
464
+ error,
465
+ );
466
+ pdfThumbs[key] = undefined;
467
+ } finally {
468
+ generatingPdf[key] = false;
469
+ }
470
+ return pdfThumbs[key];
471
+ }
472
+
473
+ async function generateDocThumb() {
474
+ if (!officeExtensions.includes(asset.extension)) return;
475
+ const key = assetKeyValue(asset);
476
+ if (docThumbs[key] !== undefined) return docThumbs[key];
477
+
478
+ try {
479
+ generatingDoc[key] = true;
480
+
481
+ // Try to get from cache first
482
+ const cached = await getCachedThumbnail(`doc-${key}`);
483
+ if (cached) {
484
+ docThumbs[key] = cached;
485
+ return cached;
486
+ }
487
+
488
+ const dataUrl = await generateDocumentThumbnail(asset);
489
+ docThumbs[key] = dataUrl;
490
+ return dataUrl;
491
+ } catch (error) {
492
+ console.warn("Error in generateDocThumb:", error);
493
+ docThumbs[key] = undefined;
494
+ } finally {
495
+ generatingDoc[key] = false;
496
+ }
497
+ return docThumbs[key];
498
+ }
499
+
500
+ // Intersection observer directive for lazy actions
501
+ const vIntersect: Directive<HTMLElement, () => void> = {
502
+ mounted(el, binding) {
503
+ const callback = binding.value;
504
+ if (typeof callback !== "function") return;
505
+ const observer = new IntersectionObserver((entries) => {
506
+ for (const entry of entries) {
507
+ if (entry.isIntersecting) {
508
+ callback();
509
+ observer.disconnect();
510
+ break;
511
+ }
512
+ }
513
+ });
514
+ observer.observe(el);
515
+ // store on element for potential cleanup
516
+ (el as any).__io__ = observer;
517
+ },
518
+ unmounted(el) {
519
+ const observer: IntersectionObserver | undefined = (el as any).__io__;
520
+ if (observer) observer.disconnect();
521
+ delete (el as any).__io__;
522
+ },
523
+ };
524
+
525
+ const renderToolbar: (
526
+ props: ImageRenderToolbarProps,
527
+ file?: Asset,
528
+ ) => VNodeChild = (
529
+ {
530
+ nodes: {
531
+ rotateCounterclockwise,
532
+ rotateClockwise,
533
+ resizeToOriginalSize,
534
+ zoomOut,
535
+ zoomIn,
536
+ download,
537
+ close,
538
+ },
539
+ },
540
+ file?: Asset,
541
+ ) => {
542
+ if (download.props && file?.publicURL)
543
+ download.props.onClick = (event: MouseEvent) => {
544
+ event?.preventDefault();
545
+ window.open(file.publicURL as string, "_blank");
546
+ close?.props?.onClick?.();
547
+ };
548
+ return [
549
+ file?.name
550
+ ? h(
551
+ NTooltip,
552
+ {},
553
+ {
554
+ default: () => file.name,
555
+ trigger: () =>
556
+ h(
557
+ "i",
558
+ { class: "n-base-icon" },
559
+ h(Icon, { name: "tabler:info-circle-filled" }),
560
+ ),
561
+ },
562
+ )
563
+ : null,
564
+ rotateCounterclockwise,
565
+ rotateClockwise,
566
+ zoomIn,
567
+ zoomOut,
568
+ resizeToOriginalSize,
569
+ download,
570
+ close,
571
+ ];
572
+ };
573
+
574
+ const { currentPreviewAsset, openPreview } = useAssetPreview();
575
+ const requiresPreviewDownloadShortcut = computed(
576
+ () => asset.extension === "pdf" || videoExtensions.includes(asset.extension) || officeExtensions.includes(asset.extension),
577
+ );
578
+ const isPreviewingCurrentAsset = computed(() => {
579
+ const previewAsset = currentPreviewAsset.value;
580
+ if (!previewAsset) return false;
581
+ return assetKeyValue(previewAsset) === assetKey.value;
582
+ });
583
+ const shouldListenForDownloadShortcut = computed(
584
+ () => requiresPreviewDownloadShortcut.value && isPreviewingCurrentAsset.value,
585
+ );
586
+ let removePreviewShortcut: (() => void) | undefined;
587
+
588
+ function handleAssetClick(event: MouseEvent) {
589
+ if (event.defaultPrevented) return false;
590
+
591
+ if (disableDefaultClickAction) {
592
+ emit("click", event);
593
+ return false;
594
+ }
595
+
596
+ if (asset.type === "dir") {
597
+ emit("click", event);
598
+ return false;
599
+ }
600
+
601
+ if (event.ctrlKey || event.metaKey) {
602
+ event.preventDefault();
603
+ event.stopPropagation();
604
+ return window.open(asset.publicURL as string, "_blank");
605
+ }
606
+
607
+ if (imageExtensions.includes(asset.extension)) {
608
+ openPreview(asset, { assets: previewAssets });
609
+ return;
610
+ }
611
+
612
+ if (useSharedPreview || asset.extension === "pdf") {
613
+ openPreview(asset, { assets: previewAssets });
614
+ return;
615
+ }
616
+
617
+ if (videoExtensions.includes(asset.extension)) {
618
+ openPreview(asset, { assets: previewAssets });
619
+ return;
620
+ }
621
+
622
+ if (officeExtensions.includes(asset.extension)) {
623
+ openPreview(asset, { assets: previewAssets });
624
+ return;
625
+ }
626
+
627
+ emit("click", event);
628
+
629
+ return false;
630
+ }
631
+
632
+ async function triggerAssetDownload(file: Asset = asset) {
633
+ if (!file.publicURL || typeof window === "undefined") return;
634
+ const link = document.createElement("a");
635
+ const defaultName = (() => {
636
+ if (file.name) return file.name;
637
+ try {
638
+ const url = new URL(file.publicURL);
639
+ const last = url.pathname.split("/").pop();
640
+ return last || "asset";
641
+ } catch {
642
+ return "asset";
643
+ }
644
+ })();
645
+ link.download = defaultName;
646
+ document.body.appendChild(link);
647
+
648
+ const downloadByOpening = () => {
649
+ const newTab = window.open(file.publicURL as string, "_blank", "noopener");
650
+ if (newTab) {
651
+ // Give the browser a moment to trigger the download before closing the tab
652
+ window.setTimeout(() => {
653
+ try {
654
+ newTab.close();
655
+ } catch {
656
+ /* Ignore cross-origin close errors */
657
+ }
658
+ }, 2000);
659
+ }
660
+ };
661
+
662
+ try {
663
+ link.href = file.publicURL as string;
664
+ link.rel = "noopener";
665
+ link.click();
666
+ } catch (error) {
667
+ console.warn("Falling back to opening asset in new tab", error);
668
+ downloadByOpening();
669
+ } finally {
670
+ document.body.removeChild(link);
671
+ }
672
+ }
673
+
674
+ function registerPreviewShortcut() {
675
+ if (!import.meta.client || removePreviewShortcut) return;
676
+ const handler = (event: KeyboardEvent) => {
677
+ if (!(event.metaKey || event.ctrlKey)) return;
678
+ if (event.key?.toLowerCase() !== "s") return;
679
+ event.preventDefault();
680
+ void triggerAssetDownload(currentPreviewAsset.value ?? asset);
681
+ };
682
+ window.addEventListener("keydown", handler);
683
+ removePreviewShortcut = () => window.removeEventListener("keydown", handler);
684
+ }
685
+
686
+ function cleanupPreviewShortcut() {
687
+ if (!removePreviewShortcut) return;
688
+ removePreviewShortcut();
689
+ removePreviewShortcut = undefined;
690
+ }
691
+
692
+ watch(
693
+ shouldListenForDownloadShortcut,
694
+ (shouldListen) => {
695
+ if (!import.meta.client) return;
696
+ if (shouldListen) {
697
+ registerPreviewShortcut();
698
+ } else {
699
+ cleanupPreviewShortcut();
700
+ }
701
+ },
702
+ { immediate: true },
703
+ );
704
+
705
+ onBeforeUnmount(() => {
706
+ cleanupPreviewShortcut();
707
+ });
708
+ </script>