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,526 @@
1
+ <template>
2
+ <NDrawer :show="show" @update:show="emit('update:show', $event)" :width="drawerWidth"
3
+ @update:width="drawerWidth = $event" resizable :placement="Language === 'ar' ? 'left' : 'right'">
4
+ <NDrawerContent :title="t('translateItem')" closable :native-scrollbar="false">
5
+ <template #footer>
6
+ <NFlex justify="end" style="width:100%">
7
+ <NButton round secondary type="primary" :loading="saving" :disabled="!hasChanges || saving"
8
+ @click="saveTranslations">
9
+ <template #icon>
10
+ <NIcon>
11
+ <Icon name="tabler:device-floppy" />
12
+ </NIcon>
13
+ </template>
14
+ {{ t("save") }}
15
+ </NButton>
16
+ </NFlex>
17
+ </template>
18
+
19
+ <NSpin :show="loading">
20
+ <NEmpty v-if="!secondaryLanguages.length" :description="t('noSecondaryLanguages')"
21
+ style="padding: 32px 0" />
22
+ <NEmpty v-else-if="!translatableFields.length" :description="t('noTranslatableFields')"
23
+ style="padding: 32px 0" />
24
+ <NTabs v-else v-model:value="activeLocale" type="line" animated style="margin-top: -8px">
25
+ <NTabPane v-for="locale in secondaryLanguages" :key="locale" :name="locale"
26
+ :tab="t(`languages.${locale}`)">
27
+ <NFlex vertical :size="20" style="padding: 8px 0">
28
+ <div v-for="field in translatableFields" :key="field.key">
29
+ <NFlex align="center" :size="4" style="margin-bottom: 4px">
30
+ <NText strong>{{ t(field.key) }}</NText>
31
+ <NTag v-if="getTranslationEntry(locale, field.id)?.existingId" size="tiny"
32
+ type="success" round :bordered="false">
33
+ {{ t("translated") }}
34
+ </NTag>
35
+ </NFlex>
36
+
37
+ <!-- Original value (editable via pencil icon) -->
38
+ <NCard size="small" :bordered="false"
39
+ style="background: var(--n-color-modal); margin-bottom: 6px; border-radius: 8px">
40
+ <template #header>
41
+ <NFlex align="center" :size="6" style="width: 100%">
42
+ <NText depth="3" style="font-size: 12px">
43
+ {{ t("original") }}:
44
+ </NText>
45
+ <NButton quaternary circle size="tiny" type="warning"
46
+ @click="toggleEditOriginal(field)">
47
+ <template #icon>
48
+ <NIcon :size="14">
49
+ <Icon name="tabler:pencil" />
50
+ </NIcon>
51
+ </template>
52
+ </NButton>
53
+ </NFlex>
54
+ </template>
55
+ <LazyField v-if="editingOriginal[field.key]"
56
+ :field="{ ...field, inputProps: { onBlur: () => saveOriginal(field), 'on:keydown.enter.prevent': () => saveOriginal(field), 'on:keydown.esc': () => toggleEditOriginal(field) } }"
57
+ v-model="originalDraft[field.key]" />
58
+ <LazyColumn v-else-if="!isEmptyTranslationValue(getItemValue(field.key))"
59
+ :field="field" :value="getItemValue(field.key)" :item-label="itemLabel" />
60
+ <NText v-else depth="3">—</NText>
61
+ </NCard>
62
+
63
+ <!-- Translation input (rendered by field type) -->
64
+ <LazyField :field="{...field,labelProps: { showLabel: false }}" v-model="draft[locale][field.id]" />
65
+ </div>
66
+ </NFlex>
67
+ </NTabPane>
68
+ </NTabs>
69
+ </NSpin>
70
+ </NDrawerContent>
71
+ </NDrawer>
72
+ </template>
73
+
74
+ <script setup lang="ts">
75
+ import { flattenSchema } from "inibase/utils";
76
+ import Inison from "inison";
77
+ import {
78
+ isArrayLikeField,
79
+ isEmptyTranslationValue,
80
+ isTranslatableField,
81
+ normalizeTranslationValue,
82
+ parseTranslationValue,
83
+ } from "~/composables/translationValue";
84
+
85
+ const props = defineProps<{
86
+ show: boolean;
87
+ item: Item | null;
88
+ }>();
89
+
90
+ const emit = defineEmits<(e: "update:show", v: boolean) => void>();
91
+
92
+ const database = useState<Database>("database");
93
+ const config = useRuntimeConfig();
94
+ const Language = useScopedCookie<LanguagesType>("language", database.value?.slug);
95
+ const sessionID = useScopedCookie<string>("sid", database.value?.slug);
96
+ const table = useState<Table>("table");
97
+
98
+ const drawerWidth = useCookie<number | string>("translateDrawerWidth", {
99
+ sameSite: true,
100
+ default: () => 560,
101
+ });
102
+
103
+ // ── Derived ───────────────────────────────────────────────────────────────────
104
+
105
+ const secondaryLanguages = computed(
106
+ () => database.value?.secondaryLanguages ?? [],
107
+ );
108
+
109
+ const activeLocale = ref<LanguagesType>(secondaryLanguages.value[0]);
110
+
111
+ const translatableFields = computed(() => {
112
+ if (!table.value?.schema) return [];
113
+ return flattenSchema(table.value.schema, true).filter(
114
+ (field) => !field.key.includes(".") && isTranslatableField(field),
115
+ );
116
+ });
117
+
118
+ function getItemValue(fieldKey: string): unknown {
119
+ const isMainLanguage = Language.value === database.value?.primaryLanguage;
120
+ const source =
121
+ !isMainLanguage && mainLanguageItem.value
122
+ ? mainLanguageItem.value
123
+ : props.item;
124
+ if (!source) return undefined;
125
+ return (source as any)[fieldKey];
126
+ }
127
+
128
+ const itemLabel = computed(() =>
129
+ props.item ? renderLabel(table.value, props.item) : "",
130
+ );
131
+
132
+ // ── State ─────────────────────────────────────────────────────────────────────
133
+
134
+ // draft[locale][fieldId] = current typed input value (string, item, array, …)
135
+ const draft = ref<Record<string, Record<string | number, unknown>>>({});
136
+
137
+ // baseline[locale][fieldId] = the loaded value before the user edited it, used
138
+ // to detect changes (covers in-place array mutations from repeater/tag fields).
139
+ const baseline = ref<Record<string, Record<string | number, unknown>>>({});
140
+
141
+ // existingMap[locale][fieldId] = existing translation record id
142
+ const existingMap = ref<Record<string, Record<string, string>>>({});
143
+
144
+ // changedKeys[locale][fieldId] = true when the user edited the value
145
+ const changedKeys = ref<Record<string, Record<string, boolean>>>({});
146
+
147
+ const loading = ref(false);
148
+ const saving = ref(false);
149
+ const originalSaving = ref(false);
150
+
151
+ // editingOriginal[fieldKey] = true while editing the original value
152
+ const editingOriginal = ref<Record<string, boolean>>({});
153
+
154
+ // originalDraft[fieldKey] = in-progress edit of original value
155
+ const originalDraft = ref<Record<string, unknown>>({});
156
+
157
+ // original item data in the primary language, fetched only when the current
158
+ // language is not the primary language
159
+ const mainLanguageItem = ref<Item | null>(null);
160
+
161
+ const hasChanges = computed(() =>
162
+ Object.values(changedKeys.value).some((localeMap) =>
163
+ Object.values(localeMap).some(Boolean),
164
+ ),
165
+ );
166
+
167
+ // ── Change tracking ───────────────────────────────────────────────────────────
168
+
169
+ function snapshotValues(
170
+ source: Record<string, Record<string | number, unknown>>,
171
+ ): Record<string, Record<string | number, unknown>> {
172
+ return JSON.parse(JSON.stringify(source)) as typeof draft.value;
173
+ }
174
+
175
+ function updateChangedFlags() {
176
+ for (const lang of secondaryLanguages.value) {
177
+ if (!changedKeys.value[lang]) changedKeys.value[lang] = {};
178
+ for (const field of translatableFields.value) {
179
+ changedKeys.value[lang][field.id] =
180
+ JSON.stringify(draft.value[lang]?.[field.id]) !==
181
+ JSON.stringify(baseline.value[lang]?.[field.id]);
182
+ }
183
+ }
184
+ }
185
+
186
+ watch(draft, updateChangedFlags, { deep: true });
187
+
188
+ // ── Helpers ───────────────────────────────────────────────────────────────────
189
+
190
+ function getTranslationEntry(locale: string, fieldId: string | number) {
191
+ return {
192
+ value: draft.value[locale]?.[fieldId] ?? "",
193
+ existingId: existingMap.value[locale]?.[String(fieldId)],
194
+ };
195
+ }
196
+
197
+ function toggleEditOriginal(field: Field) {
198
+ const fieldKey = field.key;
199
+ if (!editingOriginal.value[fieldKey]) {
200
+ originalDraft.value[fieldKey] = deepClone(getItemValue(fieldKey));
201
+ editingOriginal.value[fieldKey] = true;
202
+ } else {
203
+ editingOriginal.value[fieldKey] = false;
204
+ }
205
+ }
206
+
207
+ function initDraft() {
208
+ const langs = secondaryLanguages.value;
209
+ const fields = translatableFields.value;
210
+ const newDraft: Record<string, Record<string | number, unknown>> = {};
211
+ const newChanged: Record<string, Record<string, boolean>> = {};
212
+ for (const lang of langs) {
213
+ newDraft[lang] = {};
214
+ newChanged[lang] = {};
215
+ for (const field of fields) {
216
+ newDraft[lang][field.id] = isArrayLikeField(field) ? [] : "";
217
+ }
218
+ }
219
+ draft.value = newDraft;
220
+ baseline.value = snapshotValues(newDraft);
221
+ changedKeys.value = newChanged;
222
+ existingMap.value = {};
223
+ editingOriginal.value = {};
224
+ originalDraft.value = {};
225
+ }
226
+
227
+ // ── Fetch translations for the current item across ALL locales ──────────────
228
+ async function fetchItemTranslations() {
229
+ initDraft();
230
+ if (!props.item?.id) return;
231
+ try {
232
+ const result = (
233
+ await $fetch<apiResponse<Item[]>>(
234
+ `${config.public.apiBase}${database.value.slug}/translations`,
235
+ {
236
+ params: {
237
+ where: Inison.stringify({
238
+ table: table.value.id,
239
+ item: props.item.id,
240
+ }),
241
+ options: Inison.stringify({ perPage: 500 }),
242
+ locale: Language.value,
243
+ [`${database.value.slug}_sid`]: sessionID.value,
244
+ },
245
+ credentials: "include",
246
+ },
247
+ )
248
+ ).result;
249
+
250
+ const fieldById = new Map(
251
+ translatableFields.value.map((field) => [String(field.id), field]),
252
+ );
253
+
254
+ if (result)
255
+ for (const record of result) {
256
+ const fieldId = String(record.field);
257
+ if (!record.locale || !fieldId) continue;
258
+ const field = fieldById.get(fieldId);
259
+ if (!field) continue;
260
+ if (!existingMap.value[record.locale])
261
+ existingMap.value[record.locale] = {};
262
+ existingMap.value[record.locale][fieldId] = String(record.id);
263
+ if (!draft.value[record.locale]) draft.value[record.locale] = {};
264
+ draft.value[record.locale][fieldId] = parseTranslationValue(
265
+ field,
266
+ record.translation ?? "",
267
+ );
268
+ }
269
+
270
+ // Snapshot the loaded state so change detection has a baseline.
271
+ baseline.value = snapshotValues(draft.value);
272
+ updateChangedFlags();
273
+ } catch (e) {
274
+ console.error("[TranslateDrawer] fetch item translations error", e);
275
+ }
276
+ }
277
+
278
+ // ── Fetch the original item data in the primary language ─────────────────────
279
+ // When the current language is not the primary one, the row passed in
280
+ // (`props.item`) holds translated values, so the true "original" has to be
281
+ // fetched separately in the primary language.
282
+ async function fetchMainLanguageItem() {
283
+ const primary = database.value?.primaryLanguage;
284
+ mainLanguageItem.value = null;
285
+ if (!props.item?.id || !primary || Language.value === primary) return;
286
+
287
+ try {
288
+ const res = await $fetch<apiResponse<Item>>(
289
+ `${config.public.apiBase}${database.value.slug}/${table.value.slug}/${props.item.id}`,
290
+ {
291
+ params: {
292
+ locale: primary,
293
+ [`${database.value.slug}_sid`]: sessionID.value,
294
+ },
295
+ credentials: "include",
296
+ },
297
+ );
298
+ mainLanguageItem.value = res?.result ?? null;
299
+ } catch (e) {
300
+ console.error("[TranslateDrawer] fetch main language item error", e);
301
+ }
302
+ }
303
+
304
+ // ── Save ──────────────────────────────────────────────────────────────────────
305
+
306
+ async function saveTranslations() {
307
+ if (!props.item?.id) return;
308
+ saving.value = true;
309
+
310
+ const baseUrl = `${config.public.apiBase}${database.value.slug}/translations`;
311
+ const sidLang = {
312
+ locale: Language.value,
313
+ [`${database.value.slug}_sid`]: sessionID.value,
314
+ };
315
+ const itemId = String(props.item.id);
316
+
317
+ // Group changes so we send as few requests as possible: every new
318
+ // translation in one POST, every modified one in one PUT, every deleted
319
+ // one in one DELETE.
320
+ const toCreate = [];
321
+ const createdIndex: { locale: string; fieldId: string }[] = [];
322
+ const toUpdate = [];
323
+ const toDelete = [];
324
+
325
+ for (const locale of secondaryLanguages.value) {
326
+ for (const field of translatableFields.value) {
327
+ const fieldId = String(field.id);
328
+ if (!changedKeys.value[locale]?.[fieldId]) continue;
329
+
330
+ const rawValue = draft.value[locale]?.[field.id];
331
+ const translationValue = normalizeTranslationValue(field, rawValue);
332
+ const empty = isEmptyTranslationValue(rawValue);
333
+ const existingId = existingMap.value[locale]?.[fieldId];
334
+
335
+ // Clearing an existing translation removes its record entirely.
336
+ if (existingId && empty) {
337
+ toDelete.push(existingId);
338
+ } else if (existingId) {
339
+ toUpdate.push({ id: existingId, translation: translationValue });
340
+ } else if (!empty) {
341
+ toCreate.push({
342
+ translation: translationValue,
343
+ locale,
344
+ table: table.value.slug,
345
+ field: field.id,
346
+ item: itemId,
347
+ });
348
+ createdIndex.push({ locale, fieldId });
349
+ }
350
+ }
351
+ }
352
+
353
+ const operations: Promise<any>[] = [];
354
+
355
+ if (toCreate.length) {
356
+ const createOps = $fetch(baseUrl, {
357
+ method: "POST",
358
+ body: toCreate.length === 1 ? toCreate[0] : toCreate,
359
+ params: sidLang,
360
+ credentials: "include",
361
+ });
362
+ operations.push(
363
+ createOps.then((res: any) => {
364
+ const result = res?.result;
365
+ const ids = Array.isArray(result)
366
+ ? result.map((r: any) => (r && typeof r === "object" ? r.id : r))
367
+ : result?.id != null
368
+ ? [result.id]
369
+ : [];
370
+ // Store the created record ids for future saves
371
+ for (let i = 0; i < ids.length && i < createdIndex.length; i++) {
372
+ const { locale, fieldId } = createdIndex[i] as {
373
+ locale: string;
374
+ fieldId: string;
375
+ };
376
+ if (!existingMap.value[locale]) existingMap.value[locale] = {};
377
+ existingMap.value[locale][fieldId] = String(ids[i]);
378
+ }
379
+ }),
380
+ );
381
+ }
382
+
383
+ if (toDelete.length) {
384
+ if (toDelete.length === 1) {
385
+ operations.push(
386
+ $fetch(`${baseUrl}/${toDelete[0]}`, {
387
+ method: "DELETE",
388
+ params: sidLang,
389
+ credentials: "include",
390
+ }),
391
+ );
392
+ } else {
393
+ operations.push(
394
+ $fetch(baseUrl, {
395
+ method: "DELETE",
396
+ body: toDelete,
397
+ params: sidLang,
398
+ credentials: "include",
399
+ }),
400
+ );
401
+ }
402
+ }
403
+
404
+ if (toUpdate.length) {
405
+ if (toUpdate.length === 1) {
406
+ operations.push(
407
+ $fetch(`${baseUrl}/${toUpdate[0].id}`, {
408
+ method: "PUT",
409
+ body: { translation: toUpdate[0].translation },
410
+ params: sidLang,
411
+ credentials: "include",
412
+ }),
413
+ );
414
+ } else {
415
+ operations.push(
416
+ $fetch(baseUrl, {
417
+ method: "PUT",
418
+ body: toUpdate,
419
+ params: sidLang,
420
+ credentials: "include",
421
+ }),
422
+ );
423
+ }
424
+ }
425
+
426
+ try {
427
+ await Promise.allSettled(operations);
428
+ window.$message.success(t("translationsSaved"));
429
+ changedKeys.value = {};
430
+ baseline.value = snapshotValues(draft.value);
431
+ } catch (e: any) {
432
+ window.$message.error(e?.message ?? t("error"));
433
+ } finally {
434
+ saving.value = false;
435
+ }
436
+ }
437
+
438
+ // ── Save original value (PUT) ────────────────────────────────────────────────
439
+
440
+ async function saveOriginal(field: Field) {
441
+ const fieldKey = field.key;
442
+ const newOriginal = originalDraft.value[fieldKey];
443
+ const oldOriginal = getItemValue(fieldKey);
444
+ editingOriginal.value[fieldKey] = false;
445
+
446
+ if (
447
+ JSON.stringify(newOriginal ?? null) ===
448
+ JSON.stringify(oldOriginal ?? null) ||
449
+ !props.item?.id
450
+ )
451
+ return;
452
+
453
+ const bodyValue: unknown =
454
+ newOriginal === undefined || newOriginal === null ? "" : newOriginal;
455
+
456
+ // Update the local item so subsequent display reflects the change; when the
457
+ // current language is not the primary one, update the fetched
458
+ // main-language item instead
459
+ if (mainLanguageItem.value)
460
+ (mainLanguageItem.value as any)[fieldKey] = bodyValue;
461
+ else if (props.item) (props.item as any)[fieldKey] = bodyValue;
462
+
463
+ // Update the source item's field value to stay in sync
464
+ originalSaving.value = true;
465
+ const ops: Promise<any>[] = [
466
+ $fetch(
467
+ `${config.public.apiBase}${database.value.slug}/${table.value.slug}/${props.item.id}`,
468
+ {
469
+ method: "PUT",
470
+ body: { [fieldKey]: bodyValue },
471
+ params: {
472
+ locale: database.value?.primaryLanguage,
473
+ [`${database.value.slug}_sid`]: sessionID.value,
474
+ },
475
+ credentials: "include",
476
+ },
477
+ ),
478
+ ];
479
+
480
+ try {
481
+ await Promise.all(ops);
482
+ window.$message.success(t("savedSuccessfully"));
483
+ } catch (e: any) {
484
+ window.$message.error(e?.message ?? t("error"));
485
+ } finally {
486
+ originalSaving.value = false;
487
+ }
488
+ }
489
+
490
+ // ── Ctrl+S / ⌘+S to save ──────────────────────────────────────────────────────
491
+
492
+ function onKeydown(event: KeyboardEvent) {
493
+ // `code` is layout-independent so the shortcut works on any keyboard layout
494
+ if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
495
+ event.preventDefault();
496
+ if (!props.show || saving.value || !hasChanges.value) return;
497
+ saveTranslations();
498
+ }
499
+ }
500
+
501
+ onMounted(() => window.addEventListener("keydown", onKeydown));
502
+ onUnmounted(() => window.removeEventListener("keydown", onKeydown));
503
+
504
+ // ── Watch for open ────────────────────────────────────────────────────────────
505
+
506
+ watch(
507
+ () => props.show,
508
+ async (open) => {
509
+ if (!open) return;
510
+ // open on the current interface language tab, otherwise fall back to
511
+ // the first secondary language
512
+ if (Language.value && secondaryLanguages.value.includes(Language.value))
513
+ activeLocale.value = Language.value;
514
+ else if (secondaryLanguages.value[0])
515
+ activeLocale.value = secondaryLanguages.value[0];
516
+ // fetch this item's translations across all locales, plus the original
517
+ // item data when the current language is not the primary one
518
+ loading.value = true;
519
+ try {
520
+ await Promise.all([fetchItemTranslations(), fetchMainLanguageItem()]);
521
+ } finally {
522
+ loading.value = false;
523
+ }
524
+ },
525
+ );
526
+ </script>