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.
- package/.github/pull_request.yml +18 -0
- package/CONTRIBUTING.md +67 -0
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/app/app.vue +106 -0
- package/app/assets/main.css +974 -0
- package/app/components/Api/EndpointCard.vue +169 -0
- package/app/components/Api/SchemaDocsTable.vue +208 -0
- package/app/components/Asset/Card.vue +1363 -0
- package/app/components/Asset/Grid.vue +295 -0
- package/app/components/Asset/Icon.vue +38 -0
- package/app/components/Asset/Preview.vue +486 -0
- package/app/components/Asset/Scanner.vue +700 -0
- package/app/components/Asset/Thumb.vue +708 -0
- package/app/components/Auth/Reset.vue +99 -0
- package/app/components/Auth/index.vue +346 -0
- package/app/components/ChatInterface.vue +2326 -0
- package/app/components/ChatPagePreview.vue +34 -0
- package/app/components/Column/Array.vue +29 -0
- package/app/components/Column/Asset.vue +21 -0
- package/app/components/Column/Boolean.vue +12 -0
- package/app/components/Column/Color.vue +9 -0
- package/app/components/Column/Date.vue +57 -0
- package/app/components/Column/Edit.vue +215 -0
- package/app/components/Column/Email.vue +9 -0
- package/app/components/Column/Html.vue +27 -0
- package/app/components/Column/Id.vue +20 -0
- package/app/components/Column/Locale.vue +32 -0
- package/app/components/Column/Object.vue +18 -0
- package/app/components/Column/Password.vue +7 -0
- package/app/components/Column/Role.vue +15 -0
- package/app/components/Column/S.vue +14 -0
- package/app/components/Column/Table/Single.vue +49 -0
- package/app/components/Column/Table/index.vue +42 -0
- package/app/components/Column/Tags.vue +31 -0
- package/app/components/Column/Text.vue +9 -0
- package/app/components/Column/Url.vue +9 -0
- package/app/components/Column/index.vue +41 -0
- package/app/components/Dashboard/Editor.vue +221 -0
- package/app/components/Dashboard/Grid.vue +270 -0
- package/app/components/Dashboard/View.vue +131 -0
- package/app/components/Dashboard/Widget/BarChart.vue +73 -0
- package/app/components/Dashboard/Widget/Counter.vue +51 -0
- package/app/components/Dashboard/Widget/LineChart.vue +76 -0
- package/app/components/Dashboard/Widget/PieChart.vue +75 -0
- package/app/components/Dashboard/Widget/Table.vue +94 -0
- package/app/components/Dashboard/WidgetEditor.vue +274 -0
- package/app/components/Data/Array.vue +102 -0
- package/app/components/Data/Html.vue +9 -0
- package/app/components/Data/Icon.vue +9 -0
- package/app/components/Data/Object.vue +10 -0
- package/app/components/Data/S.vue +16 -0
- package/app/components/Data/Table.vue +53 -0
- package/app/components/Data/Value.vue +34 -0
- package/app/components/Data/index.vue +14 -0
- package/app/components/Field/Array.vue +324 -0
- package/app/components/Field/Asset/Actions.vue +97 -0
- package/app/components/Field/Asset/index.vue +639 -0
- package/app/components/Field/Boolean.vue +26 -0
- package/app/components/Field/Checkbox.vue +48 -0
- package/app/components/Field/Color.vue +27 -0
- package/app/components/Field/Date.vue +30 -0
- package/app/components/Field/Email.vue +51 -0
- package/app/components/Field/EmailTemplate.vue +420 -0
- package/app/components/Field/Html/ColorPicker.vue +127 -0
- package/app/components/Field/Html/Editor.vue +544 -0
- package/app/components/Field/Html/index.vue +25 -0
- package/app/components/Field/Icon.vue +114 -0
- package/app/components/Field/JSON.vue +113 -0
- package/app/components/Field/Mention.vue +36 -0
- package/app/components/Field/Number.vue +27 -0
- package/app/components/Field/Object.vue +92 -0
- package/app/components/Field/Password.vue +55 -0
- package/app/components/Field/Radio.vue +47 -0
- package/app/components/Field/S.vue +44 -0
- package/app/components/Field/Select.vue +49 -0
- package/app/components/Field/Table.vue +348 -0
- package/app/components/Field/Tags.vue +113 -0
- package/app/components/Field/Text.vue +31 -0
- package/app/components/Field/Textarea.vue +33 -0
- package/app/components/Field/Url.vue +40 -0
- package/app/components/Field/Wrapper.vue +128 -0
- package/app/components/Field/index.vue +68 -0
- package/app/components/FloatingChatbot.vue +73 -0
- package/app/components/Form/Card.vue +151 -0
- package/app/components/Form/Drawer.vue +195 -0
- package/app/components/Form/index.vue +621 -0
- package/app/components/Header.vue +328 -0
- package/app/components/Offline/ConflictModal.vue +200 -0
- package/app/components/Offline/SyncStatus.vue +249 -0
- package/app/components/PlatformFeatureLanding.vue +65 -0
- package/app/components/Table/Backups.vue +638 -0
- package/app/components/Table/Flows.vue +814 -0
- package/app/components/Table/Grid.vue +296 -0
- package/app/components/Table/Icon.vue +36 -0
- package/app/components/Table/Logs.vue +181 -0
- package/app/components/Table/Schedules.vue +722 -0
- package/app/components/Table/Search/Button.vue +256 -0
- package/app/components/Table/Search/Items.vue +286 -0
- package/app/components/Table/Search/index.vue +87 -0
- package/app/components/Table/Settings/Schema.vue +801 -0
- package/app/components/Table/Settings/index.vue +631 -0
- package/app/components/Table/TranslateDrawer.vue +526 -0
- package/app/components/Table/Views/Kanban.vue +289 -0
- package/app/components/Table/Views/Table.vue +1273 -0
- package/app/components/Table/index.vue +890 -0
- package/app/composables/Translation/define.ts +41 -0
- package/app/composables/Translation/fetch.ts +107 -0
- package/app/composables/Translation/languages.ts +1 -0
- package/app/composables/Translation/loadCoreTranslations.ts +38 -0
- package/app/composables/Translation/translate.ts +250 -0
- package/app/composables/databaseCookies.ts +79 -0
- package/app/composables/fieldValidator.ts +72 -0
- package/app/composables/fieldsList.ts +301 -0
- package/app/composables/formatDatabase.ts +101 -0
- package/app/composables/index.ts +444 -0
- package/app/composables/openDrawer.ts +118 -0
- package/app/composables/optimizeFile.ts +287 -0
- package/app/composables/renderLabel.ts +88 -0
- package/app/composables/schemaClipboard.ts +95 -0
- package/app/composables/search.ts +353 -0
- package/app/composables/setThemeConfig.ts +48 -0
- package/app/composables/translationValue.ts +169 -0
- package/app/composables/useAssetPreview.ts +84 -0
- package/app/composables/useAssetUploader.ts +56 -0
- package/app/composables/useDashboardData.ts +310 -0
- package/app/composables/useDocumentThumbnail.ts +351 -0
- package/app/composables/useEcharts.ts +33 -0
- package/app/composables/useLocalDatabaseConfig.ts +51 -0
- package/app/composables/useOcr.ts +300 -0
- package/app/composables/useOfflineFetch.ts +194 -0
- package/app/composables/useOfflineItem.ts +107 -0
- package/app/composables/useOfflineQueue.ts +302 -0
- package/app/composables/useOfflineSync.ts +235 -0
- package/app/composables/usePasskeyAuth.ts +156 -0
- package/app/composables/usePdfCompressor.ts +180 -0
- package/app/composables/usePlatformRedirect.ts +72 -0
- package/app/composables/useRealtimeSync.ts +233 -0
- package/app/composables/useReferencedItems.ts +180 -0
- package/app/composables/useScanner.ts +664 -0
- package/app/composables/useSubscription.ts +650 -0
- package/app/composables/useThumbnailCache.ts +136 -0
- package/app/composables/useVideoCompressor.ts +194 -0
- package/app/error.vue +112 -0
- package/app/layouts/api.vue +188 -0
- package/app/layouts/dashboard.vue +91 -0
- package/app/layouts/default.vue +228 -0
- package/app/layouts/table.vue +471 -0
- package/app/locales/ar.ts +996 -0
- package/app/locales/en.ts +655 -0
- package/app/locales/es.ts +996 -0
- package/app/locales/fr.ts +997 -0
- package/app/middleware/dashboard.ts +33 -0
- package/app/middleware/database.ts +83 -0
- package/app/middleware/global.ts +1 -0
- package/app/middleware/sid-ingest.global.ts +29 -0
- package/app/middleware/table.ts +54 -0
- package/app/middleware/user.ts +26 -0
- package/app/pages/[[database]]/admin/api/auth/index.vue +306 -0
- package/app/pages/[[database]]/admin/api/index.vue +18 -0
- package/app/pages/[[database]]/admin/api/tables/[table]/index.vue +497 -0
- package/app/pages/[[database]]/admin/api/tables/index.vue +87 -0
- package/app/pages/[[database]]/admin/billing/index.vue +16 -0
- package/app/pages/[[database]]/admin/dashboards/[id]/edit.vue +66 -0
- package/app/pages/[[database]]/admin/dashboards/[id]/index.vue +117 -0
- package/app/pages/[[database]]/admin/dashboards/index.vue +20 -0
- package/app/pages/[[database]]/admin/index.vue +28 -0
- package/app/pages/[[database]]/admin/settings.vue +526 -0
- package/app/pages/[[database]]/admin/tables/[table]/[id]/edit.vue +143 -0
- package/app/pages/[[database]]/admin/tables/[table]/[id]/index.vue +146 -0
- package/app/pages/[[database]]/admin/tables/[table]/flows.vue +10 -0
- package/app/pages/[[database]]/admin/tables/[table]/index.vue +10 -0
- package/app/pages/[[database]]/admin/tables/[table]/new.vue +12 -0
- package/app/pages/[[database]]/admin/tables/[table]/schedules.vue +10 -0
- package/app/pages/[[database]]/admin/tables/[table]/settings.vue +30 -0
- package/app/pages/[[database]]/admin/tables/assets/[...path].vue +24 -0
- package/app/pages/[[database]]/admin/tables/assets/flows.vue +10 -0
- package/app/pages/[[database]]/admin/tables/backups/index.vue +10 -0
- package/app/pages/[[database]]/admin/tables/blocks/index.vue +16 -0
- package/app/pages/[[database]]/admin/tables/index.vue +20 -0
- package/app/pages/[[database]]/admin/tables/pages/index.vue +16 -0
- package/app/pages/[[database]]/admin/tables/templates/index.vue +16 -0
- package/app/pages/[[database]]/api/index.vue +120 -0
- package/app/pages/[[database]]/auth/index.vue +12 -0
- package/app/pages/[[database]]/auth/reset.vue +10 -0
- package/app/pages/[[database]]/index.vue +10 -0
- package/app/plugins/offline-warmup.client.ts +53 -0
- package/app/plugins/template-globals.ts +9 -0
- package/app/spa-loading-template.html +93 -0
- package/biome.json +53 -0
- package/index.d.ts +380 -0
- package/modules/naiveui.ts +41 -0
- package/nuxt.config.ts +269 -0
- package/package.json +56 -0
- package/pnpm-workspace.yaml +5 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/pwa-192x192.png +0 -0
- package/public/pwa-512x512.png +0 -0
- package/public/pwa-maskable-192x192.png +0 -0
- package/public/pwa-maskable-512x512.png +0 -0
- package/public/vendor/opencv.js +48 -0
- 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>
|