@intranefr/superbackend 1.4.3
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/.commiat +4 -0
- package/.env.example +47 -0
- package/README.md +110 -0
- package/index.js +94 -0
- package/package.json +67 -0
- package/public/css/styles.css +139 -0
- package/public/js/animations.js +41 -0
- package/sdk/error-tracking/browser/package.json +16 -0
- package/sdk/error-tracking/browser/src/core.js +270 -0
- package/sdk/error-tracking/browser/src/embed.js +18 -0
- package/sdk/error-tracking/browser/src/index.js +1 -0
- package/server.js +5 -0
- package/src/admin/endpointRegistry.js +300 -0
- package/src/controllers/admin.controller.js +321 -0
- package/src/controllers/adminAssets.controller.js +530 -0
- package/src/controllers/adminAssetsStorage.controller.js +260 -0
- package/src/controllers/adminEjsVirtual.controller.js +354 -0
- package/src/controllers/adminFeatureFlags.controller.js +155 -0
- package/src/controllers/adminHeadless.controller.js +1071 -0
- package/src/controllers/adminI18n.controller.js +604 -0
- package/src/controllers/adminJsonConfigs.controller.js +97 -0
- package/src/controllers/adminLlm.controller.js +273 -0
- package/src/controllers/adminMigration.controller.js +257 -0
- package/src/controllers/adminSeoConfig.controller.js +515 -0
- package/src/controllers/adminStats.controller.js +121 -0
- package/src/controllers/adminUploadNamespaces.controller.js +208 -0
- package/src/controllers/assets.controller.js +248 -0
- package/src/controllers/auth.controller.js +93 -0
- package/src/controllers/billing.controller.js +223 -0
- package/src/controllers/featureFlags.controller.js +35 -0
- package/src/controllers/forms.controller.js +217 -0
- package/src/controllers/globalSettings.controller.js +252 -0
- package/src/controllers/headlessCrud.controller.js +126 -0
- package/src/controllers/i18n.controller.js +12 -0
- package/src/controllers/invite.controller.js +249 -0
- package/src/controllers/jsonConfigs.controller.js +19 -0
- package/src/controllers/metrics.controller.js +149 -0
- package/src/controllers/notificationAdmin.controller.js +264 -0
- package/src/controllers/notifications.controller.js +131 -0
- package/src/controllers/org.controller.js +357 -0
- package/src/controllers/orgAdmin.controller.js +491 -0
- package/src/controllers/stripeAdmin.controller.js +410 -0
- package/src/controllers/user.controller.js +361 -0
- package/src/controllers/userAdmin.controller.js +277 -0
- package/src/controllers/waitingList.controller.js +167 -0
- package/src/controllers/webhook.controller.js +200 -0
- package/src/middleware/auth.js +66 -0
- package/src/middleware/errorCapture.js +170 -0
- package/src/middleware/headlessApiTokenAuth.js +57 -0
- package/src/middleware/org.js +108 -0
- package/src/middleware.js +901 -0
- package/src/models/ActionEvent.js +31 -0
- package/src/models/ActivityLog.js +41 -0
- package/src/models/Asset.js +84 -0
- package/src/models/AuditEvent.js +93 -0
- package/src/models/EmailLog.js +28 -0
- package/src/models/ErrorAggregate.js +72 -0
- package/src/models/FormSubmission.js +41 -0
- package/src/models/GlobalSetting.js +38 -0
- package/src/models/HeadlessApiToken.js +24 -0
- package/src/models/HeadlessModelDefinition.js +41 -0
- package/src/models/I18nEntry.js +77 -0
- package/src/models/I18nLocale.js +33 -0
- package/src/models/Invite.js +70 -0
- package/src/models/JsonConfig.js +46 -0
- package/src/models/Notification.js +60 -0
- package/src/models/Organization.js +57 -0
- package/src/models/OrganizationMember.js +43 -0
- package/src/models/StripeCatalogItem.js +77 -0
- package/src/models/StripeWebhookEvent.js +57 -0
- package/src/models/User.js +89 -0
- package/src/models/VirtualEjsFile.js +60 -0
- package/src/models/VirtualEjsFileVersion.js +43 -0
- package/src/models/VirtualEjsGroupChange.js +32 -0
- package/src/models/WaitingList.js +41 -0
- package/src/models/Webhook.js +63 -0
- package/src/models/Workflow.js +29 -0
- package/src/models/WorkflowExecution.js +12 -0
- package/src/routes/admin.routes.js +26 -0
- package/src/routes/adminAssets.routes.js +28 -0
- package/src/routes/adminAssetsStorage.routes.js +13 -0
- package/src/routes/adminAudit.routes.js +196 -0
- package/src/routes/adminEjsVirtual.routes.js +17 -0
- package/src/routes/adminErrors.routes.js +164 -0
- package/src/routes/adminFeatureFlags.routes.js +12 -0
- package/src/routes/adminHeadless.routes.js +38 -0
- package/src/routes/adminI18n.routes.js +22 -0
- package/src/routes/adminJsonConfigs.routes.js +15 -0
- package/src/routes/adminLlm.routes.js +12 -0
- package/src/routes/adminMigration.routes.js +81 -0
- package/src/routes/adminSeoConfig.routes.js +20 -0
- package/src/routes/adminUploadNamespaces.routes.js +13 -0
- package/src/routes/assets.routes.js +21 -0
- package/src/routes/auth.routes.js +12 -0
- package/src/routes/billing.routes.js +11 -0
- package/src/routes/errorTracking.routes.js +31 -0
- package/src/routes/featureFlags.routes.js +9 -0
- package/src/routes/forms.routes.js +9 -0
- package/src/routes/formsAdmin.routes.js +13 -0
- package/src/routes/globalSettings.routes.js +18 -0
- package/src/routes/headless.routes.js +15 -0
- package/src/routes/i18n.routes.js +8 -0
- package/src/routes/invite.routes.js +9 -0
- package/src/routes/jsonConfigs.routes.js +8 -0
- package/src/routes/log.routes.js +111 -0
- package/src/routes/metrics.routes.js +9 -0
- package/src/routes/notificationAdmin.routes.js +15 -0
- package/src/routes/notifications.routes.js +12 -0
- package/src/routes/org.routes.js +31 -0
- package/src/routes/orgAdmin.routes.js +20 -0
- package/src/routes/publicAssets.routes.js +7 -0
- package/src/routes/stripeAdmin.routes.js +20 -0
- package/src/routes/user.routes.js +22 -0
- package/src/routes/userAdmin.routes.js +15 -0
- package/src/routes/waitingList.routes.js +13 -0
- package/src/routes/waitingListAdmin.routes.js +9 -0
- package/src/routes/webhook.routes.js +32 -0
- package/src/routes/workflowWebhook.routes.js +54 -0
- package/src/routes/workflows.routes.js +110 -0
- package/src/services/assets.service.js +110 -0
- package/src/services/audit.service.js +62 -0
- package/src/services/auditLogger.js +165 -0
- package/src/services/ejsVirtual.service.js +614 -0
- package/src/services/email.service.js +351 -0
- package/src/services/errorLogger.js +221 -0
- package/src/services/featureFlags.service.js +202 -0
- package/src/services/forms.service.js +214 -0
- package/src/services/globalSettings.service.js +49 -0
- package/src/services/headlessApiTokens.service.js +158 -0
- package/src/services/headlessCrypto.service.js +31 -0
- package/src/services/headlessModels.service.js +356 -0
- package/src/services/i18n.service.js +314 -0
- package/src/services/i18nInferredKeys.service.js +337 -0
- package/src/services/jsonConfigs.service.js +392 -0
- package/src/services/llm.service.js +749 -0
- package/src/services/migration.service.js +581 -0
- package/src/services/migrationAssets/fsLocal.js +58 -0
- package/src/services/migrationAssets/index.js +134 -0
- package/src/services/migrationAssets/s3.js +75 -0
- package/src/services/migrationAssets/sftp.js +92 -0
- package/src/services/notification.service.js +212 -0
- package/src/services/objectStorage.service.js +514 -0
- package/src/services/seoConfig.service.js +402 -0
- package/src/services/storage.js +150 -0
- package/src/services/stripe.service.js +185 -0
- package/src/services/stripeHelper.service.js +264 -0
- package/src/services/uploadNamespaces.service.js +326 -0
- package/src/services/webhook.service.js +157 -0
- package/src/services/workflow.service.js +271 -0
- package/src/utils/asyncHandler.js +5 -0
- package/src/utils/encryption.js +80 -0
- package/src/utils/jwt.js +40 -0
- package/src/utils/orgRoles.js +156 -0
- package/src/utils/validation.js +26 -0
- package/src/utils/webhookRetry.js +93 -0
- package/views/admin-assets.ejs +444 -0
- package/views/admin-audit.ejs +283 -0
- package/views/admin-coolify-deploy.ejs +207 -0
- package/views/admin-dashboard-home.ejs +291 -0
- package/views/admin-dashboard.ejs +397 -0
- package/views/admin-ejs-virtual.ejs +280 -0
- package/views/admin-errors.ejs +368 -0
- package/views/admin-feature-flags.ejs +390 -0
- package/views/admin-forms.ejs +526 -0
- package/views/admin-global-settings.ejs +436 -0
- package/views/admin-headless.ejs +2020 -0
- package/views/admin-i18n-locales.ejs +221 -0
- package/views/admin-i18n.ejs +728 -0
- package/views/admin-json-configs.ejs +410 -0
- package/views/admin-llm.ejs +884 -0
- package/views/admin-metrics.ejs +274 -0
- package/views/admin-migration.ejs +814 -0
- package/views/admin-notifications.ejs +430 -0
- package/views/admin-organizations.ejs +984 -0
- package/views/admin-seo-config.ejs +673 -0
- package/views/admin-stripe-pricing.ejs +558 -0
- package/views/admin-test.ejs +342 -0
- package/views/admin-users.ejs +452 -0
- package/views/admin-waiting-list.ejs +547 -0
- package/views/admin-webhooks.ejs +329 -0
- package/views/admin-workflows.ejs +310 -0
- package/views/partials/admin-assets-script.ejs +2022 -0
- package/views/partials/admin-test-sidebar.ejs +14 -0
- package/views/partials/dashboard/nav-items.ejs +66 -0
- package/views/partials/dashboard/palette.ejs +63 -0
- package/views/partials/dashboard/sidebar.ejs +21 -0
- package/views/partials/dashboard/tab-bar.ejs +26 -0
- package/views/partials/footer.ejs +3 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>i18n - Admin</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<style>
|
|
9
|
+
.toast { animation: slideIn 0.3s ease-out; }
|
|
10
|
+
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
|
11
|
+
.fade-out { animation: fadeOut 0.3s ease-out forwards; }
|
|
12
|
+
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
|
|
13
|
+
</style>
|
|
14
|
+
</head>
|
|
15
|
+
<body class="bg-gray-100">
|
|
16
|
+
<div class="min-h-screen">
|
|
17
|
+
<div class="bg-white shadow">
|
|
18
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
19
|
+
<div class="flex justify-between items-center">
|
|
20
|
+
<div>
|
|
21
|
+
<h1 class="text-2xl font-bold text-gray-900">i18n Manager</h1>
|
|
22
|
+
<p class="text-sm text-gray-600 mt-1">Manage translations (entries) and AI translations</p>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="flex items-center gap-4">
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
31
|
+
<div class="bg-white rounded-lg shadow p-6">
|
|
32
|
+
<div class="flex flex-col md:flex-row gap-4 md:items-end md:justify-between">
|
|
33
|
+
<div class="flex gap-3 items-end">
|
|
34
|
+
<div>
|
|
35
|
+
<label class="block text-sm font-medium mb-2">Locale</label>
|
|
36
|
+
<select id="locale" class="border rounded px-3 py-2" onchange="loadEntries()"></select>
|
|
37
|
+
</div>
|
|
38
|
+
<div>
|
|
39
|
+
<label class="block text-sm font-medium mb-2">Search key</label>
|
|
40
|
+
<input id="search" class="border rounded px-3 py-2" placeholder="e.g. landing.hero.title" oninput="loadEntries()" onkeydown="if (event.key === 'Enter') { event.preventDefault(); loadEntries(); }" />
|
|
41
|
+
</div>
|
|
42
|
+
<div class="flex items-center gap-2 pb-2">
|
|
43
|
+
<input id="missingOnly" type="checkbox" onchange="loadEntries()" />
|
|
44
|
+
<label for="missingOnly" class="text-sm">Missing only</label>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="flex items-center gap-2 pb-2">
|
|
47
|
+
<input id="includeInferred" type="checkbox" onchange="loadEntries()" />
|
|
48
|
+
<label for="includeInferred" class="text-sm">Include inferred keys</label>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="flex gap-2">
|
|
52
|
+
<button onclick="loadEntries()" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Refresh</button>
|
|
53
|
+
<button onclick="showCreate()" class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600">New Entry</button>
|
|
54
|
+
<button onclick="showAi()" class="bg-purple-600 text-white px-4 py-2 rounded hover:bg-purple-700">AI Translate</button>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div class="mt-6 overflow-auto">
|
|
59
|
+
<table class="min-w-full text-sm">
|
|
60
|
+
<thead>
|
|
61
|
+
<tr class="text-left border-b">
|
|
62
|
+
<th class="py-2 pr-4">Key</th>
|
|
63
|
+
<th class="py-2 pr-4">Value</th>
|
|
64
|
+
<th class="py-2 pr-4">Format</th>
|
|
65
|
+
<th class="py-2 pr-4">Source</th>
|
|
66
|
+
<th class="py-2 pr-4"></th>
|
|
67
|
+
</tr>
|
|
68
|
+
</thead>
|
|
69
|
+
<tbody id="rows"></tbody>
|
|
70
|
+
</table>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div id="toast-container" class="fixed top-4 right-4 space-y-2 z-50"></div>
|
|
77
|
+
|
|
78
|
+
<div id="modal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
|
79
|
+
<div class="relative top-20 mx-auto p-5 border w-full max-w-3xl shadow-lg rounded-md bg-white">
|
|
80
|
+
<div class="flex justify-between items-center mb-4">
|
|
81
|
+
<h3 class="text-xl font-bold" id="modalTitle">Entry</h3>
|
|
82
|
+
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600">✕</button>
|
|
83
|
+
</div>
|
|
84
|
+
<form onsubmit="saveEntry(event)">
|
|
85
|
+
<input type="hidden" id="entryId" />
|
|
86
|
+
<div class="mb-4">
|
|
87
|
+
<label class="block text-sm font-medium mb-2">Key</label>
|
|
88
|
+
<input id="entryKey" class="w-full border rounded px-3 py-2" required />
|
|
89
|
+
</div>
|
|
90
|
+
<div class="mb-4">
|
|
91
|
+
<label class="block text-sm font-medium mb-2">Locale</label>
|
|
92
|
+
<input id="entryLocale" class="w-full border rounded px-3 py-2" required />
|
|
93
|
+
</div>
|
|
94
|
+
<div class="mb-4">
|
|
95
|
+
<label class="block text-sm font-medium mb-2">Format</label>
|
|
96
|
+
<select id="entryFormat" class="w-full border rounded px-3 py-2">
|
|
97
|
+
<option value="text">text</option>
|
|
98
|
+
<option value="html">html</option>
|
|
99
|
+
</select>
|
|
100
|
+
</div>
|
|
101
|
+
<div class="mb-4">
|
|
102
|
+
<label class="block text-sm font-medium mb-2">Value</label>
|
|
103
|
+
<textarea id="entryValue" class="w-full border rounded px-3 py-2" rows="6" required></textarea>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="mb-4">
|
|
106
|
+
<label class="block text-sm font-medium mb-2">AI Translate</label>
|
|
107
|
+
<div class="flex flex-col md:flex-row gap-2 md:items-end">
|
|
108
|
+
<div>
|
|
109
|
+
<div class="text-xs text-gray-600 mb-1">From</div>
|
|
110
|
+
<input id="entryAiFrom" class="border rounded px-3 py-2" value="fr" />
|
|
111
|
+
</div>
|
|
112
|
+
<div>
|
|
113
|
+
<div class="text-xs text-gray-600 mb-1">To</div>
|
|
114
|
+
<input id="entryAiTo" class="border rounded px-3 py-2" readonly />
|
|
115
|
+
</div>
|
|
116
|
+
<div class="flex gap-2">
|
|
117
|
+
<button type="button" onclick="aiTranslateEntry()" class="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700">AI translate</button>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
<div class="flex justify-end gap-2">
|
|
122
|
+
<button type="button" onclick="closeModal()" class="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400">Cancel</button>
|
|
123
|
+
<button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">Save</button>
|
|
124
|
+
</div>
|
|
125
|
+
</form>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div id="aiModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
|
130
|
+
<div class="relative top-10 mx-auto p-5 border w-full max-w-5xl shadow-lg rounded-md bg-white">
|
|
131
|
+
<div class="flex justify-between items-center mb-4">
|
|
132
|
+
<h3 class="text-xl font-bold">AI Translate (preview then apply)</h3>
|
|
133
|
+
<button onclick="closeAi()" class="text-gray-400 hover:text-gray-600">✕</button>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div class="grid grid-cols-1 md:grid-cols-5 gap-3">
|
|
137
|
+
<div>
|
|
138
|
+
<label class="block text-sm font-medium mb-2">From</label>
|
|
139
|
+
<select id="aiFrom" class="w-full border rounded px-3 py-2" onchange="loadAiCandidates()"></select>
|
|
140
|
+
</div>
|
|
141
|
+
<div>
|
|
142
|
+
<label class="block text-sm font-medium mb-2">To</label>
|
|
143
|
+
<input id="aiTo" class="w-full border rounded px-3 py-2" value="fr" oninput="renderAiCandidates()" />
|
|
144
|
+
</div>
|
|
145
|
+
<div>
|
|
146
|
+
<label class="block text-sm font-medium mb-2">Filter</label>
|
|
147
|
+
<input id="aiFilter" class="w-full border rounded px-3 py-2" placeholder="Filter keys..." oninput="renderAiCandidates()" />
|
|
148
|
+
</div>
|
|
149
|
+
<div>
|
|
150
|
+
<label class="block text-sm font-medium mb-2">Model</label>
|
|
151
|
+
<input id="aiModel" class="w-full border rounded px-3 py-2" placeholder="(optional)" />
|
|
152
|
+
</div>
|
|
153
|
+
<div class="flex items-center gap-2 pt-8">
|
|
154
|
+
<input id="aiMissing" type="checkbox" onchange="renderAiCandidates()" />
|
|
155
|
+
<label for="aiMissing" class="text-sm">Show missing only</label>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<div class="mt-4">
|
|
160
|
+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
|
|
161
|
+
<div class="flex gap-2 items-center">
|
|
162
|
+
<button type="button" onclick="aiBulkSelect('all')" class="px-3 py-2 bg-gray-100 rounded border hover:bg-gray-200">Select all</button>
|
|
163
|
+
<button type="button" onclick="aiBulkSelect('none')" class="px-3 py-2 bg-gray-100 rounded border hover:bg-gray-200">Select none</button>
|
|
164
|
+
<button type="button" onclick="aiBulkSelect('visible')" class="px-3 py-2 bg-gray-100 rounded border hover:bg-gray-200">Select visible</button>
|
|
165
|
+
</div>
|
|
166
|
+
<div class="text-sm text-gray-600">
|
|
167
|
+
<span id="aiCandidateSummary">0 selected</span>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div class="mt-3 border rounded max-h-[24vh] overflow-auto">
|
|
172
|
+
<table class="min-w-full text-sm">
|
|
173
|
+
<thead class="bg-gray-50 sticky top-0">
|
|
174
|
+
<tr class="text-left border-b">
|
|
175
|
+
<th class="py-2 px-3 w-10"></th>
|
|
176
|
+
<th class="py-2 pr-4">Key</th>
|
|
177
|
+
<th class="py-2 pr-4">From value</th>
|
|
178
|
+
</tr>
|
|
179
|
+
</thead>
|
|
180
|
+
<tbody id="aiCandidateRows"></tbody>
|
|
181
|
+
</table>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div class="mt-4 flex gap-2">
|
|
186
|
+
<button onclick="aiPreview()" class="bg-purple-600 text-white px-4 py-2 rounded hover:bg-purple-700">Preview</button>
|
|
187
|
+
<button onclick="aiApply()" class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700">Apply selected</button>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<div class="mt-4 overflow-auto max-h-[40vh]">
|
|
191
|
+
<table class="min-w-full text-sm">
|
|
192
|
+
<thead>
|
|
193
|
+
<tr class="text-left border-b">
|
|
194
|
+
<th class="py-2 pr-4"></th>
|
|
195
|
+
<th class="py-2 pr-4">Key</th>
|
|
196
|
+
<th class="py-2 pr-4">From</th>
|
|
197
|
+
<th class="py-2 pr-4">Proposed</th>
|
|
198
|
+
</tr>
|
|
199
|
+
</thead>
|
|
200
|
+
<tbody id="aiRows"></tbody>
|
|
201
|
+
</table>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<script>
|
|
207
|
+
const API_BASE = window.location.origin + "<%= baseUrl %>" || window.location.origin;
|
|
208
|
+
let allLocales = [];
|
|
209
|
+
let aiResults = [];
|
|
210
|
+
let aiCandidates = [];
|
|
211
|
+
let aiCandidateKeySet = new Set();
|
|
212
|
+
let aiSelectedKeys = new Set();
|
|
213
|
+
let aiToLocaleKeySet = null;
|
|
214
|
+
let aiToLocaleKeySetFor = null;
|
|
215
|
+
|
|
216
|
+
function showToast(message, type = 'success') {
|
|
217
|
+
const container = document.getElementById('toast-container');
|
|
218
|
+
const toast = document.createElement('div');
|
|
219
|
+
toast.className = `toast px-6 py-4 rounded-lg shadow-lg text-white ${type === 'success' ? 'bg-green-500' : 'bg-red-500'}`;
|
|
220
|
+
toast.textContent = message;
|
|
221
|
+
container.appendChild(toast);
|
|
222
|
+
setTimeout(() => {
|
|
223
|
+
toast.classList.add('fade-out');
|
|
224
|
+
setTimeout(() => toast.remove(), 300);
|
|
225
|
+
}, 3000);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function loadLocales() {
|
|
229
|
+
const res = await fetch(`${API_BASE}/api/admin/i18n/locales`);
|
|
230
|
+
if (!res.ok) throw new Error('Failed to load locales');
|
|
231
|
+
const data = await res.json();
|
|
232
|
+
allLocales = data.locales || [];
|
|
233
|
+
|
|
234
|
+
if (!Array.isArray(allLocales) || allLocales.length === 0) {
|
|
235
|
+
allLocales = [
|
|
236
|
+
{ code: 'en', isDefault: true },
|
|
237
|
+
{ code: 'fr', isDefault: false },
|
|
238
|
+
{ code: 'es', isDefault: false },
|
|
239
|
+
];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const select = document.getElementById('locale');
|
|
243
|
+
select.innerHTML = allLocales.map(l => `<option value="${l.code}">${l.code}${l.isDefault ? ' (default)' : ''}</option>`).join('');
|
|
244
|
+
|
|
245
|
+
const defaultLocale = allLocales.find(l => l.isDefault)?.code || allLocales[0]?.code;
|
|
246
|
+
if (defaultLocale) select.value = defaultLocale;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function entryRow(entry) {
|
|
250
|
+
const id = entry._id;
|
|
251
|
+
const key = entry.key;
|
|
252
|
+
const value = entry.value || '';
|
|
253
|
+
const format = entry.valueFormat || 'text';
|
|
254
|
+
const source = entry.source || '';
|
|
255
|
+
|
|
256
|
+
return `
|
|
257
|
+
<tr class="border-b">
|
|
258
|
+
<td class="py-2 pr-4 font-mono text-xs">${escapeHtml(key)}</td>
|
|
259
|
+
<td class="py-2 pr-4">
|
|
260
|
+
<pre class="whitespace-pre-wrap max-w-3xl">${escapeHtml(value)}</pre>
|
|
261
|
+
</td>
|
|
262
|
+
<td class="py-2 pr-4">${escapeHtml(format)}</td>
|
|
263
|
+
<td class="py-2 pr-4">${escapeHtml(source)}</td>
|
|
264
|
+
<td class="py-2 pr-4 text-right">
|
|
265
|
+
${id ? `<button onclick="editEntry('${id}')" class="text-blue-600 hover:text-blue-800">Edit</button>` : `<button onclick="createMissing('${escapeAttr(key)}')" class="text-green-600 hover:text-green-800">Create</button>`}
|
|
266
|
+
${id ? `<button onclick="deleteEntry('${id}')" class="ml-3 text-red-600 hover:text-red-800">Delete</button>` : ''}
|
|
267
|
+
</td>
|
|
268
|
+
</tr>
|
|
269
|
+
`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function escapeHtml(text) {
|
|
273
|
+
const div = document.createElement('div');
|
|
274
|
+
div.textContent = text;
|
|
275
|
+
return div.innerHTML;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function escapeAttr(text) {
|
|
279
|
+
return String(text).replace(/"/g, '"');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function loadEntries() {
|
|
283
|
+
try {
|
|
284
|
+
const locale = document.getElementById('locale').value;
|
|
285
|
+
const search = document.getElementById('search').value;
|
|
286
|
+
const missingOnly = document.getElementById('missingOnly').checked;
|
|
287
|
+
const includeInferred = document.getElementById('includeInferred').checked;
|
|
288
|
+
|
|
289
|
+
const params = new URLSearchParams({ locale });
|
|
290
|
+
if (search) params.set('search', search);
|
|
291
|
+
if (missingOnly) params.set('missing', 'true');
|
|
292
|
+
if (includeInferred) params.set('includeInferred', 'true');
|
|
293
|
+
|
|
294
|
+
const res = await fetch(`${API_BASE}/api/admin/i18n/entries?${params.toString()}`);
|
|
295
|
+
if (!res.ok) throw new Error('Failed to load entries');
|
|
296
|
+
const data = await res.json();
|
|
297
|
+
const entries = data.entries || [];
|
|
298
|
+
|
|
299
|
+
window.__i18nEntriesByKey = {};
|
|
300
|
+
for (const e of entries) {
|
|
301
|
+
if (!e || !e.key) continue;
|
|
302
|
+
window.__i18nEntriesByKey[e.key] = e;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
document.getElementById('rows').innerHTML = entries.map(entryRow).join('');
|
|
306
|
+
showToast('Entries loaded');
|
|
307
|
+
} catch (e) {
|
|
308
|
+
showToast(e.message, 'error');
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function showCreate() {
|
|
313
|
+
document.getElementById('modalTitle').textContent = 'Create Entry';
|
|
314
|
+
document.getElementById('entryId').value = '';
|
|
315
|
+
document.getElementById('entryKey').value = '';
|
|
316
|
+
document.getElementById('entryLocale').value = document.getElementById('locale').value;
|
|
317
|
+
document.getElementById('entryFormat').value = 'text';
|
|
318
|
+
document.getElementById('entryValue').value = '';
|
|
319
|
+
|
|
320
|
+
document.getElementById('entryAiTo').value = document.getElementById('locale').value;
|
|
321
|
+
|
|
322
|
+
document.getElementById('entryKey').disabled = false;
|
|
323
|
+
document.getElementById('entryLocale').disabled = false;
|
|
324
|
+
|
|
325
|
+
document.getElementById('modal').classList.remove('hidden');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function createMissing(key) {
|
|
329
|
+
const inferred = window.__i18nEntriesByKey ? window.__i18nEntriesByKey[key] : null;
|
|
330
|
+
|
|
331
|
+
document.getElementById('modalTitle').textContent = 'Create Entry';
|
|
332
|
+
document.getElementById('entryId').value = '';
|
|
333
|
+
document.getElementById('entryKey').value = key;
|
|
334
|
+
document.getElementById('entryLocale').value = document.getElementById('locale').value;
|
|
335
|
+
document.getElementById('entryFormat').value = (inferred && inferred.valueFormat) ? inferred.valueFormat : 'text';
|
|
336
|
+
document.getElementById('entryValue').value = (inferred && inferred.value) ? inferred.value : '';
|
|
337
|
+
|
|
338
|
+
document.getElementById('entryAiTo').value = document.getElementById('locale').value;
|
|
339
|
+
|
|
340
|
+
document.getElementById('entryKey').disabled = true;
|
|
341
|
+
document.getElementById('entryLocale').disabled = true;
|
|
342
|
+
|
|
343
|
+
document.getElementById('modal').classList.remove('hidden');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function aiTranslateEntry() {
|
|
347
|
+
try {
|
|
348
|
+
const fromLocale = document.getElementById('entryAiFrom').value;
|
|
349
|
+
const toLocale = document.getElementById('entryAiTo').value || document.getElementById('entryLocale').value;
|
|
350
|
+
const text = document.getElementById('entryValue').value;
|
|
351
|
+
|
|
352
|
+
const res = await fetch(`${API_BASE}/api/admin/i18n/ai/translate-text`, {
|
|
353
|
+
method: 'POST',
|
|
354
|
+
headers: { 'Content-Type': 'application/json' },
|
|
355
|
+
body: JSON.stringify({ fromLocale, toLocale, text })
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
if (!res.ok) {
|
|
359
|
+
const data = await res.json().catch(() => ({}));
|
|
360
|
+
throw new Error(data.error || 'AI translate failed');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const data = await res.json();
|
|
364
|
+
document.getElementById('entryValue').value = data.translatedText || '';
|
|
365
|
+
showToast('AI translation ready');
|
|
366
|
+
} catch (e) {
|
|
367
|
+
showToast(e.message, 'error');
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function editEntry(id) {
|
|
372
|
+
try {
|
|
373
|
+
const locale = document.getElementById('locale').value;
|
|
374
|
+
const res = await fetch(`${API_BASE}/api/admin/i18n/entries?locale=${encodeURIComponent(locale)}`);
|
|
375
|
+
if (!res.ok) throw new Error('Failed to load entry');
|
|
376
|
+
const data = await res.json();
|
|
377
|
+
const entry = (data.entries || []).find(e => e._id === id);
|
|
378
|
+
if (!entry) throw new Error('Entry not found');
|
|
379
|
+
|
|
380
|
+
document.getElementById('modalTitle').textContent = 'Edit Entry';
|
|
381
|
+
document.getElementById('entryId').value = entry._id;
|
|
382
|
+
document.getElementById('entryKey').value = entry.key;
|
|
383
|
+
document.getElementById('entryLocale').value = entry.locale;
|
|
384
|
+
document.getElementById('entryFormat').value = entry.valueFormat || 'text';
|
|
385
|
+
document.getElementById('entryValue').value = entry.value || '';
|
|
386
|
+
|
|
387
|
+
document.getElementById('entryAiTo').value = entry.locale;
|
|
388
|
+
|
|
389
|
+
document.getElementById('entryKey').disabled = true;
|
|
390
|
+
document.getElementById('entryLocale').disabled = true;
|
|
391
|
+
|
|
392
|
+
document.getElementById('modal').classList.remove('hidden');
|
|
393
|
+
} catch (e) {
|
|
394
|
+
showToast(e.message, 'error');
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function closeModal() {
|
|
399
|
+
document.getElementById('modal').classList.add('hidden');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function saveEntry(event) {
|
|
403
|
+
event.preventDefault();
|
|
404
|
+
try {
|
|
405
|
+
const id = document.getElementById('entryId').value;
|
|
406
|
+
const key = document.getElementById('entryKey').value;
|
|
407
|
+
const locale = document.getElementById('entryLocale').value;
|
|
408
|
+
const valueFormat = document.getElementById('entryFormat').value;
|
|
409
|
+
const value = document.getElementById('entryValue').value;
|
|
410
|
+
|
|
411
|
+
let res;
|
|
412
|
+
if (!id) {
|
|
413
|
+
res = await fetch(`${API_BASE}/api/admin/i18n/entries`, {
|
|
414
|
+
method: 'POST',
|
|
415
|
+
headers: { 'Content-Type': 'application/json' },
|
|
416
|
+
body: JSON.stringify({ key, locale, value, valueFormat })
|
|
417
|
+
});
|
|
418
|
+
} else {
|
|
419
|
+
res = await fetch(`${API_BASE}/api/admin/i18n/entries/${id}`, {
|
|
420
|
+
method: 'PUT',
|
|
421
|
+
headers: { 'Content-Type': 'application/json' },
|
|
422
|
+
body: JSON.stringify({ value, valueFormat })
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (!res.ok) {
|
|
427
|
+
const data = await res.json();
|
|
428
|
+
throw new Error(data.error || 'Operation failed');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
closeModal();
|
|
432
|
+
await loadEntries();
|
|
433
|
+
showToast('Saved');
|
|
434
|
+
} catch (e) {
|
|
435
|
+
showToast(e.message, 'error');
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function deleteEntry(id) {
|
|
440
|
+
if (!confirm('Delete this entry?')) return;
|
|
441
|
+
try {
|
|
442
|
+
const res = await fetch(`${API_BASE}/api/admin/i18n/entries/${id}`, { method: 'DELETE' });
|
|
443
|
+
if (!res.ok) {
|
|
444
|
+
const data = await res.json();
|
|
445
|
+
throw new Error(data.error || 'Delete failed');
|
|
446
|
+
}
|
|
447
|
+
await loadEntries();
|
|
448
|
+
showToast('Deleted');
|
|
449
|
+
} catch (e) {
|
|
450
|
+
showToast(e.message, 'error');
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function showAi() {
|
|
455
|
+
document.getElementById('aiRows').innerHTML = '';
|
|
456
|
+
document.getElementById('aiCandidateRows').innerHTML = '';
|
|
457
|
+
aiResults = [];
|
|
458
|
+
aiCandidates = [];
|
|
459
|
+
aiCandidateKeySet = new Set();
|
|
460
|
+
aiSelectedKeys = new Set();
|
|
461
|
+
aiToLocaleKeySet = null;
|
|
462
|
+
aiToLocaleKeySetFor = null;
|
|
463
|
+
|
|
464
|
+
const fromSelect = document.getElementById('aiFrom');
|
|
465
|
+
const fromLocales = allLocales
|
|
466
|
+
.filter((l) => Number(l.entryCount || 0) > 0)
|
|
467
|
+
.map((l) => l.code);
|
|
468
|
+
|
|
469
|
+
const fallbackFromLocales = allLocales
|
|
470
|
+
.filter((l) => l.enabled !== false)
|
|
471
|
+
.map((l) => l.code);
|
|
472
|
+
|
|
473
|
+
const fromOptions = (fromLocales.length > 0 ? fromLocales : fallbackFromLocales);
|
|
474
|
+
fromSelect.innerHTML = fromOptions.map((code) => `<option value="${code}">${code}</option>`).join('');
|
|
475
|
+
|
|
476
|
+
const currentLocale = document.getElementById('locale')?.value;
|
|
477
|
+
const defaultLocale = allLocales.find(l => l.isDefault)?.code || allLocales[0]?.code;
|
|
478
|
+
if (defaultLocale && fromOptions.includes(defaultLocale)) {
|
|
479
|
+
fromSelect.value = defaultLocale;
|
|
480
|
+
} else if (fromOptions.includes(String(currentLocale || ''))) {
|
|
481
|
+
fromSelect.value = currentLocale;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const toInput = document.getElementById('aiTo');
|
|
485
|
+
if (currentLocale) toInput.value = currentLocale;
|
|
486
|
+
|
|
487
|
+
document.getElementById('aiFilter').value = '';
|
|
488
|
+
document.getElementById('aiMissing').checked = false;
|
|
489
|
+
document.getElementById('aiCandidateSummary').textContent = '0 selected';
|
|
490
|
+
|
|
491
|
+
document.getElementById('aiModal').classList.remove('hidden');
|
|
492
|
+
loadAiCandidates();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function closeAi() {
|
|
496
|
+
document.getElementById('aiModal').classList.add('hidden');
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function aiRow(item, idx) {
|
|
500
|
+
return `
|
|
501
|
+
<tr class="border-b">
|
|
502
|
+
<td class="py-2 pr-4"><input type="checkbox" data-ai="${idx}" checked /></td>
|
|
503
|
+
<td class="py-2 pr-4 font-mono text-xs">${escapeHtml(item.key)}</td>
|
|
504
|
+
<td class="py-2 pr-4"><pre class="whitespace-pre-wrap max-w-xl">${escapeHtml(item.fromValue || '')}</pre></td>
|
|
505
|
+
<td class="py-2 pr-4">
|
|
506
|
+
<textarea class="w-full border rounded px-2 py-1" rows="3" data-ai-value="${idx}">${escapeHtml(item.proposedValue || '')}</textarea>
|
|
507
|
+
</td>
|
|
508
|
+
</tr>
|
|
509
|
+
`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function aiCandidateRow(item) {
|
|
513
|
+
const checked = aiSelectedKeys.has(item.key);
|
|
514
|
+
return `
|
|
515
|
+
<tr class="border-b hover:bg-gray-50">
|
|
516
|
+
<td class="py-2 px-3">
|
|
517
|
+
<input type="checkbox" data-ai-candidate="${escapeAttr(item.key)}" ${checked ? 'checked' : ''} onchange="toggleAiCandidate('${escapeAttr(item.key)}', this.checked)" />
|
|
518
|
+
</td>
|
|
519
|
+
<td class="py-2 pr-4 font-mono text-xs">${escapeHtml(item.key)}</td>
|
|
520
|
+
<td class="py-2 pr-4"><pre class="whitespace-pre-wrap max-w-4xl">${escapeHtml(item.value || '')}</pre></td>
|
|
521
|
+
</tr>
|
|
522
|
+
`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function toggleAiCandidate(key, checked) {
|
|
526
|
+
if (!key) return;
|
|
527
|
+
if (checked) aiSelectedKeys.add(key);
|
|
528
|
+
else aiSelectedKeys.delete(key);
|
|
529
|
+
updateAiCandidateSummary();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function updateAiCandidateSummary() {
|
|
533
|
+
const selected = aiSelectedKeys.size;
|
|
534
|
+
const total = aiCandidates.length;
|
|
535
|
+
const el = document.getElementById('aiCandidateSummary');
|
|
536
|
+
if (!el) return;
|
|
537
|
+
el.textContent = `${selected} selected (of ${total})`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function buildToLocaleKeySet() {
|
|
541
|
+
const toLocale = document.getElementById('aiTo').value;
|
|
542
|
+
if (!toLocale) return new Set();
|
|
543
|
+
if (aiToLocaleKeySet && aiToLocaleKeySetFor === toLocale) return aiToLocaleKeySet;
|
|
544
|
+
|
|
545
|
+
const res = await fetch(`${API_BASE}/api/admin/i18n/entries?locale=${encodeURIComponent(toLocale)}`);
|
|
546
|
+
if (!res.ok) {
|
|
547
|
+
aiToLocaleKeySet = new Set();
|
|
548
|
+
aiToLocaleKeySetFor = toLocale;
|
|
549
|
+
return aiToLocaleKeySet;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const data = await res.json().catch(() => ({}));
|
|
553
|
+
const entries = data.entries || [];
|
|
554
|
+
const set = new Set(entries.filter((e) => e && e.key).map((e) => e.key));
|
|
555
|
+
aiToLocaleKeySet = set;
|
|
556
|
+
aiToLocaleKeySetFor = toLocale;
|
|
557
|
+
return set;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function loadAiCandidates() {
|
|
561
|
+
try {
|
|
562
|
+
const fromLocale = document.getElementById('aiFrom').value;
|
|
563
|
+
if (!fromLocale) return;
|
|
564
|
+
|
|
565
|
+
const res = await fetch(`${API_BASE}/api/admin/i18n/entries?locale=${encodeURIComponent(fromLocale)}`);
|
|
566
|
+
if (!res.ok) throw new Error('Failed to load source entries');
|
|
567
|
+
|
|
568
|
+
const data = await res.json().catch(() => ({}));
|
|
569
|
+
const entries = (data.entries || []).filter((e) => e && e._id && e.key);
|
|
570
|
+
aiCandidates = entries.map((e) => ({
|
|
571
|
+
key: e.key,
|
|
572
|
+
value: e.value || '',
|
|
573
|
+
valueFormat: e.valueFormat || 'text',
|
|
574
|
+
}));
|
|
575
|
+
aiCandidateKeySet = new Set(aiCandidates.map((c) => c.key));
|
|
576
|
+
|
|
577
|
+
aiSelectedKeys = new Set();
|
|
578
|
+
aiResults = [];
|
|
579
|
+
document.getElementById('aiRows').innerHTML = '';
|
|
580
|
+
|
|
581
|
+
updateAiCandidateSummary();
|
|
582
|
+
await renderAiCandidates();
|
|
583
|
+
} catch (e) {
|
|
584
|
+
showToast(e.message, 'error');
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function renderAiCandidates() {
|
|
589
|
+
const filter = String(document.getElementById('aiFilter').value || '').trim().toLowerCase();
|
|
590
|
+
const showMissingOnly = document.getElementById('aiMissing').checked;
|
|
591
|
+
|
|
592
|
+
let candidates = aiCandidates;
|
|
593
|
+
if (filter) {
|
|
594
|
+
candidates = candidates.filter((c) => String(c.key).toLowerCase().includes(filter));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (showMissingOnly) {
|
|
598
|
+
const toSet = await buildToLocaleKeySet();
|
|
599
|
+
candidates = candidates.filter((c) => !toSet.has(c.key));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const container = document.getElementById('aiCandidateRows');
|
|
603
|
+
container.innerHTML = candidates.map(aiCandidateRow).join('');
|
|
604
|
+
updateAiCandidateSummary();
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function aiBulkSelect(mode) {
|
|
608
|
+
const filter = String(document.getElementById('aiFilter').value || '').trim().toLowerCase();
|
|
609
|
+
const showMissingOnly = document.getElementById('aiMissing').checked;
|
|
610
|
+
|
|
611
|
+
(async () => {
|
|
612
|
+
let candidates = aiCandidates;
|
|
613
|
+
if (filter) {
|
|
614
|
+
candidates = candidates.filter((c) => String(c.key).toLowerCase().includes(filter));
|
|
615
|
+
}
|
|
616
|
+
if (showMissingOnly) {
|
|
617
|
+
const toSet = await buildToLocaleKeySet();
|
|
618
|
+
candidates = candidates.filter((c) => !toSet.has(c.key));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (mode === 'none') {
|
|
622
|
+
aiSelectedKeys = new Set();
|
|
623
|
+
} else if (mode === 'all') {
|
|
624
|
+
aiSelectedKeys = new Set(aiCandidates.map((c) => c.key));
|
|
625
|
+
} else if (mode === 'visible') {
|
|
626
|
+
for (const c of candidates) aiSelectedKeys.add(c.key);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
await renderAiCandidates();
|
|
630
|
+
})();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async function aiPreview() {
|
|
634
|
+
try {
|
|
635
|
+
const fromLocale = document.getElementById('aiFrom').value;
|
|
636
|
+
const toLocale = document.getElementById('aiTo').value;
|
|
637
|
+
const model = document.getElementById('aiModel').value;
|
|
638
|
+
const keys = Array.from(aiSelectedKeys);
|
|
639
|
+
|
|
640
|
+
if (keys.length === 0) {
|
|
641
|
+
showToast('Select at least one key to preview', 'error');
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const res = await fetch(`${API_BASE}/api/admin/i18n/ai/preview`, {
|
|
646
|
+
method: 'POST',
|
|
647
|
+
headers: { 'Content-Type': 'application/json' },
|
|
648
|
+
body: JSON.stringify({ fromLocale, toLocale, keys, missingOnly: false, model: model || undefined })
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
if (!res.ok) {
|
|
652
|
+
const data = await res.json();
|
|
653
|
+
throw new Error(data.error || 'AI preview failed');
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const data = await res.json();
|
|
657
|
+
aiResults = data.results || [];
|
|
658
|
+
document.getElementById('aiRows').innerHTML = aiResults.map(aiRow).join('');
|
|
659
|
+
showToast('AI preview ready');
|
|
660
|
+
} catch (e) {
|
|
661
|
+
showToast(e.message, 'error');
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function aiApply() {
|
|
666
|
+
try {
|
|
667
|
+
const toLocale = document.getElementById('aiTo').value;
|
|
668
|
+
const selected = [];
|
|
669
|
+
|
|
670
|
+
const checks = Array.from(document.querySelectorAll('input[data-ai]'));
|
|
671
|
+
for (const ch of checks) {
|
|
672
|
+
const idx = Number(ch.getAttribute('data-ai'));
|
|
673
|
+
if (!ch.checked) continue;
|
|
674
|
+
|
|
675
|
+
const textArea = document.querySelector(`textarea[data-ai-value="${idx}"]`);
|
|
676
|
+
const value = textArea ? textArea.value : '';
|
|
677
|
+
|
|
678
|
+
const item = aiResults[idx];
|
|
679
|
+
if (!item) continue;
|
|
680
|
+
|
|
681
|
+
selected.push({
|
|
682
|
+
key: item.key,
|
|
683
|
+
value,
|
|
684
|
+
valueFormat: item.valueFormat,
|
|
685
|
+
model: item.model,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const res = await fetch(`${API_BASE}/api/admin/i18n/ai/apply`, {
|
|
690
|
+
method: 'POST',
|
|
691
|
+
headers: { 'Content-Type': 'application/json' },
|
|
692
|
+
body: JSON.stringify({ toLocale, items: selected })
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
if (!res.ok) {
|
|
696
|
+
const data = await res.json();
|
|
697
|
+
throw new Error(data.error || 'AI apply failed');
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
closeAi();
|
|
701
|
+
await loadEntries();
|
|
702
|
+
showToast('AI results applied');
|
|
703
|
+
} catch (e) {
|
|
704
|
+
showToast(e.message, 'error');
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function init() {
|
|
709
|
+
try {
|
|
710
|
+
await loadLocales();
|
|
711
|
+
await loadEntries();
|
|
712
|
+
} catch (e) {
|
|
713
|
+
showToast(e.message, 'error');
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
init();
|
|
718
|
+
</script>
|
|
719
|
+
<script>
|
|
720
|
+
window.addEventListener("keydown", (e) => {
|
|
721
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
|
722
|
+
e.preventDefault();
|
|
723
|
+
window.parent.postMessage({ type: "keydown", ctrlK: true }, "*");
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
</script>
|
|
727
|
+
</body>
|
|
728
|
+
</html>
|