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,621 @@
1
+ <template>
2
+ <NForm :model="modelValue" ref="formValidationRef">
3
+ <slot :data="modelValue" :schema>
4
+ <FieldS v-model="modelValue" v-model:schema="schema" />
5
+ </slot>
6
+ </NForm>
7
+ </template>
8
+
9
+ <script lang="ts" setup>
10
+ import { flattenSchema, isArrayOfObjects } from "inibase/utils";
11
+ import type { FormInst } from "naive-ui";
12
+ import { debounce } from "~/composables";
13
+ import { normalizeTranslationValue } from "~/composables/translationValue";
14
+
15
+ const props = defineProps<{
16
+ // biome-ignore lint/correctness/noVueDuplicateKeys: false positive with translation POST body's `table` key
17
+ table?: string;
18
+ onBeforeCreate?: (data?: Item) => any;
19
+ onAfterCreate?: (data?: Item) => any;
20
+ onBeforeUpdate?: (data?: Item) => any;
21
+ onAfterUpdate?: (data?: Item) => any;
22
+ onBeforeDelete?: (data?: Item) => any;
23
+ onAfterDelete?: (data?: Item) => any;
24
+ }>();
25
+
26
+ const schema = defineModel<Schema>("schema", { default: () => reactive([]) });
27
+ defineSlots<{
28
+ default(data?: Item, schema?: Schema): any;
29
+ }>();
30
+
31
+ const modelValue = defineModel<Item>({ default: () => reactive({}) });
32
+
33
+ defineExpose<FormRef>({
34
+ create: CREATE,
35
+ update: UPDATE,
36
+ delete: DELETE,
37
+ schema: schema as unknown as Schema,
38
+ data: modelValue.value,
39
+ });
40
+
41
+ const config = useRuntimeConfig();
42
+ const Loading = useState<Record<string, boolean>>("Loading", () => ({}));
43
+ const route = useRoute();
44
+ const database = useState<Database>("database");
45
+ const table = useState<Table>("table");
46
+
47
+ function countItems(items: Schema): number {
48
+ return items.reduce((count, item) => {
49
+ return (
50
+ count +
51
+ 1 +
52
+ (item.children && isArrayOfObjects(item.children)
53
+ ? countItems(item.children)
54
+ : 0)
55
+ );
56
+ }, 0);
57
+ }
58
+
59
+ function mergeItems(existing: Schema, updated: Schema): Schema {
60
+ const mergedSchema: Schema = [];
61
+ const customItemsIndex = existing
62
+ .map((item, index) =>
63
+ item.id === undefined ||
64
+ (typeof item.id === "string" && (item.id as string).startsWith("temp-"))
65
+ ? index
66
+ : -1,
67
+ )
68
+ .filter((index) => index !== -1);
69
+
70
+ for (let index = 0; index < updated.length; index++) {
71
+ const item = updated[index];
72
+ if (!item) continue;
73
+
74
+ const existingItem = existing.find(
75
+ (_item, index) =>
76
+ !customItemsIndex.includes(index) && _item.id === item.id,
77
+ );
78
+
79
+ // Fields that only exist in the updated schema (e.g. fields the server
80
+ // adds after a role change) have no counterpart to merge with — keep
81
+ // them as-is instead of dropping them from the rendered form.
82
+ if (!existingItem) {
83
+ mergedSchema.push(item);
84
+ continue;
85
+ }
86
+
87
+ if (
88
+ item.children &&
89
+ isArrayOfObjects(item.children) &&
90
+ existingItem?.children &&
91
+ isArrayOfObjects(existingItem.children)
92
+ )
93
+ item.children = mergeItems(
94
+ existingItem.children as Schema,
95
+ item.children as Schema,
96
+ );
97
+
98
+ const prioritizedFields = {
99
+ options: existingItem.options ?? item.options,
100
+ type: existingItem.type ?? item.type,
101
+ render: existingItem.render ?? item.render,
102
+ extraButtons: existingItem.extraButtons ?? item.extraButtons,
103
+ extraActions: existingItem.extraActions ?? item.extraActions,
104
+ itemExtraButtons: existingItem.itemExtraButtons ?? item.itemExtraButtons,
105
+ itemExtraActions: existingItem.itemExtraActions ?? item.itemExtraActions,
106
+ onDelete: existingItem.onDelete ?? item.onDelete,
107
+ onCreate: existingItem.onCreate ?? item.onCreate,
108
+ };
109
+
110
+ mergedSchema.push({
111
+ ...existingItem,
112
+ ...item,
113
+ ...prioritizedFields,
114
+ });
115
+ }
116
+
117
+ for (let index = 0; index < customItemsIndex.length; index++) {
118
+ const elementIndex = customItemsIndex[index];
119
+
120
+ if (elementIndex === undefined || !existing[elementIndex]) continue;
121
+
122
+ if (elementIndex === 0) mergedSchema.unshift(existing[elementIndex]);
123
+ else if (
124
+ existing[elementIndex - 1] &&
125
+ !updated.some((item) => item.key === existing[elementIndex]?.key)
126
+ ) {
127
+ const prevItemIndex = mergedSchema.findIndex(
128
+ (item) => item.id === existing[elementIndex - 1]?.id,
129
+ );
130
+ if (prevItemIndex !== -1)
131
+ mergedSchema.splice(prevItemIndex + 1, 0, existing[elementIndex]);
132
+ }
133
+ }
134
+
135
+ return mergedSchema;
136
+ }
137
+
138
+ const Language = useScopedCookie<LanguagesType>(
139
+ "language",
140
+ database.value?.slug,
141
+ );
142
+ const PostSchemaResp = useState<
143
+ Record<string, apiResponse<{ schema: Schema; data: Item }>>
144
+ >("PostSchema", () => ({}));
145
+ const filterDefaultColumns = (field: Field) =>
146
+ !["id", "createdAt", "createdBy", "updatedAt", "updatedBy"].includes(
147
+ field.key,
148
+ );
149
+
150
+ const sessionID = useScopedCookie<string>("sid", database.value?.slug);
151
+
152
+ function hasFunctionsProperties(items: Schema): boolean {
153
+ return items.some((item) => {
154
+ if (
155
+ item.render !== undefined ||
156
+ item.itemExtraActions !== undefined ||
157
+ item.itemExtraButtons !== undefined ||
158
+ item.extraActions !== undefined ||
159
+ item.extraButtons !== undefined ||
160
+ item.onDelete !== undefined ||
161
+ item.onCreate !== undefined
162
+ )
163
+ return true;
164
+
165
+ if (item.children && isArrayOfObjects(item.children))
166
+ return hasFunctionsProperties(item.children as Schema);
167
+
168
+ return false;
169
+ });
170
+ }
171
+ const oldModelValue = ref();
172
+ let schemaFetchSequence = 0;
173
+ // Fetch schema and data dynamically from the correct endpoint
174
+ async function fetchSchemaAndData() {
175
+ const bodyContent = toRaw(modelValue.value);
176
+ const requestSequence = ++schemaFetchSequence;
177
+
178
+ let response: apiResponse<{ schema: Schema; data: Item }>;
179
+ let setSchema = false;
180
+
181
+ try {
182
+ const currentPOSTSchemaResp =
183
+ PostSchemaResp.value[
184
+ props.table ?? table.value?.slug ?? route.params.table
185
+ ];
186
+
187
+ if (
188
+ currentPOSTSchemaResp &&
189
+ (Object.keys(bodyContent).length === 0 ||
190
+ JSON.stringify(bodyContent) ===
191
+ JSON.stringify(currentPOSTSchemaResp?.result.data))
192
+ )
193
+ response = currentPOSTSchemaResp as apiResponse<{
194
+ schema: Schema;
195
+ data: Item;
196
+ }>;
197
+ else {
198
+ Loading.value.SCHEMA = true;
199
+
200
+ response = await $fetch<apiResponse<{ schema: Schema; data: Item }>>(
201
+ `${config.public.apiBase}${database.value.slug}/${props.table ?? table.value?.slug ?? route.params.table}/schema`,
202
+ {
203
+ method: bodyContent?.id ? "PUT" : "POST",
204
+ body: bodyContent,
205
+ params: {
206
+ locale: Language.value,
207
+ [`${database.value.slug}_sid`]: sessionID.value,
208
+ },
209
+ credentials: "include",
210
+ },
211
+ );
212
+
213
+ if (Object.keys(bodyContent).length === 0) setSchema = true;
214
+ }
215
+
216
+ // A newer edit already started another request; don't let this
217
+ // stale response overwrite the schema/data the user is now seeing.
218
+ if (requestSequence !== schemaFetchSequence) return;
219
+
220
+ const currentSchema = schema.value.filter(filterDefaultColumns);
221
+
222
+ // Update the schema
223
+ if (response.result?.schema) {
224
+ response.result.schema =
225
+ response.result.schema.filter(filterDefaultColumns);
226
+ if (!currentSchema?.length) {
227
+ if (props.table) {
228
+ const targetTableSchema = database.value.tables
229
+ ?.find(
230
+ ({ slug }) =>
231
+ slug ===
232
+ (props.table ?? table.value?.slug ?? route.params.table),
233
+ )
234
+ ?.schema?.filter(filterDefaultColumns);
235
+ if (targetTableSchema)
236
+ response.result.schema = mergeItems(
237
+ targetTableSchema,
238
+ response.result.schema,
239
+ );
240
+ } else if (table.value?.schema)
241
+ response.result.schema = mergeItems(
242
+ table.value.schema.filter(filterDefaultColumns),
243
+ response.result.schema,
244
+ );
245
+ } else if (
246
+ countItems(currentSchema) !== countItems(response.result.schema) ||
247
+ hasFunctionsProperties(currentSchema)
248
+ )
249
+ response.result.schema = mergeItems(
250
+ currentSchema,
251
+ response.result.schema,
252
+ );
253
+
254
+ if (hasFunctionsProperties(response.result.schema))
255
+ delete PostSchemaResp.value[
256
+ props.table ?? table.value?.slug ?? route.params.table
257
+ ];
258
+ else if (setSchema)
259
+ PostSchemaResp.value[
260
+ props.table ?? table.value?.slug ?? route.params.table
261
+ ] = response;
262
+
263
+ schema.value = response.result.schema;
264
+ }
265
+
266
+ // Update data only if the API sends changes
267
+ if (response.result?.data) {
268
+ if (modelValue.value)
269
+ Object.assign(modelValue.value, response.result.data);
270
+ else modelValue.value = response.result.data;
271
+ }
272
+ } catch (error) {
273
+ // The /schema endpoint needs the network and can be unreachable when
274
+ // offline (device on Wi-Fi/LAN without internet, captive portal, etc.).
275
+ // Queuing an "error fetching schema" toast for that is pure noise — it
276
+ // would also re-fire on every debounced refetch while the user types.
277
+ //
278
+ // Render the form from the table's default schema (the cached database
279
+ // metadata, the same source the success path merges in) whenever there
280
+ // are no fields yet: an empty create drawer must never be blocked by an
281
+ // unreachable /schema call, and the create button stays clickable so the
282
+ // drawer can complete and close.
283
+ const defaultSchema = props.table
284
+ ? database.value.tables?.find(
285
+ ({ slug }) =>
286
+ slug === (props.table ?? table.value?.slug ?? route.params.table),
287
+ )?.schema
288
+ : table.value?.schema;
289
+ if (!schema.value.length && defaultSchema?.length)
290
+ schema.value = defaultSchema.filter(filterDefaultColumns);
291
+
292
+ // Network failures and junk (HTML-stub) responses are silent. Only
293
+ // genuine server errors (HTTP status, valid JSON API) get a toast.
294
+ if (!isNetworkError(error) && !isJunkResponse(error)) {
295
+ console.error("Error fetching schema:", error);
296
+ window.$message.error(t("errorFetchingSchema"));
297
+ }
298
+ return;
299
+ } finally {
300
+ oldModelValue.value = JSON.parse(JSON.stringify(modelValue.value));
301
+ Loading.value.SCHEMA = false;
302
+ }
303
+ }
304
+
305
+ // Debounced version of fetchSchemaAndData
306
+ const debouncedFetchSchemaAndData = debounce(async () => {
307
+ await fetchSchemaAndData();
308
+ }, 1000);
309
+
310
+ // Debounce the change check itself so a fast typist doesn't pay the cost
311
+ // of deep-diffing the whole form object on every keystroke.
312
+ const debouncedCheckForChanges = debounce((v: Item) => {
313
+ if (JSON.stringify(oldModelValue.value) !== JSON.stringify(v))
314
+ debouncedFetchSchemaAndData();
315
+ }, 300);
316
+
317
+ // Trigger schema fetch on input changes with debounce
318
+ watch(
319
+ modelValue,
320
+ (v) => {
321
+ debouncedCheckForChanges(v);
322
+ },
323
+ { deep: true, immediate: true },
324
+ );
325
+
326
+ onMounted(async () => {
327
+ // Save on Ctrl+S or Command+S
328
+ document.onkeydown = (e) => {
329
+ if (
330
+ !(
331
+ (e.ctrlKey || e.metaKey) &&
332
+ (e.key.toLowerCase() === "s" || e.key === "س")
333
+ )
334
+ )
335
+ return;
336
+ e.preventDefault();
337
+ if (modelValue.value?.id) UPDATE();
338
+ else CREATE();
339
+ };
340
+ });
341
+
342
+ const formValidationRef = ref<FormInst>();
343
+
344
+ async function UPDATE() {
345
+ if (Loading.value.UPDATE) return;
346
+
347
+ formValidationRef.value?.validate(async (errors) => {
348
+ if (!errors) {
349
+ const bodyContent = toRaw(modelValue.value);
350
+
351
+ if (props.onBeforeUpdate)
352
+ modelValue.value = props.onBeforeUpdate(bodyContent);
353
+
354
+ Loading.value.UPDATE = true;
355
+ const data = await useOfflineFetch()
356
+ .request(
357
+ `${config.public.apiBase}${database.value.slug}/${
358
+ props.table ?? table.value?.slug ?? route.params.table
359
+ }/${bodyContent?.id}`,
360
+ {
361
+ method: "PUT",
362
+ body: bodyContent,
363
+ params: {
364
+ return: false,
365
+ locale: Language.value,
366
+ [`${database.value.slug}_sid`]: sessionID.value,
367
+ },
368
+ credentials: "include",
369
+ offline: {
370
+ database: database.value.slug,
371
+ table:
372
+ props.table ?? table.value?.slug ?? route.params.table ?? "",
373
+ },
374
+ },
375
+ )
376
+ .catch(() => {
377
+ window.$message.error(t("updateFailed"));
378
+ return null;
379
+ });
380
+ Loading.value.UPDATE = false;
381
+
382
+ if (isOfflineQueuedResult(data)) {
383
+ window.$message.warning(t("queuedOfflineToast"));
384
+ useOfflineSync().refreshCounts();
385
+ return;
386
+ }
387
+
388
+ if (
389
+ (typeof (data as any)?.result === "boolean" &&
390
+ (data as any).result === true) ||
391
+ (typeof (data as any)?.result !== "boolean" &&
392
+ (data as any)?.result?.id)
393
+ ) {
394
+ window.$message.success((data as any).message);
395
+ if (props.onAfterUpdate) return props.onAfterUpdate(bodyContent);
396
+ } else window.$message.error((data as any).message);
397
+ } else window.$message.error(t("inputsAreInvalid"));
398
+ });
399
+ }
400
+
401
+ async function DELETE() {
402
+ if (Loading.value.DELETE) return;
403
+
404
+ const bodyContent = toRaw(modelValue.value);
405
+
406
+ if (props.onBeforeDelete) props.onBeforeDelete(bodyContent);
407
+
408
+ Loading.value.DELETE = true;
409
+ const data = await useOfflineFetch()
410
+ .request(
411
+ `${config.public.apiBase}${database.value.slug}/${
412
+ props.table ?? table.value?.slug ?? route.params.table
413
+ }/${bodyContent?.id}`,
414
+ {
415
+ method: "DELETE",
416
+ params: {
417
+ locale: Language.value,
418
+ [`${database.value.slug}_sid`]: sessionID.value,
419
+ },
420
+ credentials: "include",
421
+ offline: {
422
+ database: database.value.slug,
423
+ table: props.table ?? table.value?.slug ?? route.params.table ?? "",
424
+ },
425
+ },
426
+ )
427
+ .catch(() => {
428
+ window.$message.error(t("deleteFailed"));
429
+ return null;
430
+ });
431
+ Loading.value.DELETE = false;
432
+
433
+ if (isOfflineQueuedResult(data)) {
434
+ window.$message.warning(t("queuedOfflineToast"));
435
+ useOfflineSync().refreshCounts();
436
+ return;
437
+ }
438
+
439
+ if ((data as any)?.result) {
440
+ window.$message.success((data as any).message);
441
+ await refreshNuxtData(
442
+ `${database.value.slug}/${props.table ?? table.value?.slug ?? route.params.table}`,
443
+ );
444
+
445
+ if (props.onAfterDelete) return props.onAfterDelete((data as any).result);
446
+
447
+ await navigateTo(
448
+ `${route.params.database ? `/${route.params.database}` : ""}/admin/tables/${props.table ?? table.value?.slug ?? route.params.table}`,
449
+ );
450
+ return;
451
+ }
452
+ window.$message.error((data as any)?.message);
453
+ }
454
+
455
+ // Submit form data
456
+ async function CREATE() {
457
+ if (Loading.value.CREATE) return;
458
+
459
+ formValidationRef.value?.validate(async (errors) => {
460
+ if (!errors) {
461
+ const bodyContent = toRaw(modelValue.value);
462
+
463
+ if (props.onBeforeCreate)
464
+ modelValue.value = props.onBeforeCreate(bodyContent);
465
+
466
+ Loading.value.CREATE = true;
467
+ const data = await useOfflineFetch()
468
+ .request(
469
+ `${config.public.apiBase}${database.value.slug}/${props.table ?? table.value?.slug ?? route.params.table}`,
470
+ {
471
+ method: "POST",
472
+ body: bodyContent,
473
+ params: {
474
+ locale: Language.value,
475
+ [`${database.value.slug}_sid`]: sessionID.value,
476
+ },
477
+ credentials: "include",
478
+ offline: {
479
+ database: database.value.slug,
480
+ table:
481
+ props.table ?? table.value?.slug ?? route.params.table ?? "",
482
+ },
483
+ },
484
+ )
485
+ .catch(() => {
486
+ window.$message.error(t("createFailed"));
487
+ return null;
488
+ });
489
+ Loading.value.CREATE = false;
490
+
491
+ if (isOfflineQueuedResult(data)) {
492
+ window.$message.warning(t("queuedOfflineToast"));
493
+ useOfflineSync().refreshCounts();
494
+ // Re-read the (cached) table data so the new item shows up in
495
+ // the list — Table/index.vue merges queued creates into view.
496
+ await refreshNuxtData(
497
+ `${database.value.slug}/${props.table ?? table.value?.slug ?? route.params.table}`,
498
+ );
499
+ return navigateTo(
500
+ `${route.params.database ? `/${route.params.database}` : ""}/admin/tables/${props.table ?? table.value?.slug ?? route.params.table}`,
501
+ );
502
+ }
503
+
504
+ if (!(data as any)?.result || !(data as any)?.result.id)
505
+ return window.$message.error((data as any)?.message);
506
+
507
+ // When creating an item in a secondary language, also post the
508
+ // entered values as translations for that language.
509
+ if (Language.value && Language.value !== database.value?.primaryLanguage)
510
+ await postTranslationsForNewItem(bodyContent, String(data.result.id));
511
+
512
+ window.$message.success(data.message);
513
+ await refreshNuxtData(
514
+ `${database.value.slug}/${props.table ?? table.value?.slug ?? route.params.table}`,
515
+ );
516
+
517
+ if (props.onAfterCreate) return props.onAfterCreate(data.result);
518
+
519
+ return navigateTo(
520
+ `${route.params.database ? `/${route.params.database}` : ""}/admin/tables/${props.table ?? table.value?.slug ?? route.params.table}/${data.result.id}/edit`,
521
+ );
522
+ }
523
+ window.$message.error(t("inputsAreInvalid"));
524
+ });
525
+ }
526
+
527
+ // ── Post translations when creating an item in a secondary language ──────────
528
+
529
+ const notTranslatableKeys = new Set([
530
+ "id",
531
+ "createdAt",
532
+ "createdBy",
533
+ "updatedAt",
534
+ "updatedBy",
535
+ "password",
536
+ "email",
537
+ "color",
538
+ "icon",
539
+ "link",
540
+ "role",
541
+ ]);
542
+
543
+ const extraTranslatableKeys = new Set([
544
+ "url",
545
+ "table",
546
+ "asset",
547
+ "array-table",
548
+ "array-asset",
549
+ ]);
550
+
551
+ const translatableTypes = new Set(["string", "text", "textarea", "html"]);
552
+
553
+ function isArrayField(field: Field): boolean {
554
+ const type = Array.isArray(field.type) ? field.type[0] : field.type;
555
+ return (
556
+ field.isArray === true ||
557
+ type === "array" ||
558
+ (type === "multiple" && field.subType !== "select")
559
+ );
560
+ }
561
+
562
+ function isTranslatableField(field: Field): boolean {
563
+ const type = Array.isArray(field.type) ? field.type[0] : field.type;
564
+ const subType = field.subType;
565
+ const resolved = (subType ?? type) as string;
566
+ if (notTranslatableKeys.has(resolved)) return false;
567
+ if (["ids", "id"].includes(field.key)) return false;
568
+ if (translatableTypes.has(resolved)) return true;
569
+ if (extraTranslatableKeys.has(resolved)) return true;
570
+ if (isArrayField(field)) return true;
571
+ return false;
572
+ }
573
+
574
+ function normalizeValue(field: Field, value: unknown): string {
575
+ // References are stored as their id(s) and arrays with Inison, matching the
576
+ // translation drawer so both writers produce identical records.
577
+ return normalizeTranslationValue(field, value);
578
+ }
579
+
580
+ async function postTranslationsForNewItem(
581
+ bodyContent: Item,
582
+ newItemId: string,
583
+ ) {
584
+ if (!table.value?.schema) return;
585
+ const fields = flattenSchema(table.value.schema, true).filter(
586
+ (field) => !field.key.includes(".") && isTranslatableField(field),
587
+ );
588
+ const ops: Promise<any>[] = [];
589
+
590
+ for (const field of fields) {
591
+ const raw = (bodyContent as any)[field.key];
592
+ const value = normalizeValue(field, raw);
593
+ if (!value) continue;
594
+ ops.push(
595
+ $fetch(`${config.public.apiBase}${database.value.slug}/translations`, {
596
+ method: "POST",
597
+ body: {
598
+ translation: value,
599
+ locale: Language.value,
600
+ table: table.value.id,
601
+ field: field.id,
602
+ item: newItemId,
603
+ },
604
+ params: {
605
+ locale: Language.value,
606
+ [`${database.value.slug}_sid`]: sessionID.value,
607
+ },
608
+ credentials: "include",
609
+ }),
610
+ );
611
+ }
612
+
613
+ if (ops.length) {
614
+ try {
615
+ await Promise.all(ops);
616
+ } catch (e) {
617
+ console.error("[Form] failed to post translations", e);
618
+ }
619
+ }
620
+ }
621
+ </script>