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,890 @@
1
+ <template>
2
+ <div>
3
+ <LazyFormDrawer>
4
+ <template #default="{ onAfterCreate, onAfterUpdate }">
5
+ <slot name="form" :onAfterCreate :onAfterUpdate></slot>
6
+ </template>
7
+ </LazyFormDrawer>
8
+ <LazyTableTranslateDrawer
9
+ v-if="database?.secondaryLanguages?.length"
10
+ v-model:show="translateDrawerShow"
11
+ :item="translateDrawerItem"
12
+ />
13
+ <input ref="importFileInputRef" type="file" accept=".csv,.json,text/csv,application/json"
14
+ hidden @change="handleImportFileSelected" />
15
+ <NCard :title="t(table.slug) ?? '--'"
16
+ :class="`table_${table.slug}`" id="tableCard"
17
+ style="background-color: transparent;"
18
+ :header-style="{ paddingRight: 0, paddingLeft: 0 }" content-style="padding: 0" :bordered="false">
19
+ <template #header-extra>
20
+ <NFlex id="navbarActions" align="center" justify="center" :size="isSlotSet('navbarExtraActions') ? 'medium' : 0" style="flex-direction: row-reverse;">
21
+ <slot name="navbarActions">
22
+ <NButtonGroup id="navbarExtraButtons">
23
+ <NTooltip v-if="table.config?.realtime" :delay="1500">
24
+ <template #trigger>
25
+ <NButton round size="small" type="success" secondary :loading="isConnecting">
26
+ <template #icon>
27
+ <NIcon>
28
+ <Icon :name="isConnected ? 'tabler:wifi' : 'tabler:wifi-off'" />
29
+ </NIcon>
30
+ </template>
31
+ </NButton>
32
+ </template>
33
+ {{ isConnected ? t("realtimeEnabled") : t("connectingToRealtime") }}
34
+ </NTooltip>
35
+
36
+ <NDropdown :options="toolsDropdownOptions" @select="toolsDropdownOnSelect" trigger="click">
37
+ <NTooltip :delay="1500">
38
+ <template #trigger>
39
+ <NButton secondary round size="small">
40
+ <template #icon>
41
+ <NIcon>
42
+ <Icon name="tabler:tools" />
43
+ </NIcon>
44
+ </template>
45
+ </NButton>
46
+ </template>
47
+ {{ t("tools") }}
48
+ </NTooltip>
49
+ </NDropdown>
50
+
51
+ <NDropdown v-if="table.allowedMethods?.includes('c')" placement="bottom" trigger="hover"
52
+ size="small" :options="createDropdownOptions" @select="createDropdownOnSelect">
53
+ <NTooltip placement="top" :delay="1500">
54
+ <template #trigger>
55
+ <NButton secondary round :disabled="!table.schema" tag="a"
56
+ :href="table.schema ? `${$route.params.database ? `/${$route.params.database}` : ''}/admin/tables/${table.slug}/new` : '#'"
57
+ @click.prevent="() => {
58
+ if (!isMobile)
59
+ openDrawer(table.slug)
60
+ else
61
+ navigateTo(`${$route.params.database ? `/${$route.params.database}` : ''}/admin/tables/${table.slug}/new`);
62
+ }" size="small">
63
+ <template #icon>
64
+ <NIcon>
65
+ <Icon name="tabler:plus" />
66
+ </NIcon>
67
+ </template>
68
+ </NButton>
69
+ </template>
70
+ {{ t("newItem") }}
71
+ </NTooltip>
72
+ </NDropdown>
73
+ <TableSearchButton v-model:string="searchString" v-model:array="searchArray"
74
+ size="small" />
75
+ <slot name="navbarExtraButtons"></slot>
76
+ </NButtonGroup>
77
+ <slot name="navbarExtraActions"></slot>
78
+ </slot>
79
+ <template v-if="isSlotSet('navbarActions')">
80
+ <div id="navbarExtraButtons"></div>
81
+ <div id="navbarExtraActions"></div>
82
+ </template>
83
+ </NFlex>
84
+ </template>
85
+ <slot name="default" :data>
86
+ <LazyTableViewsKanban v-if="table.displayAs === 'kanban'" v-model:columns="columns" v-model:data="data"
87
+ v-model:searchString="searchString" :slots />
88
+ <LazyTableViewsTable v-else v-model:columns="columns" v-model:data="data"
89
+ v-model:searchString="searchString" ref="tableViewRef" :slots />
90
+ </slot>
91
+ </NCard>
92
+ <LazyTableLogs v-if="table.config?.log" />
93
+ </div>
94
+ </template>
95
+
96
+ <script setup lang="ts">
97
+ import { isStringified } from "inibase/utils";
98
+ import Inison from "inison";
99
+ import type {
100
+ DataTableColumns,
101
+ DropdownOption,
102
+ NotificationReactive,
103
+ } from "naive-ui";
104
+ import {
105
+ Icon,
106
+ NButton,
107
+ NButtonGroup,
108
+ NFlex,
109
+ NIcon,
110
+ NPopconfirm,
111
+ NProgress,
112
+ NTime,
113
+ NTooltip,
114
+ } from "#components";
115
+ import {
116
+ generateSearchArray,
117
+ generateSearchString,
118
+ } from "~/composables/search";
119
+ import { useRealtimeSync } from "~/composables/useRealtimeSync";
120
+
121
+ const user = useState<User>("user");
122
+ const route = useRoute();
123
+ const router = useRouter();
124
+ const { isMobile } = useDevice();
125
+
126
+ const searchString = ref<string>(
127
+ (route.query.search as string | undefined) ?? "",
128
+ );
129
+ const searchArray = ref<searchType>(
130
+ route.query.search
131
+ ? generateSearchArray(Inison.unstringify(route.query.search as string))
132
+ : { and: [[null, "=", null]] },
133
+ );
134
+
135
+ const columns = ref<DataTableColumns>();
136
+ const data = ref<apiResponse<Item[]>>();
137
+
138
+ const database = useState<Database>("database");
139
+ const table = useState<Table>("table");
140
+
141
+ // ── When the current language isn't the primary language, disable inline table edit feature ──
142
+ const { isConnected, isConnecting } = useRealtimeSync(table, data, database);
143
+
144
+ // ── Offline-queued creates appear in the table ──────────────────────────────
145
+ // When a create is queued (device offline), the cached table data has no trace
146
+ // of the new item. Merge the pending POSTs back in as virtual rows so the item
147
+ // is visibly "there" — at the top when the table's `prepend` config is set,
148
+ // otherwise appended at the bottom. Once the mutation syncs and the server has
149
+ // the real item, the next refresh drops the virtual row automatically.
150
+ const pendingMergeSignature = ref("");
151
+ async function mergePendingCreates() {
152
+ const db = database.value?.slug;
153
+ const slug = table.value?.slug;
154
+ if (!db || !slug || !Array.isArray(data.value?.result)) return;
155
+
156
+ const pending = await getPendingCreates(db, slug);
157
+ const signature = pending
158
+ .map((p) => (p as any).__pending?.queuedId ?? "")
159
+ .sort()
160
+ .join(",");
161
+ // Same queue state as last merge — nothing to do. This guard also stops
162
+ // this watcher from looping after the in-place re-sort below.
163
+ if (signature === pendingMergeSignature.value) return;
164
+ pendingMergeSignature.value = signature;
165
+
166
+ // Drop previously-merged virtual rows, then re-add the pending set.
167
+ const result = data.value.result.filter((row) => !(row as any).__pending);
168
+ if (pending.length) {
169
+ const prepend = !!table.value?.config?.prepend;
170
+ for (const item of pending) {
171
+ if (prepend) result.unshift(item);
172
+ else result.push(item);
173
+ }
174
+ }
175
+ data.value.result = result;
176
+ }
177
+ watch(data, () => {
178
+ mergePendingCreates();
179
+ });
180
+ onMounted(() => {
181
+ mergePendingCreates();
182
+ });
183
+
184
+ watch(searchString, (v) => {
185
+ const { search, page, ...Query }: any = route.query;
186
+
187
+ router.push({
188
+ query: {
189
+ ...(Query ?? {}),
190
+ search: v,
191
+ },
192
+ });
193
+ });
194
+
195
+ watch(
196
+ searchArray,
197
+ (v) => {
198
+ const searchInput = v ? generateSearchString(v, "display") : undefined;
199
+ const nextSearchString = searchInput
200
+ ? Inison.stringify(searchInput)
201
+ : undefined;
202
+
203
+ if (searchString.value !== nextSearchString)
204
+ searchString.value = nextSearchString;
205
+ },
206
+ { deep: true },
207
+ );
208
+
209
+ const tablesConfig = computed({
210
+ get: () => user.value?.config?.tables ?? {},
211
+ set: (v) => {
212
+ user.value.config = { ...(user.value.config ?? {}), tables: v };
213
+ $fetch(
214
+ `${config.public.apiBase}${database.value.slug}/users/${user.value?.id}`,
215
+ {
216
+ method: "PUT",
217
+ body: user.value,
218
+ params: {
219
+ return: false,
220
+ locale: Language.value,
221
+ [`${database.value.slug}_sid`]: sessionID.value,
222
+ },
223
+ credentials: "include",
224
+ },
225
+ ).catch((err) => window.$message.error(err.message));
226
+ },
227
+ });
228
+
229
+ if (tablesConfig.value[table.value.slug]?.view)
230
+ table.value.displayAs = tablesConfig.value[table.value.slug]?.view;
231
+
232
+ const config = useRuntimeConfig();
233
+ const Loading = useState<Record<string, boolean>>("Loading", () => ({}));
234
+ const Language = useScopedCookie<LanguagesType>(
235
+ "language",
236
+ database.value?.slug,
237
+ );
238
+ const sessionID = useScopedCookie<string>("sid", database.value?.slug);
239
+ const importFileInputRef = ref<HTMLInputElement>();
240
+ const importUploadProgress = ref(0);
241
+
242
+ watch(Language, () => {
243
+ searchString.value = "";
244
+ searchArray.value = { and: [[null, "=", null]] };
245
+ });
246
+
247
+ async function deleteItem(id?: string | number | (string | number)[]) {
248
+ if (!data.value) return;
249
+ Loading.value.data = true;
250
+ const deleteResponse = await useOfflineFetch()
251
+ .request(
252
+ `${config.public.apiBase}${database.value.slug}/${table.value?.slug}${!id || Array.isArray(id) ? "" : `/${id}`}`,
253
+ {
254
+ method: "DELETE",
255
+ query: {
256
+ where: id && Array.isArray(id) ? Inison.stringify(id) : undefined,
257
+ locale: Language.value,
258
+ [`${database.value.slug}_sid`]: sessionID.value,
259
+ },
260
+ credentials: "include",
261
+ offline: {
262
+ database: database.value.slug,
263
+ table: table.value?.slug ?? "",
264
+ },
265
+ },
266
+ )
267
+ .catch((err) => {
268
+ window.$message.error(err.message);
269
+ return null;
270
+ });
271
+ if (isOfflineQueuedResult(deleteResponse)) {
272
+ window.$message.warning(t("queuedOfflineToast"));
273
+ useOfflineSync().refreshCounts();
274
+ Loading.value.data = false;
275
+ return;
276
+ }
277
+ if ((deleteResponse as any)?.result)
278
+ window.$message.success((deleteResponse as any).message);
279
+ else window.$message.error((deleteResponse as any)?.message);
280
+ Loading.value.data = false;
281
+ if (isSlotSet("default") && !table.value.displayAs)
282
+ data.value = await $fetch<apiResponse<Item[]>>(
283
+ `${config.public.apiBase}${database.value.slug}/${table.value?.slug as string}`,
284
+ {
285
+ credentials: "include",
286
+ query: { [`${database.value.slug}_sid`]: sessionID.value },
287
+ },
288
+ );
289
+ else
290
+ await refreshNuxtData(
291
+ `${database.value.slug}/${table.value?.slug as string}`,
292
+ );
293
+ if (table.value.config?.log)
294
+ await refreshNuxtData(
295
+ `${database.value.slug}/${table.value?.slug as string}/logs`,
296
+ );
297
+ }
298
+ provide("deleteItem", deleteItem);
299
+
300
+ const translateDrawerShow = ref(false);
301
+ const translateDrawerItem = ref<Item | null>(null);
302
+
303
+ function openTranslateDrawer(row: Item) {
304
+ translateDrawerItem.value = row;
305
+ translateDrawerShow.value = true;
306
+ }
307
+
308
+ function renderItemButtons(row: Item) {
309
+ const viewHref = `${route.params.database ? `/${route.params.database}` : ""}/admin/tables/${table.value?.slug}/${row.id}`;
310
+
311
+ return h(NButtonGroup, { vertical: isMobile }, () =>
312
+ [
313
+ slots.itemExtraButtons ? slots.itemExtraButtons(row) : undefined,
314
+ database.value?.secondaryLanguages?.length &&
315
+ table.value?.slug !== "translations"
316
+ ? h(
317
+ NTooltip,
318
+ { delay: 1500 },
319
+ {
320
+ trigger: () =>
321
+ h(
322
+ NButton,
323
+ {
324
+ class: "translateItemButton",
325
+ secondary: true,
326
+ circle: true,
327
+ type: "warning",
328
+ onClick: () => openTranslateDrawer(row),
329
+ },
330
+ {
331
+ icon: () =>
332
+ h(NIcon, () => h(Icon, { name: "tabler:language" })),
333
+ },
334
+ ),
335
+ default: () => t("translateItem"),
336
+ },
337
+ )
338
+ : null,
339
+ table.value?.allowedMethods?.includes("r")
340
+ ? h(
341
+ NButton,
342
+ {
343
+ class: "viewItemButton",
344
+ tag: "a",
345
+ href: viewHref,
346
+ onClick: async (e: MouseEvent) => {
347
+ // Keep native anchor behaviors for modified/middle clicks.
348
+ if (
349
+ e.button !== 0 ||
350
+ e.metaKey ||
351
+ e.ctrlKey ||
352
+ e.shiftKey ||
353
+ e.altKey
354
+ )
355
+ return;
356
+
357
+ e.preventDefault();
358
+
359
+ // If a page component exists for this table's item route, bail out and let
360
+ try {
361
+ // Use project-root relative path so glob works in dev and production builds
362
+ const pages = Object.keys(
363
+ import.meta.glob("/pages/admin/tables/**/[id]/index.vue"),
364
+ );
365
+ const slug = table.value?.slug;
366
+ if (
367
+ slug &&
368
+ pages.some((p) =>
369
+ p.includes(`/tables/${slug}/[id]/index.vue`),
370
+ )
371
+ )
372
+ return navigateTo(
373
+ `${route.params.database ? `/${route.params.database}` : ""}/admin/tables/${table.value?.slug}/${row.id}`,
374
+ );
375
+ } catch (e) {
376
+ // ignore and continue with drawer behavior
377
+ }
378
+ openDrawer(table.value?.slug as string, row.id, {}, "view");
379
+ },
380
+ secondary: true,
381
+ circle: true,
382
+ type: "primary",
383
+ },
384
+ {
385
+ icon: () => h(NIcon, () => h(Icon, { name: "tabler:eye" })),
386
+ },
387
+ )
388
+ : null,
389
+ table.value?.allowedMethods?.includes("u")
390
+ ? h(
391
+ NButton,
392
+ {
393
+ class: "editItemButton",
394
+ tag: "a",
395
+ href: `${route.params.database ? `/${route.params.database}` : ""}/admin/tables/${table.value?.slug}/${row.id}/edit`,
396
+ onClick: (e) => {
397
+ e.preventDefault();
398
+ if (!isMobile)
399
+ openDrawer(table.value?.slug as string, row.id, toRaw(row));
400
+ else
401
+ navigateTo(
402
+ `${route.params.database ? `/${route.params.database}` : ""}/admin/tables/${table.value?.slug}/${row.id}/edit`,
403
+ );
404
+ },
405
+ secondary: true,
406
+ circle: true,
407
+ type: "info",
408
+ },
409
+ { icon: () => h(NIcon, () => h(Icon, { name: "tabler:pencil" })) },
410
+ )
411
+ : null,
412
+ table.value?.allowedMethods?.includes("d")
413
+ ? h(
414
+ NPopconfirm,
415
+ {
416
+ onPositiveClick: () => deleteItem(row.id),
417
+ },
418
+ {
419
+ trigger: () =>
420
+ h(
421
+ NButton,
422
+ {
423
+ class: "deleteItemButton",
424
+ strong: true,
425
+ secondary: true,
426
+ circle: true,
427
+ type: "error",
428
+ },
429
+ {
430
+ icon: () =>
431
+ h(NIcon, () => h(Icon, { name: "tabler:trash" })),
432
+ },
433
+ ),
434
+ default: () => t("theFollowingActionIsIrreversible"),
435
+ },
436
+ )
437
+ : null,
438
+ ].filter((i) => i !== null),
439
+ );
440
+ }
441
+ provide("renderItemButtons", renderItemButtons);
442
+
443
+ defineExpose<TableRef>({
444
+ search: searchArray as searchType,
445
+ columns: columns as any,
446
+ delete: deleteItem,
447
+ data: data as any,
448
+ });
449
+
450
+ const slots = defineSlots<{
451
+ default(props: { data?: apiResponse<Item[]> }): any;
452
+ form(props: {
453
+ onAfterCreate: () => Promise<void>;
454
+ onAfterUpdate: () => Promise<void>;
455
+ }): any;
456
+ navbarActions(): any;
457
+ navbarExtraButtons(): any;
458
+ navbarExtraActions(): any;
459
+ itemActions(props: Item): any;
460
+ itemExtraActions(props: Item): any;
461
+ itemExtraButtons(props: Item): any;
462
+ item(props: Item): any;
463
+ }>();
464
+
465
+ const isSlotSet = (slotName: keyof typeof slots) => !!slots[slotName];
466
+
467
+ provide("isSlotSet", isSlotSet);
468
+
469
+ if (isSlotSet("default") && !table.value.displayAs)
470
+ data.value = await $fetch<apiResponse<Item[]>>(
471
+ `${config.public.apiBase}${database.value.slug}/${table.value?.slug as string}`,
472
+ {
473
+ credentials: "include",
474
+ query: { [`${database.value.slug}_sid`]: sessionID.value },
475
+ },
476
+ );
477
+
478
+ const notificationRef = ref<NotificationReactive>();
479
+ const currentJob = computed(() => table.value?.currentJob);
480
+ let jobTimer: ReturnType<typeof setTimeout> | undefined;
481
+
482
+ function clearJobTimer() {
483
+ if (jobTimer) clearTimeout(jobTimer);
484
+ jobTimer = undefined;
485
+ }
486
+
487
+ async function refreshTableAfterImport() {
488
+ if (isSlotSet("default") && !table.value.displayAs)
489
+ data.value = await $fetch<apiResponse<Item[]>>(
490
+ `${config.public.apiBase}${database.value.slug}/${table.value.slug}`,
491
+ {
492
+ credentials: "include",
493
+ query: { [`${database.value.slug}_sid`]: sessionID.value },
494
+ },
495
+ );
496
+ else await refreshNuxtData(`${database.value.slug}/${table.value.slug}`);
497
+ }
498
+
499
+ function uploadImportFile(file: File) {
500
+ return new Promise<apiResponse<TableImportStatus>>((resolve, reject) => {
501
+ const url = new URL(
502
+ `${config.public.apiBase}inicontent/databases/${database.value.slug}/${table.value.slug}/import`,
503
+ window.location.origin,
504
+ );
505
+ if (sessionID.value)
506
+ url.searchParams.set(`${database.value.slug}_sid`, sessionID.value);
507
+ if (Language.value) url.searchParams.set("locale", Language.value);
508
+
509
+ const request = new XMLHttpRequest();
510
+ request.open("POST", url.toString());
511
+ request.withCredentials = true;
512
+ request.responseType = "json";
513
+ request.setRequestHeader(
514
+ "Content-Type",
515
+ file.type || "application/octet-stream",
516
+ );
517
+ request.setRequestHeader(
518
+ "x-import-file-name",
519
+ encodeURIComponent(file.name),
520
+ );
521
+ request.upload.onprogress = (event) => {
522
+ if (event.lengthComputable)
523
+ importUploadProgress.value = Math.round(
524
+ (event.loaded / event.total) * 100,
525
+ );
526
+ };
527
+ request.onload = () => {
528
+ const response =
529
+ request.response as apiResponse<TableImportStatus> | null;
530
+ if (request.status >= 200 && request.status < 300 && response)
531
+ resolve(response);
532
+ else reject(new Error(response?.message || t("uploadFailed")));
533
+ };
534
+ request.onerror = () => reject(new Error(t("uploadFailed")));
535
+ request.onabort = () => reject(new Error(t("uploadFailed")));
536
+ request.send(file);
537
+ });
538
+ }
539
+
540
+ async function handleImportFileSelected(event: Event) {
541
+ const input = event.target as HTMLInputElement;
542
+ const file = input.files?.[0];
543
+ if (!file) return;
544
+
545
+ const extension = file.name.split(".").pop()?.toLowerCase();
546
+ if (!extension || !["csv", "json"].includes(extension)) {
547
+ window.$message.error(t("isInvalidFormat"));
548
+ input.value = "";
549
+ return;
550
+ }
551
+
552
+ Loading.value.import = true;
553
+ importUploadProgress.value = 0;
554
+ notificationRef.value?.destroy();
555
+ notificationRef.value = window.$notification.info({
556
+ title: t("uploadingImportFile"),
557
+ duration: 0,
558
+ content: () =>
559
+ h(NProgress, {
560
+ type: "line",
561
+ percentage: importUploadProgress.value,
562
+ indicatorPlacement: "inside",
563
+ processing: importUploadProgress.value < 100,
564
+ }),
565
+ });
566
+
567
+ try {
568
+ const response = await uploadImportFile(file);
569
+ if (!response.result) throw new Error(response.message);
570
+ notificationRef.value?.destroy();
571
+ notificationRef.value = undefined;
572
+ table.value.currentJob = "import";
573
+ } catch (error) {
574
+ notificationRef.value?.destroy();
575
+ notificationRef.value = undefined;
576
+ window.$message.error(
577
+ error instanceof Error ? error.message : t("uploadFailed"),
578
+ );
579
+ } finally {
580
+ Loading.value.import = false;
581
+ input.value = "";
582
+ }
583
+ }
584
+
585
+ async function jobNotification() {
586
+ clearJobTimer();
587
+ const job = currentJob.value;
588
+ if (!job) return;
589
+
590
+ if (!notificationRef.value) {
591
+ // NOTE: don't pass `onClose` here — with naive-ui 2.45 + Vue 3.5,
592
+ // mergeProps turns it into an array ([userOnClose, handleClose]),
593
+ // so the notification's close button throws
594
+ // "TypeError: props.onClose is not a function" when clicked.
595
+ // Use onAfterLeave instead (it isn't merged the same way).
596
+ const notification = window.$notification.info({
597
+ title: t(`an_${job}_job_is_running_in_background`),
598
+ duration: 0,
599
+ onAfterLeave: () => {
600
+ clearJobTimer();
601
+ // Only clear the ref if it still points at the notification
602
+ // that actually left (e.g. don't clobber a newer notification).
603
+ if (notificationRef.value === notification)
604
+ notificationRef.value = undefined;
605
+ },
606
+ meta: () => h(NTime),
607
+ });
608
+ notificationRef.value = notification;
609
+ } else
610
+ notificationRef.value.title = t(`an_${job}_job_is_running_in_background`);
611
+
612
+ const pollJob = async () => {
613
+ if (!notificationRef.value || currentJob.value !== job) return true;
614
+
615
+ try {
616
+ const result = (
617
+ await $fetch<apiResponse<number | TableImportStatus>>(
618
+ `${config.public.apiBase}inicontent/databases/${database.value.slug}/${table.value.slug}/${job}`,
619
+ {
620
+ credentials: "include",
621
+ query: { [`${database.value.slug}_sid`]: sessionID.value },
622
+ },
623
+ )
624
+ ).result;
625
+ const importStatus =
626
+ typeof result === "object" && result ? result : undefined;
627
+ const progress =
628
+ typeof result === "number" ? result : (importStatus?.progress ?? 0);
629
+
630
+ if (job === "import" && importStatus?.state === "failed") {
631
+ clearJobTimer();
632
+ notificationRef.value?.destroy();
633
+ notificationRef.value = window.$notification.error({
634
+ title: t("an_import_job_failed"),
635
+ content: importStatus.error || t("uploadFailed"),
636
+ duration: 0,
637
+ });
638
+ table.value.currentJob = undefined;
639
+ return true;
640
+ }
641
+
642
+ if (progress >= 100 || importStatus?.state === "completed") {
643
+ clearJobTimer();
644
+ if (notificationRef.value)
645
+ notificationRef.value.title = t(`an_${job}_job_is_done`);
646
+
647
+ if (job === "import") {
648
+ await refreshTableAfterImport();
649
+ if (notificationRef.value)
650
+ notificationRef.value.content = t("importedRows", {
651
+ count: importStatus?.processedRows ?? 0,
652
+ });
653
+ table.value.currentJob = undefined;
654
+ } else if (notificationRef.value)
655
+ notificationRef.value.action = () =>
656
+ h(
657
+ NButton,
658
+ {
659
+ text: true,
660
+ type: "primary",
661
+ onClick: () => {
662
+ window.open(
663
+ `${config.public.apiBase}inicontent/databases/${database.value.slug}/${table.value.slug}/export/download?${`${database.value.slug}_sid`}=${sessionID.value}`,
664
+ );
665
+ notificationRef.value?.destroy();
666
+ notificationRef.value = undefined;
667
+ table.value.currentJob = undefined;
668
+ },
669
+ },
670
+ { default: () => t("download") },
671
+ );
672
+ return true;
673
+ }
674
+
675
+ if (notificationRef.value)
676
+ notificationRef.value.content = () =>
677
+ h(NProgress, {
678
+ type: "line",
679
+ percentage: progress,
680
+ indicatorPlacement: "inside",
681
+ processing: true,
682
+ });
683
+ } catch (error) {
684
+ console.error("Failed to poll table job", error);
685
+ }
686
+ return false;
687
+ };
688
+
689
+ const schedulePoll = () => {
690
+ jobTimer = setTimeout(async () => {
691
+ if (!(await pollJob())) schedulePoll();
692
+ }, 1000);
693
+ };
694
+ if (!(await pollJob())) schedulePoll();
695
+ }
696
+ watch(currentJob, jobNotification);
697
+ onMounted(jobNotification);
698
+ onBeforeUnmount(clearJobTimer);
699
+
700
+ const tableViewRef = ref();
701
+
702
+ const toolsDropdownOptions = computed(() => [
703
+ {
704
+ icon: () =>
705
+ h(NIcon, () => h(Icon, { name: "tabler:layout-dashboard-filled" })),
706
+ label: t("view"),
707
+ key: "view",
708
+ children: [
709
+ {
710
+ icon: () => h(NIcon, () => h(Icon, { name: "tabler:table" })),
711
+ label: t("table"),
712
+ key: "viewTable",
713
+ children: [
714
+ {
715
+ icon: () => "S",
716
+ label: t("small"),
717
+ key: "tableSizeS",
718
+ disabled:
719
+ !table.value.displayAs &&
720
+ tablesConfig.value[table.value.slug]?.size === "small",
721
+ },
722
+ {
723
+ icon: () => "M",
724
+ label: t("medium"),
725
+ key: "tableSizeM",
726
+ disabled:
727
+ !table.value.displayAs &&
728
+ !tablesConfig.value[table.value.slug]?.size,
729
+ },
730
+ {
731
+ icon: () => "L",
732
+ label: t("large"),
733
+ key: "tableSizeL",
734
+ disabled:
735
+ !table.value.displayAs &&
736
+ tablesConfig.value[table.value.slug]?.size === "large",
737
+ },
738
+ ],
739
+ },
740
+ {
741
+ icon: () =>
742
+ h(NIcon, () => h(Icon, { name: "tabler:layout-kanban-filled" })),
743
+ label: t("kanban"),
744
+ key: "viewKanban",
745
+ disabled: table.value.displayAs === "kanban",
746
+ show: !!table.value?.groupBy,
747
+ },
748
+ ],
749
+ },
750
+ {
751
+ icon: () => h(NIcon, () => h(Icon, { name: "tabler:table-import" })),
752
+ label: t("import"),
753
+ key: "import",
754
+ disabled:
755
+ !table.value?.schema || !!currentJob.value || Loading.value.import,
756
+ show: user.value?.role === config.public.idOne,
757
+ },
758
+ {
759
+ icon: () => h(NIcon, () => h(Icon, { name: "tabler:table-export" })),
760
+ label: t("export"),
761
+ key: "export",
762
+ disabled: !table.value?.schema,
763
+ show: user.value?.role === config.public.idOne,
764
+ children: [
765
+ {
766
+ icon: () =>
767
+ h(NIcon, () => h(Icon, { name: "tabler:file-arrow-right" })),
768
+ label: t("exportCurrentData"),
769
+ key: "exportCurrentData",
770
+ },
771
+ {
772
+ icon: () => h(NIcon, () => h(Icon, { name: "tabler:table-export" })),
773
+ disabled: !!currentJob.value,
774
+ label: t("exportAllData"),
775
+ key: "exportAllData",
776
+ },
777
+ ],
778
+ },
779
+ ]);
780
+
781
+ async function toolsDropdownOnSelect(
782
+ value:
783
+ | "import"
784
+ | "exportCurrentData"
785
+ | "exportAllData"
786
+ | "viewTable"
787
+ | "viewKanban"
788
+ | "tableSizeS"
789
+ | "tableSizeM"
790
+ | "tableSizeL",
791
+ ) {
792
+ switch (value) {
793
+ case "import": {
794
+ importFileInputRef.value?.click();
795
+ break;
796
+ }
797
+ case "exportAllData": {
798
+ await $fetch(
799
+ `${config.public.apiBase}inicontent/databases/${database.value.slug}/${table.value?.slug}/export`,
800
+ {
801
+ method: "POST",
802
+ credentials: "include",
803
+ query: { [`${database.value.slug}_sid`]: sessionID.value },
804
+ },
805
+ );
806
+ table.value.currentJob = "export";
807
+ break;
808
+ }
809
+ case "exportCurrentData": {
810
+ tableViewRef.value.dataTableRef?.downloadCsv({
811
+ fileName: `${table.value.slug}-${new Date().toISOString().split("T")[0]}`,
812
+ keepOriginalData: false,
813
+ });
814
+ break;
815
+ }
816
+ case "tableSizeS":
817
+ case "tableSizeM":
818
+ case "tableSizeL":
819
+ case "viewTable":
820
+ case "viewKanban": {
821
+ const clonedTablesConfig = structuredClone(toRaw(tablesConfig.value));
822
+ const displayAs = value === "viewKanban" ? "kanban" : undefined;
823
+ if (!clonedTablesConfig[table.value.slug])
824
+ clonedTablesConfig[table.value.slug] = {};
825
+ // @ts-expect-error
826
+ clonedTablesConfig[table.value.slug].view = displayAs;
827
+
828
+ if (value.startsWith("tableSize"))
829
+ // @ts-expect-error
830
+ clonedTablesConfig[table.value.slug].size = value.endsWith("S")
831
+ ? "small"
832
+ : value.endsWith("L")
833
+ ? "large"
834
+ : undefined;
835
+ else data.value = undefined;
836
+ table.value.displayAs = displayAs;
837
+ tablesConfig.value = clonedTablesConfig;
838
+ break;
839
+ }
840
+ }
841
+ }
842
+
843
+ const createDropdownOptions: DropdownOption[] = [
844
+ {
845
+ label: () => t("createFromClipboard"),
846
+ key: "paste",
847
+ icon: () => h(NIcon, () => h(Icon, { name: "tabler:copy" })),
848
+ },
849
+ ];
850
+ async function createDropdownOnSelect(value: string) {
851
+ try {
852
+ const itemFromClipboard = await navigator.clipboard.readText();
853
+
854
+ if (!itemFromClipboard) {
855
+ window.$message.error(t("clipboardEmpty"));
856
+ return;
857
+ }
858
+ if (!isStringified(itemFromClipboard)) {
859
+ window.$message.error(t("clipboardItemIsNotCorrect"));
860
+ return;
861
+ }
862
+
863
+ const unstringifiedItem = Inison.unstringify<Item>(itemFromClipboard);
864
+ if (!unstringifiedItem) {
865
+ window.$message.error(t("clipboardItemIsNotCorrect"));
866
+ return;
867
+ }
868
+
869
+ switch (value) {
870
+ case "paste": {
871
+ if (!isMobile)
872
+ openDrawer(table.value.slug, undefined, unstringifiedItem);
873
+ else
874
+ await navigateTo(
875
+ `${route.params.database ? `/${route.params.database}` : ""}/admin/tables/${table.value.slug}/new?data=${itemFromClipboard}`,
876
+ );
877
+ }
878
+ }
879
+ } catch {
880
+ window.$message.error(t("clipboardItemIsNotCorrect"));
881
+ }
882
+ }
883
+
884
+ useHead({
885
+ title: `${t(database.value.slug)} | ${t(table.value.slug)}`,
886
+ link: [
887
+ { rel: "icon", href: database.value?.icon?.publicURL ?? "/favicon.ico" },
888
+ ],
889
+ });
890
+ </script>