@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,2020 @@
|
|
|
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>Headless CMS - 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
|
+
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
|
14
|
+
</style>
|
|
15
|
+
</head>
|
|
16
|
+
<body class="bg-gray-100">
|
|
17
|
+
<div class="min-h-screen">
|
|
18
|
+
<div class="bg-white shadow">
|
|
19
|
+
<div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
20
|
+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
|
21
|
+
<div>
|
|
22
|
+
<h1 class="text-2xl font-bold text-gray-900">Headless CMS</h1>
|
|
23
|
+
<p class="text-sm text-gray-600 mt-1">Define tables (models), manage data (collections), and issue API tokens</p>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="flex items-center gap-2">
|
|
26
|
+
<button id="btn-refresh" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Refresh</button>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
33
|
+
<div class="bg-white rounded-lg shadow">
|
|
34
|
+
<div class="border-b border-gray-200">
|
|
35
|
+
<nav class="-mb-px flex gap-6 px-6" aria-label="Tabs">
|
|
36
|
+
<button data-tab="models" class="tab-btn border-transparent text-gray-600 hover:text-gray-800 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">Models</button>
|
|
37
|
+
<button data-tab="collections" class="tab-btn border-transparent text-gray-600 hover:text-gray-800 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">Collections</button>
|
|
38
|
+
<button data-tab="apis" class="tab-btn border-transparent text-gray-600 hover:text-gray-800 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">Collections APIs</button>
|
|
39
|
+
</nav>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div id="tab-models" class="p-6">
|
|
43
|
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
44
|
+
<div class="lg:col-span-1">
|
|
45
|
+
<div class="flex items-center justify-between mb-3">
|
|
46
|
+
<h2 class="text-lg font-semibold">Models</h2>
|
|
47
|
+
<button id="btn-new-model" class="bg-green-500 text-white px-3 py-2 rounded hover:bg-green-600 text-sm">New</button>
|
|
48
|
+
</div>
|
|
49
|
+
<div id="models-list" class="space-y-2">
|
|
50
|
+
<div class="text-gray-500 text-sm">Loading…</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div class="lg:col-span-2">
|
|
55
|
+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-4">
|
|
56
|
+
<div>
|
|
57
|
+
<div class="text-sm text-gray-500">Selected model</div>
|
|
58
|
+
<div id="selected-model-title" class="text-xl font-semibold">None</div>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="flex gap-2">
|
|
61
|
+
<button id="btn-save-model" class="bg-blue-600 text-white px-3 py-2 rounded hover:bg-blue-700 text-sm" disabled>Save schema</button>
|
|
62
|
+
<button id="btn-delete-model" class="bg-red-500 text-white px-3 py-2 rounded hover:bg-red-600 text-sm" disabled>Disable</button>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div class="bg-white border border-gray-200 rounded p-3 mb-4">
|
|
67
|
+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
|
68
|
+
<div class="text-sm text-gray-600">Mode</div>
|
|
69
|
+
<div class="flex gap-2">
|
|
70
|
+
<button id="btn-mode-simple" class="bg-gray-900 text-white px-3 py-2 rounded text-sm" disabled>Simple</button>
|
|
71
|
+
<button id="btn-mode-advanced" class="bg-white border px-3 py-2 rounded hover:bg-gray-50 text-sm" disabled>Advanced JSON</button>
|
|
72
|
+
<button id="btn-mode-ai" class="bg-white border px-3 py-2 rounded hover:bg-gray-50 text-sm" disabled>AI assist</button>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div class="grid grid-cols-1 gap-6">
|
|
78
|
+
<div id="panel-simple" class="bg-gray-50 border border-gray-200 rounded p-4">
|
|
79
|
+
<div class="flex items-center justify-between mb-3">
|
|
80
|
+
<h3 class="font-semibold">Schema</h3>
|
|
81
|
+
<button id="btn-add-field" class="bg-gray-900 text-white px-3 py-2 rounded hover:bg-black text-sm" disabled>Add field</button>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
85
|
+
<div>
|
|
86
|
+
<label class="block text-sm font-medium mb-1">Display name</label>
|
|
87
|
+
<input id="model-displayName" class="border rounded px-3 py-2 w-full" placeholder="e.g. Products" disabled />
|
|
88
|
+
</div>
|
|
89
|
+
<div>
|
|
90
|
+
<label class="block text-sm font-medium mb-1">Code identifier</label>
|
|
91
|
+
<input id="model-code" class="border rounded px-3 py-2 w-full mono" placeholder="e.g. products" disabled />
|
|
92
|
+
<div class="text-xs text-gray-500 mt-1">Stored as Mongo collection <span class="mono">headless_<code></span></div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div class="overflow-x-auto">
|
|
97
|
+
<table class="min-w-full text-sm">
|
|
98
|
+
<thead>
|
|
99
|
+
<tr class="text-left text-gray-500 border-b">
|
|
100
|
+
<th class="py-2 pr-3">Name</th>
|
|
101
|
+
<th class="py-2 pr-3">Type</th>
|
|
102
|
+
<th class="py-2 pr-3">Required</th>
|
|
103
|
+
<th class="py-2 pr-3">Unique</th>
|
|
104
|
+
<th class="py-2 pr-3">Default</th>
|
|
105
|
+
<th class="py-2 pr-3">Ref</th>
|
|
106
|
+
<th class="py-2 pr-3"></th>
|
|
107
|
+
</tr>
|
|
108
|
+
</thead>
|
|
109
|
+
<tbody id="fields-tbody">
|
|
110
|
+
<tr><td class="py-3 text-gray-500" colspan="7">Select a model</td></tr>
|
|
111
|
+
</tbody>
|
|
112
|
+
</table>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<div id="panel-advanced" class="bg-gray-50 border border-gray-200 rounded p-4 hidden">
|
|
117
|
+
<div class="flex items-center justify-between mb-3">
|
|
118
|
+
<h3 class="font-semibold">Advanced (JSON)</h3>
|
|
119
|
+
<div class="flex gap-2">
|
|
120
|
+
<button id="btn-advanced-load" class="bg-white border px-3 py-2 rounded hover:bg-gray-100 text-sm" disabled>Load selected</button>
|
|
121
|
+
<button id="btn-advanced-validate" class="bg-gray-900 text-white px-3 py-2 rounded hover:bg-black text-sm" disabled>Validate</button>
|
|
122
|
+
<button id="btn-advanced-save" class="bg-blue-600 text-white px-3 py-2 rounded hover:bg-blue-700 text-sm" disabled>Save</button>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
<div class="text-xs text-gray-600 mb-2">Paste a full model definition JSON. Server-owned fields are ignored with warnings.</div>
|
|
126
|
+
<textarea id="advanced-json" class="w-full h-72 border rounded px-3 py-2 mono text-xs" placeholder='{"codeIdentifier":"posts","displayName":"Posts","fields":[],"indexes":[]}'></textarea>
|
|
127
|
+
<div id="advanced-result" class="mt-3 hidden">
|
|
128
|
+
<div class="text-xs text-gray-600 mb-1">Validation</div>
|
|
129
|
+
<pre id="advanced-result-pre" class="mono text-xs bg-white border rounded p-3 overflow-x-auto"></pre>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<div id="panel-ai" class="bg-gray-50 border border-gray-200 rounded p-4 hidden">
|
|
134
|
+
<div class="flex items-center justify-between mb-3">
|
|
135
|
+
<div class="flex items-center gap-2">
|
|
136
|
+
<h3 class="font-semibold">AI assist</h3>
|
|
137
|
+
<span class="group relative inline-block">
|
|
138
|
+
<svg class="w-4 h-4 text-gray-400 cursor-help" fill="currentColor" viewBox="0 0 20 20">
|
|
139
|
+
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
|
140
|
+
</svg>
|
|
141
|
+
<div class="absolute left-1/2 transform -translate-x-1/2 bottom-full mb-2 hidden group-hover:block z-10 w-80 p-2 bg-gray-900 text-white text-xs rounded shadow-lg">
|
|
142
|
+
<div class="font-semibold mb-1">Customize AI provider & model</div>
|
|
143
|
+
<div>Set these GlobalSetting keys (fallback to env vars):</div>
|
|
144
|
+
<ul class="mt-1 space-y-1">
|
|
145
|
+
<li><code class="bg-gray-800 px-1 rounded">headless.aiProviderKey</code> (default: openrouter)</li>
|
|
146
|
+
<li><code class="bg-gray-800 px-1 rounded">headless.aiModel</code> (default: google/gemini-2.5-flash-lite)</li>
|
|
147
|
+
</ul>
|
|
148
|
+
<div class="mt-1 text-gray-300">Configure providers in /admin/llm.</div>
|
|
149
|
+
<div class="absolute left-1/2 transform -translate-x-1/2 top-full mt-1 w-2 h-2 bg-gray-900 rotate-45"></div>
|
|
150
|
+
</div>
|
|
151
|
+
</span>
|
|
152
|
+
</div>
|
|
153
|
+
<div class="flex gap-2">
|
|
154
|
+
<button id="btn-ai-clear" class="bg-white border px-3 py-2 rounded hover:bg-gray-100 text-sm" disabled>Clear history</button>
|
|
155
|
+
<button id="btn-ai-apply" class="bg-blue-600 text-white px-3 py-2 rounded hover:bg-blue-700 text-sm" disabled>Apply proposal</button>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
159
|
+
<div>
|
|
160
|
+
<div class="text-xs text-gray-600 mb-2">Chat</div>
|
|
161
|
+
<div id="ai-history" class="bg-white border rounded p-3 h-64 overflow-y-auto text-xs"></div>
|
|
162
|
+
<div class="mt-2 flex gap-2">
|
|
163
|
+
<input id="ai-input" class="border rounded px-3 py-2 w-full" placeholder="Describe the models you want, then refine with follow-ups" disabled />
|
|
164
|
+
<button id="btn-ai-send" class="bg-gray-900 text-white px-3 py-2 rounded hover:bg-black text-sm" disabled>Send</button>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
<div>
|
|
168
|
+
<div class="text-xs text-gray-600 mb-2">Latest proposal</div>
|
|
169
|
+
<pre id="ai-proposal" class="mono text-xs bg-white border rounded p-3 h-64 overflow-x-auto overflow-y-auto"></pre>
|
|
170
|
+
<div class="text-xs text-gray-500 mt-2">Use “Apply proposal” to execute best-effort creates/updates.</div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<div id="tab-collections" class="p-6 hidden">
|
|
182
|
+
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
183
|
+
<div class="lg:col-span-1">
|
|
184
|
+
<div class="flex items-center justify-between mb-3">
|
|
185
|
+
<h2 class="text-lg font-semibold">Collections</h2>
|
|
186
|
+
<button id="btn-data-refresh-models" class="bg-white border px-3 py-2 rounded hover:bg-gray-100 text-sm">Refresh</button>
|
|
187
|
+
</div>
|
|
188
|
+
<div id="data-models-list" class="space-y-2">
|
|
189
|
+
<div class="text-gray-500 text-sm">Loading…</div>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div class="lg:col-span-3">
|
|
194
|
+
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-4 mb-4">
|
|
195
|
+
<div>
|
|
196
|
+
<div class="text-sm text-gray-500">Selected collection</div>
|
|
197
|
+
<div id="data-selected-title" class="text-xl font-semibold">None</div>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div class="flex flex-col md:flex-row gap-2 md:items-end">
|
|
201
|
+
<div>
|
|
202
|
+
<label class="block text-xs text-gray-500 mb-1">Filter (Mongo JSON)</label>
|
|
203
|
+
<input id="data-filter" class="border rounded px-3 py-2 w-80 mono" placeholder='{"field":"value"}' />
|
|
204
|
+
</div>
|
|
205
|
+
<div class="flex gap-2">
|
|
206
|
+
<button id="btn-data-apply" class="bg-blue-600 text-white px-3 py-2 rounded hover:bg-blue-700 text-sm" disabled>Apply</button>
|
|
207
|
+
<button id="btn-data-clear" class="bg-white border px-3 py-2 rounded hover:bg-gray-100 text-sm" disabled>Clear</button>
|
|
208
|
+
<button id="btn-data-new-row" class="bg-green-500 text-white px-3 py-2 rounded hover:bg-green-600 text-sm" disabled>New row</button>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<div class="flex items-center justify-between mb-3">
|
|
214
|
+
<div class="text-sm text-gray-600" id="data-meta"> </div>
|
|
215
|
+
<div class="flex items-center gap-2">
|
|
216
|
+
<button id="btn-page-prev" class="bg-white border px-3 py-2 rounded hover:bg-gray-100 text-sm" disabled>Prev</button>
|
|
217
|
+
<button id="btn-page-next" class="bg-white border px-3 py-2 rounded hover:bg-gray-100 text-sm" disabled>Next</button>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<div id="data-table" class="overflow-x-auto text-sm text-gray-600 bg-gray-50 border border-gray-200 rounded p-4">
|
|
222
|
+
<div class="py-6 text-center text-gray-500">Select a collection</div>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<div id="tab-apis" class="p-6 hidden">
|
|
229
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
230
|
+
<div class="bg-gray-50 border border-gray-200 rounded p-4">
|
|
231
|
+
<div class="flex items-center justify-between mb-3">
|
|
232
|
+
<h2 class="text-lg font-semibold">API tokens</h2>
|
|
233
|
+
<button id="btn-new-token" class="bg-green-500 text-white px-3 py-2 rounded hover:bg-green-600 text-sm">New</button>
|
|
234
|
+
</div>
|
|
235
|
+
<div id="tokens-list" class="space-y-2">
|
|
236
|
+
<div class="text-gray-500 text-sm">Loading…</div>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<div class="bg-gray-50 border border-gray-200 rounded p-4">
|
|
241
|
+
<div class="flex items-center justify-between mb-3">
|
|
242
|
+
<h2 class="text-lg font-semibold">cURL examples</h2>
|
|
243
|
+
<div class="text-xs text-gray-500" id="curl-selected-token"></div>
|
|
244
|
+
</div>
|
|
245
|
+
<div class="text-sm text-gray-600 mb-4">Use <span class="mono">Authorization: Bearer</span> or <span class="mono">X-API-Token</span>:</div>
|
|
246
|
+
<div class="space-y-4" id="curl-blocks">
|
|
247
|
+
<!-- List -->
|
|
248
|
+
<div class="bg-white border rounded p-3">
|
|
249
|
+
<div class="flex items-center justify-between mb-2">
|
|
250
|
+
<div class="font-medium text-sm">List</div>
|
|
251
|
+
<button type="button" class="btn-copy-curl text-gray-500 hover:text-gray-800" data-op="list" title="Copy command">
|
|
252
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
253
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
254
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
255
|
+
</svg>
|
|
256
|
+
</button>
|
|
257
|
+
</div>
|
|
258
|
+
<pre class="mono text-xs overflow-x-auto" id="curl-list"></pre>
|
|
259
|
+
</div>
|
|
260
|
+
<!-- Create -->
|
|
261
|
+
<div class="bg-white border rounded p-3">
|
|
262
|
+
<div class="flex items-center justify-between mb-2">
|
|
263
|
+
<div class="font-medium text-sm">Create</div>
|
|
264
|
+
<button type="button" class="btn-copy-curl text-gray-500 hover:text-gray-800" data-op="create" title="Copy command">
|
|
265
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
266
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
267
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
268
|
+
</svg>
|
|
269
|
+
</button>
|
|
270
|
+
</div>
|
|
271
|
+
<pre class="mono text-xs overflow-x-auto" id="curl-create"></pre>
|
|
272
|
+
</div>
|
|
273
|
+
<!-- Update -->
|
|
274
|
+
<div class="bg-white border rounded p-3">
|
|
275
|
+
<div class="flex items-center justify-between mb-2">
|
|
276
|
+
<div class="font-medium text-sm">Update</div>
|
|
277
|
+
<button type="button" class="btn-copy-curl text-gray-500 hover:text-gray-800" data-op="update" title="Copy command">
|
|
278
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
279
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
280
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
281
|
+
</svg>
|
|
282
|
+
</button>
|
|
283
|
+
</div>
|
|
284
|
+
<pre class="mono text-xs overflow-x-auto" id="curl-update"></pre>
|
|
285
|
+
</div>
|
|
286
|
+
<!-- Delete -->
|
|
287
|
+
<div class="bg-white border rounded p-3">
|
|
288
|
+
<div class="flex items-center justify-between mb-2">
|
|
289
|
+
<div class="font-medium text-sm">Delete</div>
|
|
290
|
+
<button type="button" class="btn-copy-curl text-gray-500 hover:text-gray-800" data-op="delete" title="Copy command">
|
|
291
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
292
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
293
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
294
|
+
</svg>
|
|
295
|
+
</button>
|
|
296
|
+
</div>
|
|
297
|
+
<pre class="mono text-xs overflow-x-auto" id="curl-delete"></pre>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
<div class="mt-6 bg-white border border-gray-200 rounded p-4">
|
|
304
|
+
<div class="flex items-center justify-between mb-2">
|
|
305
|
+
<h3 class="font-semibold">Test request</h3>
|
|
306
|
+
<div class="text-xs text-gray-500">Executes real <span class="mono">/api/headless/*</span> endpoints via admin proxy and logs an audit event</div>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
310
|
+
<div>
|
|
311
|
+
<label class="block text-sm font-medium mb-1">Operation</label>
|
|
312
|
+
<select id="api-test-op" class="border rounded px-3 py-2 w-full">
|
|
313
|
+
<option value="list">List</option>
|
|
314
|
+
<option value="create">Create</option>
|
|
315
|
+
<option value="update">Update</option>
|
|
316
|
+
<option value="delete">Delete</option>
|
|
317
|
+
</select>
|
|
318
|
+
</div>
|
|
319
|
+
<div>
|
|
320
|
+
<label class="block text-sm font-medium mb-1">Model</label>
|
|
321
|
+
<select id="api-test-model" class="border rounded px-3 py-2 w-full"></select>
|
|
322
|
+
<div class="text-xs text-gray-500 mt-1">Uses the selected collection model</div>
|
|
323
|
+
</div>
|
|
324
|
+
<div>
|
|
325
|
+
<label class="block text-sm font-medium mb-1">API token</label>
|
|
326
|
+
<input id="api-test-token" class="border rounded px-3 py-2 w-full mono" placeholder="paste API token" />
|
|
327
|
+
<div class="text-xs text-gray-500 mt-1">Not stored server-side. Cached token can be used if available.</div>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
<div class="mt-4 grid grid-cols-1 md:grid-cols-3 gap-4" id="api-test-pathvars">
|
|
332
|
+
<div>
|
|
333
|
+
<label class="block text-sm font-medium mb-1">ID (for update/delete)</label>
|
|
334
|
+
<input id="api-test-id" class="border rounded px-3 py-2 w-full mono" placeholder="row id" />
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
<div class="mt-4">
|
|
339
|
+
<div class="font-medium text-sm mb-2">Query parameters</div>
|
|
340
|
+
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
|
341
|
+
<div>
|
|
342
|
+
<label class="block text-xs font-medium mb-1">limit</label>
|
|
343
|
+
<input id="api-test-limit" type="number" class="border rounded px-3 py-2 w-full" placeholder="10" />
|
|
344
|
+
</div>
|
|
345
|
+
<div>
|
|
346
|
+
<label class="block text-xs font-medium mb-1">skip</label>
|
|
347
|
+
<input id="api-test-skip" type="number" class="border rounded px-3 py-2 w-full" placeholder="0" />
|
|
348
|
+
</div>
|
|
349
|
+
<div class="md:col-span-2">
|
|
350
|
+
<label class="block text-xs font-medium mb-1">populate</label>
|
|
351
|
+
<input id="api-test-populate" class="border rounded px-3 py-2 w-full" placeholder="field1,field2" />
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
|
|
355
|
+
<div class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
356
|
+
<div>
|
|
357
|
+
<div class="flex items-center justify-between mb-1">
|
|
358
|
+
<label class="block text-xs font-medium">filter (builder)</label>
|
|
359
|
+
<button id="btn-filter-add" class="text-xs text-blue-600 hover:underline" type="button">Add</button>
|
|
360
|
+
</div>
|
|
361
|
+
<div id="api-test-filter-rows" class="space-y-2"></div>
|
|
362
|
+
<div class="text-xs text-gray-500 mt-1">Produces JSON object used as <span class="mono">filter</span> query param.</div>
|
|
363
|
+
</div>
|
|
364
|
+
<div>
|
|
365
|
+
<div class="flex items-center justify-between mb-1">
|
|
366
|
+
<label class="block text-xs font-medium">sort (builder)</label>
|
|
367
|
+
<button id="btn-sort-add" class="text-xs text-blue-600 hover:underline" type="button">Add</button>
|
|
368
|
+
</div>
|
|
369
|
+
<div id="api-test-sort-rows" class="space-y-2"></div>
|
|
370
|
+
<div class="text-xs text-gray-500 mt-1">Values should be <span class="mono">1</span> or <span class="mono">-1</span>.</div>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
<div class="mt-4">
|
|
376
|
+
<div class="flex items-center justify-between mb-2">
|
|
377
|
+
<div class="font-medium text-sm">Body payload</div>
|
|
378
|
+
<div class="text-xs text-gray-500">Generated from selected model fields</div>
|
|
379
|
+
</div>
|
|
380
|
+
<div id="api-test-body-fields" class="grid grid-cols-1 md:grid-cols-2 gap-3"></div>
|
|
381
|
+
<div class="mt-2 text-xs text-gray-500" id="api-test-body-help"></div>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
<div class="mt-4 flex gap-2">
|
|
385
|
+
<button id="btn-api-test-execute" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700" type="button">Execute</button>
|
|
386
|
+
<button id="btn-api-test-clear" class="bg-gray-200 text-gray-900 px-4 py-2 rounded hover:bg-gray-300" type="button">Clear</button>
|
|
387
|
+
</div>
|
|
388
|
+
|
|
389
|
+
<div id="api-test-result" class="mt-4 hidden">
|
|
390
|
+
<div class="text-sm text-gray-600 mb-1">Result</div>
|
|
391
|
+
<div class="border rounded p-3 bg-gray-50">
|
|
392
|
+
<div class="text-xs text-gray-600 mb-2" id="api-test-result-meta"></div>
|
|
393
|
+
<pre class="mono text-xs overflow-x-auto" id="api-test-result-body"></pre>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
|
|
398
|
+
<div class="mt-6 bg-white border border-gray-200 rounded p-4">
|
|
399
|
+
<h3 class="font-semibold mb-2">Token permissions</h3>
|
|
400
|
+
<div class="text-sm text-gray-600 mb-4">Each token has per-table permissions: create/read/update/delete.</div>
|
|
401
|
+
|
|
402
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
403
|
+
<div>
|
|
404
|
+
<label class="block text-sm font-medium mb-1">Name</label>
|
|
405
|
+
<input id="token-name" class="border rounded px-3 py-2 w-full" placeholder="e.g. Website" />
|
|
406
|
+
</div>
|
|
407
|
+
<div>
|
|
408
|
+
<label class="block text-sm font-medium mb-1">TTL (seconds, optional)</label>
|
|
409
|
+
<input id="token-ttl" type="number" class="border rounded px-3 py-2 w-full" placeholder="e.g. 3600" />
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
|
|
413
|
+
<div class="mt-4">
|
|
414
|
+
<div class="flex items-center justify-between mb-2">
|
|
415
|
+
<div class="text-sm font-medium">Permissions</div>
|
|
416
|
+
<button id="btn-add-permission" class="bg-gray-900 text-white px-3 py-2 rounded hover:bg-black text-sm">Add</button>
|
|
417
|
+
</div>
|
|
418
|
+
<div id="permissions-list" class="space-y-2"></div>
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
<div class="mt-4 flex gap-2">
|
|
422
|
+
<button id="btn-create-token" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">Create token</button>
|
|
423
|
+
</div>
|
|
424
|
+
|
|
425
|
+
<div id="created-token" class="mt-4 hidden">
|
|
426
|
+
<div class="text-sm text-gray-600 mb-1">Created token (copy now):</div>
|
|
427
|
+
<div class="mono bg-white border rounded p-3 text-sm" id="created-token-value"></div>
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
<div id="toast-container" class="fixed top-4 right-4 space-y-2 z-50"></div>
|
|
436
|
+
</div>
|
|
437
|
+
|
|
438
|
+
<script>
|
|
439
|
+
const baseUrl = '<%= baseUrl %>';
|
|
440
|
+
const adminPath = '<%= adminPath %>';
|
|
441
|
+
|
|
442
|
+
const TOKEN_CACHE_KEY = 'headlessCmsApiTokenCache';
|
|
443
|
+
|
|
444
|
+
let state = {
|
|
445
|
+
activeTab: 'models',
|
|
446
|
+
models: [],
|
|
447
|
+
selectedModel: null,
|
|
448
|
+
modelUi: {
|
|
449
|
+
mode: 'simple',
|
|
450
|
+
advancedJson: '',
|
|
451
|
+
advancedValidation: null,
|
|
452
|
+
aiHistory: [],
|
|
453
|
+
aiLastProposal: null,
|
|
454
|
+
},
|
|
455
|
+
data: { items: [], total: 0 },
|
|
456
|
+
dataUi: {
|
|
457
|
+
selectedModelCode: null,
|
|
458
|
+
limit: 25,
|
|
459
|
+
skip: 0,
|
|
460
|
+
filterRaw: '',
|
|
461
|
+
},
|
|
462
|
+
apiUi: {
|
|
463
|
+
selectedTokenId: null,
|
|
464
|
+
selectedTokenValue: null,
|
|
465
|
+
},
|
|
466
|
+
tokens: [],
|
|
467
|
+
permissionsDraft: [],
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
function toast(message, type = 'info') {
|
|
471
|
+
const container = document.getElementById('toast-container');
|
|
472
|
+
const el = document.createElement('div');
|
|
473
|
+
const color = type === 'error' ? 'bg-red-600' : type === 'success' ? 'bg-green-600' : 'bg-gray-900';
|
|
474
|
+
el.className = `toast ${color} text-white px-4 py-2 rounded shadow`;
|
|
475
|
+
el.textContent = message;
|
|
476
|
+
container.appendChild(el);
|
|
477
|
+
setTimeout(() => {
|
|
478
|
+
el.classList.add('fade-out');
|
|
479
|
+
setTimeout(() => el.remove(), 350);
|
|
480
|
+
}, 2500);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function advancedLoadSelected() {
|
|
484
|
+
const m = state.selectedModel;
|
|
485
|
+
if (!m) return;
|
|
486
|
+
const def = {
|
|
487
|
+
codeIdentifier: m.codeIdentifier,
|
|
488
|
+
displayName: m.displayName,
|
|
489
|
+
description: m.description || '',
|
|
490
|
+
fields: m.fields || [],
|
|
491
|
+
indexes: m.indexes || [],
|
|
492
|
+
};
|
|
493
|
+
const text = JSON.stringify(def, null, 2);
|
|
494
|
+
state.modelUi.advancedJson = text;
|
|
495
|
+
document.getElementById('advanced-json').value = text;
|
|
496
|
+
state.modelUi.advancedValidation = null;
|
|
497
|
+
renderAdvancedValidation();
|
|
498
|
+
toast('Loaded selected model into JSON editor', 'success');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function advancedValidate() {
|
|
502
|
+
try {
|
|
503
|
+
const raw = document.getElementById('advanced-json').value;
|
|
504
|
+
state.modelUi.advancedJson = raw;
|
|
505
|
+
const definition = safeJsonParse(raw);
|
|
506
|
+
if (!definition) {
|
|
507
|
+
toast('Invalid JSON', 'error');
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const result = await api('/api/admin/headless/models/validate', {
|
|
511
|
+
method: 'POST',
|
|
512
|
+
body: JSON.stringify({ definition }),
|
|
513
|
+
});
|
|
514
|
+
state.modelUi.advancedValidation = result;
|
|
515
|
+
renderAdvancedValidation();
|
|
516
|
+
toast(result.valid ? 'Valid' : 'Invalid', result.valid ? 'success' : 'error');
|
|
517
|
+
return result;
|
|
518
|
+
} catch (e) {
|
|
519
|
+
toast(e.message, 'error');
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function advancedSave() {
|
|
524
|
+
try {
|
|
525
|
+
const v = await advancedValidate();
|
|
526
|
+
if (!v || !v.valid) return;
|
|
527
|
+
const normalized = v.normalized;
|
|
528
|
+
if (!normalized || !normalized.codeIdentifier) {
|
|
529
|
+
toast('Missing codeIdentifier', 'error');
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const exists = (state.models || []).some((m) => m.codeIdentifier === normalized.codeIdentifier);
|
|
534
|
+
|
|
535
|
+
if (exists) {
|
|
536
|
+
await api(`/api/admin/headless/models/${encodeURIComponent(normalized.codeIdentifier)}`, {
|
|
537
|
+
method: 'PUT',
|
|
538
|
+
body: JSON.stringify(normalized),
|
|
539
|
+
});
|
|
540
|
+
toast('Model updated', 'success');
|
|
541
|
+
} else {
|
|
542
|
+
await api('/api/admin/headless/models', {
|
|
543
|
+
method: 'POST',
|
|
544
|
+
body: JSON.stringify(normalized),
|
|
545
|
+
});
|
|
546
|
+
toast('Model created', 'success');
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
await loadModels();
|
|
550
|
+
await selectModel(normalized.codeIdentifier);
|
|
551
|
+
setModelMode('simple');
|
|
552
|
+
} catch (e) {
|
|
553
|
+
toast(e.message, 'error');
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function aiSend() {
|
|
558
|
+
try {
|
|
559
|
+
const input = document.getElementById('ai-input');
|
|
560
|
+
const msg = String(input.value || '').trim();
|
|
561
|
+
if (!msg) return;
|
|
562
|
+
|
|
563
|
+
state.modelUi.aiHistory = Array.isArray(state.modelUi.aiHistory) ? state.modelUi.aiHistory : [];
|
|
564
|
+
state.modelUi.aiHistory.push({ role: 'user', content: msg });
|
|
565
|
+
input.value = '';
|
|
566
|
+
renderAiHistory();
|
|
567
|
+
|
|
568
|
+
const currentJson = safeJsonParse(document.getElementById('advanced-json').value);
|
|
569
|
+
const currentDefinition = currentJson || (state.selectedModel ? {
|
|
570
|
+
codeIdentifier: state.selectedModel.codeIdentifier,
|
|
571
|
+
displayName: state.selectedModel.displayName,
|
|
572
|
+
description: state.selectedModel.description || '',
|
|
573
|
+
fields: state.selectedModel.fields || [],
|
|
574
|
+
indexes: state.selectedModel.indexes || [],
|
|
575
|
+
} : null);
|
|
576
|
+
|
|
577
|
+
const res = await api('/api/admin/headless/ai/model-builder/chat', {
|
|
578
|
+
method: 'POST',
|
|
579
|
+
body: JSON.stringify({
|
|
580
|
+
message: msg,
|
|
581
|
+
history: state.modelUi.aiHistory,
|
|
582
|
+
currentDefinition,
|
|
583
|
+
}),
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
const assistantMessage = res.assistantMessage || '';
|
|
587
|
+
state.modelUi.aiHistory.push({ role: 'assistant', content: assistantMessage });
|
|
588
|
+
renderAiHistory();
|
|
589
|
+
|
|
590
|
+
state.modelUi.aiLastProposal = res.proposal || null;
|
|
591
|
+
renderAiProposal();
|
|
592
|
+
|
|
593
|
+
if (res.validation && res.validation.valid === false) {
|
|
594
|
+
toast('Proposal has validation errors', 'error');
|
|
595
|
+
} else {
|
|
596
|
+
toast('Proposal ready', 'success');
|
|
597
|
+
}
|
|
598
|
+
} catch (e) {
|
|
599
|
+
toast(e.message, 'error');
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function aiClear() {
|
|
604
|
+
state.modelUi.aiHistory = [];
|
|
605
|
+
state.modelUi.aiLastProposal = null;
|
|
606
|
+
renderAiHistory();
|
|
607
|
+
renderAiProposal();
|
|
608
|
+
toast('History cleared', 'success');
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function aiApply() {
|
|
612
|
+
try {
|
|
613
|
+
const proposal = state.modelUi.aiLastProposal;
|
|
614
|
+
if (!proposal) {
|
|
615
|
+
toast('No proposal', 'error');
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const res = await api('/api/admin/headless/models/apply', {
|
|
619
|
+
method: 'POST',
|
|
620
|
+
body: JSON.stringify({
|
|
621
|
+
creates: proposal.creates || [],
|
|
622
|
+
updates: proposal.updates || [],
|
|
623
|
+
}),
|
|
624
|
+
});
|
|
625
|
+
if (res.errors && res.errors.length) {
|
|
626
|
+
toast(`Applied with errors (${res.errors.length})`, 'error');
|
|
627
|
+
} else {
|
|
628
|
+
toast('Applied', 'success');
|
|
629
|
+
}
|
|
630
|
+
await loadModels();
|
|
631
|
+
} catch (e) {
|
|
632
|
+
toast(e.message, 'error');
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function loadTokenCache() {
|
|
637
|
+
try {
|
|
638
|
+
const raw = localStorage.getItem(TOKEN_CACHE_KEY);
|
|
639
|
+
const parsed = raw ? JSON.parse(raw) : {};
|
|
640
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
641
|
+
} catch {
|
|
642
|
+
return {};
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function saveTokenCache(cache) {
|
|
647
|
+
try {
|
|
648
|
+
localStorage.setItem(TOKEN_CACHE_KEY, JSON.stringify(cache || {}));
|
|
649
|
+
} catch {
|
|
650
|
+
// ignore
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function cacheTokenValue(tokenId, tokenValue) {
|
|
655
|
+
if (!tokenId || !tokenValue) return;
|
|
656
|
+
const cache = loadTokenCache();
|
|
657
|
+
cache[String(tokenId)] = String(tokenValue);
|
|
658
|
+
saveTokenCache(cache);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function getCachedTokenValue(tokenId) {
|
|
662
|
+
const cache = loadTokenCache();
|
|
663
|
+
return cache[String(tokenId)] || null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function copyToClipboard(text) {
|
|
667
|
+
const value = String(text || '');
|
|
668
|
+
if (!value) return false;
|
|
669
|
+
try {
|
|
670
|
+
await navigator.clipboard.writeText(value);
|
|
671
|
+
return true;
|
|
672
|
+
} catch {
|
|
673
|
+
try {
|
|
674
|
+
const ta = document.createElement('textarea');
|
|
675
|
+
ta.value = value;
|
|
676
|
+
document.body.appendChild(ta);
|
|
677
|
+
ta.select();
|
|
678
|
+
document.execCommand('copy');
|
|
679
|
+
ta.remove();
|
|
680
|
+
return true;
|
|
681
|
+
} catch {
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function renderDataModelsList() {
|
|
688
|
+
const list = document.getElementById('data-models-list');
|
|
689
|
+
if (!list) return;
|
|
690
|
+
list.innerHTML = '';
|
|
691
|
+
|
|
692
|
+
if (!state.models.length) {
|
|
693
|
+
list.innerHTML = '<div class="text-gray-500 text-sm">No collections yet</div>';
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
state.models.forEach((m) => {
|
|
698
|
+
const btn = document.createElement('button');
|
|
699
|
+
const active = state.dataUi.selectedModelCode === m.codeIdentifier;
|
|
700
|
+
btn.className = `w-full text-left px-3 py-2 rounded border ${active ? 'bg-blue-50 border-blue-200' : 'bg-white border-gray-200 hover:bg-gray-50'}`;
|
|
701
|
+
btn.innerHTML = `<div class="font-medium">${m.displayName}</div><div class="text-xs text-gray-500 mono">${m.codeIdentifier}</div>`;
|
|
702
|
+
btn.addEventListener('click', () => selectDataModel(m.codeIdentifier));
|
|
703
|
+
list.appendChild(btn);
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function setTab(tab) {
|
|
708
|
+
state.activeTab = tab;
|
|
709
|
+
document.getElementById('tab-models').classList.toggle('hidden', tab !== 'models');
|
|
710
|
+
document.getElementById('tab-collections').classList.toggle('hidden', tab !== 'collections');
|
|
711
|
+
document.getElementById('tab-apis').classList.toggle('hidden', tab !== 'apis');
|
|
712
|
+
|
|
713
|
+
document.querySelectorAll('.tab-btn').forEach((b) => {
|
|
714
|
+
const isActive = b.dataset.tab === tab;
|
|
715
|
+
b.classList.toggle('border-blue-600', isActive);
|
|
716
|
+
b.classList.toggle('text-blue-700', isActive);
|
|
717
|
+
b.classList.toggle('border-transparent', !isActive);
|
|
718
|
+
b.classList.toggle('text-gray-600', !isActive);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
if (tab === 'apis') {
|
|
722
|
+
renderCurlExamples();
|
|
723
|
+
renderSelectedTokenLabel();
|
|
724
|
+
loadTokens();
|
|
725
|
+
loadModels();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (tab === 'collections') {
|
|
729
|
+
loadModels();
|
|
730
|
+
renderDataModelsList();
|
|
731
|
+
renderDataSelectedHeader();
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
document.querySelectorAll('.tab-btn').forEach((b) => b.addEventListener('click', () => setTab(b.dataset.tab)));
|
|
736
|
+
|
|
737
|
+
async function api(path, opts = {}) {
|
|
738
|
+
const res = await fetch(baseUrl + path, {
|
|
739
|
+
headers: { 'Content-Type': 'application/json' },
|
|
740
|
+
...opts,
|
|
741
|
+
});
|
|
742
|
+
const json = await res.json().catch(() => ({}));
|
|
743
|
+
if (!res.ok) {
|
|
744
|
+
throw new Error(json.error || `Request failed (${res.status})`);
|
|
745
|
+
}
|
|
746
|
+
return json;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function renderModelsList() {
|
|
750
|
+
const list = document.getElementById('models-list');
|
|
751
|
+
list.innerHTML = '';
|
|
752
|
+
|
|
753
|
+
if (!state.models.length) {
|
|
754
|
+
list.innerHTML = '<div class="text-gray-500 text-sm">No models yet</div>';
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
state.models.forEach((m) => {
|
|
759
|
+
const btn = document.createElement('button');
|
|
760
|
+
const active = state.selectedModel && state.selectedModel.codeIdentifier === m.codeIdentifier;
|
|
761
|
+
btn.className = `w-full text-left px-3 py-2 rounded border ${active ? 'bg-blue-50 border-blue-200' : 'bg-white border-gray-200 hover:bg-gray-50'}`;
|
|
762
|
+
btn.innerHTML = `<div class="font-medium">${m.displayName}</div><div class="text-xs text-gray-500 mono">${m.codeIdentifier}</div>`;
|
|
763
|
+
btn.addEventListener('click', () => selectModel(m.codeIdentifier));
|
|
764
|
+
list.appendChild(btn);
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function setModelFormEnabled(enabled, isNew = false) {
|
|
769
|
+
document.getElementById('model-displayName').disabled = !enabled;
|
|
770
|
+
document.getElementById('model-code').disabled = !enabled || !isNew;
|
|
771
|
+
document.getElementById('btn-add-field').disabled = !enabled;
|
|
772
|
+
document.getElementById('btn-save-model').disabled = !enabled;
|
|
773
|
+
document.getElementById('btn-delete-model').disabled = !enabled || isNew;
|
|
774
|
+
document.getElementById('btn-mode-simple').disabled = !enabled;
|
|
775
|
+
document.getElementById('btn-mode-advanced').disabled = !enabled;
|
|
776
|
+
document.getElementById('btn-mode-ai').disabled = !enabled;
|
|
777
|
+
|
|
778
|
+
document.getElementById('btn-advanced-load').disabled = !enabled;
|
|
779
|
+
document.getElementById('btn-advanced-validate').disabled = !enabled;
|
|
780
|
+
document.getElementById('btn-advanced-save').disabled = !enabled;
|
|
781
|
+
|
|
782
|
+
document.getElementById('ai-input').disabled = !enabled;
|
|
783
|
+
document.getElementById('btn-ai-send').disabled = !enabled;
|
|
784
|
+
document.getElementById('btn-ai-clear').disabled = !enabled;
|
|
785
|
+
document.getElementById('btn-ai-apply').disabled = !enabled;
|
|
786
|
+
// Data tab controls are managed separately
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function setModelMode(mode) {
|
|
790
|
+
state.modelUi.mode = mode;
|
|
791
|
+
document.getElementById('panel-simple').classList.toggle('hidden', mode !== 'simple');
|
|
792
|
+
document.getElementById('panel-advanced').classList.toggle('hidden', mode !== 'advanced');
|
|
793
|
+
document.getElementById('panel-ai').classList.toggle('hidden', mode !== 'ai');
|
|
794
|
+
|
|
795
|
+
const btnSimple = document.getElementById('btn-mode-simple');
|
|
796
|
+
const btnAdv = document.getElementById('btn-mode-advanced');
|
|
797
|
+
const btnAi = document.getElementById('btn-mode-ai');
|
|
798
|
+
|
|
799
|
+
btnSimple.className = mode === 'simple'
|
|
800
|
+
? 'bg-gray-900 text-white px-3 py-2 rounded text-sm'
|
|
801
|
+
: 'bg-white border px-3 py-2 rounded hover:bg-gray-50 text-sm';
|
|
802
|
+
btnAdv.className = mode === 'advanced'
|
|
803
|
+
? 'bg-gray-900 text-white px-3 py-2 rounded text-sm'
|
|
804
|
+
: 'bg-white border px-3 py-2 rounded hover:bg-gray-50 text-sm';
|
|
805
|
+
btnAi.className = mode === 'ai'
|
|
806
|
+
? 'bg-gray-900 text-white px-3 py-2 rounded text-sm'
|
|
807
|
+
: 'bg-white border px-3 py-2 rounded hover:bg-gray-50 text-sm';
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function safeJsonParse(text) {
|
|
811
|
+
try {
|
|
812
|
+
return JSON.parse(String(text || ''));
|
|
813
|
+
} catch {
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function renderAdvancedValidation() {
|
|
819
|
+
const container = document.getElementById('advanced-result');
|
|
820
|
+
const pre = document.getElementById('advanced-result-pre');
|
|
821
|
+
const v = state.modelUi.advancedValidation;
|
|
822
|
+
if (!v) {
|
|
823
|
+
container.classList.add('hidden');
|
|
824
|
+
pre.textContent = '';
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
container.classList.remove('hidden');
|
|
828
|
+
pre.textContent = JSON.stringify(v, null, 2);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function renderAiHistory() {
|
|
832
|
+
const container = document.getElementById('ai-history');
|
|
833
|
+
const history = Array.isArray(state.modelUi.aiHistory) ? state.modelUi.aiHistory : [];
|
|
834
|
+
if (!history.length) {
|
|
835
|
+
container.innerHTML = '<div class="text-gray-500">No messages yet</div>';
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
container.innerHTML = '';
|
|
839
|
+
history.forEach((m) => {
|
|
840
|
+
const role = m.role === 'assistant' ? 'assistant' : 'user';
|
|
841
|
+
const el = document.createElement('div');
|
|
842
|
+
el.className = 'mb-2';
|
|
843
|
+
el.innerHTML = `<div class="text-[10px] uppercase text-gray-500 mb-1">${role}</div><div class="whitespace-pre-wrap">${String(m.content || '').replace(/</g,'<')}</div>`;
|
|
844
|
+
container.appendChild(el);
|
|
845
|
+
});
|
|
846
|
+
container.scrollTop = container.scrollHeight;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function renderAiProposal() {
|
|
850
|
+
const pre = document.getElementById('ai-proposal');
|
|
851
|
+
const proposal = state.modelUi.aiLastProposal;
|
|
852
|
+
pre.textContent = proposal ? JSON.stringify(proposal, null, 2) : '';
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function renderSelectedModel() {
|
|
856
|
+
const title = document.getElementById('selected-model-title');
|
|
857
|
+
const codeInput = document.getElementById('model-code');
|
|
858
|
+
const nameInput = document.getElementById('model-displayName');
|
|
859
|
+
|
|
860
|
+
const m = state.selectedModel;
|
|
861
|
+
if (!m) {
|
|
862
|
+
title.textContent = 'None';
|
|
863
|
+
codeInput.value = '';
|
|
864
|
+
nameInput.value = '';
|
|
865
|
+
setModelFormEnabled(false);
|
|
866
|
+
renderFieldsTable();
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
title.textContent = m.displayName;
|
|
871
|
+
codeInput.value = m.codeIdentifier;
|
|
872
|
+
nameInput.value = m.displayName;
|
|
873
|
+
setModelFormEnabled(true, !!m.__isNew);
|
|
874
|
+
renderFieldsTable();
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function renderFieldsTable() {
|
|
878
|
+
const tbody = document.getElementById('fields-tbody');
|
|
879
|
+
const m = state.selectedModel;
|
|
880
|
+
|
|
881
|
+
if (!m) {
|
|
882
|
+
tbody.innerHTML = '<tr><td class="py-3 text-gray-500" colspan="7">Select a table</td></tr>';
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const fields = Array.isArray(m.fields) ? m.fields : [];
|
|
887
|
+
if (!fields.length) {
|
|
888
|
+
tbody.innerHTML = '<tr><td class="py-3 text-gray-500" colspan="7">No fields yet</td></tr>';
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
tbody.innerHTML = '';
|
|
893
|
+
|
|
894
|
+
fields.forEach((f, idx) => {
|
|
895
|
+
const tr = document.createElement('tr');
|
|
896
|
+
tr.className = 'border-t';
|
|
897
|
+
|
|
898
|
+
const typeOptions = ['string','number','boolean','date','object','array','ref','ref[]'];
|
|
899
|
+
const opts = typeOptions.map((t) => `<option value="${t}" ${String(f.type).toLowerCase()===t?'selected':''}>${t}</option>`).join('');
|
|
900
|
+
|
|
901
|
+
tr.innerHTML = `
|
|
902
|
+
<td class="py-2 pr-3"><input class="field-name border rounded px-2 py-1 w-40 mono" value="${f.name || ''}" data-idx="${idx}" /></td>
|
|
903
|
+
<td class="py-2 pr-3"><select class="field-type border rounded px-2 py-1" data-idx="${idx}">${opts}</select></td>
|
|
904
|
+
<td class="py-2 pr-3"><input type="checkbox" class="field-required" data-idx="${idx}" ${f.required?'checked':''} /></td>
|
|
905
|
+
<td class="py-2 pr-3"><input type="checkbox" class="field-unique" data-idx="${idx}" ${f.unique?'checked':''} /></td>
|
|
906
|
+
<td class="py-2 pr-3"><input class="field-default border rounded px-2 py-1 w-40" value="${f.default===undefined?'':String(f.default)}" data-idx="${idx}" placeholder="(optional)" /></td>
|
|
907
|
+
<td class="py-2 pr-3"><input class="field-ref border rounded px-2 py-1 w-40 mono" value="${f.refModelCode||''}" data-idx="${idx}" placeholder="ref model code" /></td>
|
|
908
|
+
<td class="py-2 pr-3 text-right"><button class="btn-remove-field text-red-600 hover:underline" data-idx="${idx}">Remove</button></td>
|
|
909
|
+
`;
|
|
910
|
+
|
|
911
|
+
tbody.appendChild(tr);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
tbody.querySelectorAll('input,select').forEach((el) => {
|
|
915
|
+
el.addEventListener('change', () => syncFieldsFromUI());
|
|
916
|
+
el.addEventListener('input', () => syncFieldsFromUI());
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
tbody.querySelectorAll('.btn-remove-field').forEach((btn) => {
|
|
920
|
+
btn.addEventListener('click', () => {
|
|
921
|
+
const idx = Number(btn.dataset.idx);
|
|
922
|
+
const fields = Array.isArray(state.selectedModel.fields) ? state.selectedModel.fields : [];
|
|
923
|
+
fields.splice(idx, 1);
|
|
924
|
+
state.selectedModel.fields = fields;
|
|
925
|
+
renderFieldsTable();
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
updateRefVisibility();
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function updateRefVisibility() {
|
|
933
|
+
const tbody = document.getElementById('fields-tbody');
|
|
934
|
+
tbody.querySelectorAll('tr').forEach((tr) => {
|
|
935
|
+
const select = tr.querySelector('.field-type');
|
|
936
|
+
const ref = tr.querySelector('.field-ref');
|
|
937
|
+
if (!select || !ref) return;
|
|
938
|
+
const t = String(select.value).toLowerCase();
|
|
939
|
+
ref.disabled = !(t === 'ref' || t === 'ref[]');
|
|
940
|
+
if (ref.disabled) ref.value = '';
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function syncFieldsFromUI() {
|
|
945
|
+
const m = state.selectedModel;
|
|
946
|
+
if (!m) return;
|
|
947
|
+
|
|
948
|
+
m.displayName = document.getElementById('model-displayName').value;
|
|
949
|
+
|
|
950
|
+
const rows = Array.from(document.querySelectorAll('#fields-tbody tr'));
|
|
951
|
+
const fields = [];
|
|
952
|
+
rows.forEach((tr) => {
|
|
953
|
+
const name = tr.querySelector('.field-name')?.value?.trim();
|
|
954
|
+
const type = tr.querySelector('.field-type')?.value?.trim();
|
|
955
|
+
const required = tr.querySelector('.field-required')?.checked;
|
|
956
|
+
const unique = tr.querySelector('.field-unique')?.checked;
|
|
957
|
+
const def = tr.querySelector('.field-default')?.value;
|
|
958
|
+
const refModelCode = tr.querySelector('.field-ref')?.value?.trim();
|
|
959
|
+
if (!name) return;
|
|
960
|
+
|
|
961
|
+
const field = { name, type, required: !!required, unique: !!unique };
|
|
962
|
+
if (def !== undefined && String(def).trim() !== '') {
|
|
963
|
+
field.default = def;
|
|
964
|
+
}
|
|
965
|
+
const t = String(type).toLowerCase();
|
|
966
|
+
if ((t === 'ref' || t === 'ref[]') && refModelCode) {
|
|
967
|
+
field.refModelCode = refModelCode;
|
|
968
|
+
}
|
|
969
|
+
fields.push(field);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
m.fields = fields;
|
|
973
|
+
updateRefVisibility();
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async function loadModels() {
|
|
977
|
+
try {
|
|
978
|
+
const { items } = await api('/api/admin/headless/models');
|
|
979
|
+
state.models = items || [];
|
|
980
|
+
renderModelsList();
|
|
981
|
+
renderDataModelsList();
|
|
982
|
+
|
|
983
|
+
if (state.selectedModel && !state.selectedModel.__isNew) {
|
|
984
|
+
const still = state.models.find((m) => m.codeIdentifier === state.selectedModel.codeIdentifier);
|
|
985
|
+
if (still) {
|
|
986
|
+
state.selectedModel = still;
|
|
987
|
+
renderSelectedModel();
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
} catch (e) {
|
|
991
|
+
toast(e.message, 'error');
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
async function selectModel(codeIdentifier) {
|
|
996
|
+
const m = state.models.find((x) => x.codeIdentifier === codeIdentifier);
|
|
997
|
+
state.selectedModel = m || null;
|
|
998
|
+
renderSelectedModel();
|
|
999
|
+
renderCurlExamples();
|
|
1000
|
+
|
|
1001
|
+
if (state.selectedModel) {
|
|
1002
|
+
state.modelUi.advancedValidation = null;
|
|
1003
|
+
renderAdvancedValidation();
|
|
1004
|
+
renderAiHistory();
|
|
1005
|
+
renderAiProposal();
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function renderDataSelectedHeader() {
|
|
1010
|
+
const title = document.getElementById('data-selected-title');
|
|
1011
|
+
if (!title) return;
|
|
1012
|
+
|
|
1013
|
+
const code = state.dataUi.selectedModelCode;
|
|
1014
|
+
const m = state.models.find((x) => x.codeIdentifier === code);
|
|
1015
|
+
title.textContent = m ? m.displayName : 'None';
|
|
1016
|
+
|
|
1017
|
+
const applyBtn = document.getElementById('btn-data-apply');
|
|
1018
|
+
const clearBtn = document.getElementById('btn-data-clear');
|
|
1019
|
+
const newRowBtn = document.getElementById('btn-data-new-row');
|
|
1020
|
+
if (applyBtn) applyBtn.disabled = !m;
|
|
1021
|
+
if (clearBtn) clearBtn.disabled = !m;
|
|
1022
|
+
if (newRowBtn) newRowBtn.disabled = !m;
|
|
1023
|
+
|
|
1024
|
+
const filterInput = document.getElementById('data-filter');
|
|
1025
|
+
if (filterInput) {
|
|
1026
|
+
filterInput.disabled = !m;
|
|
1027
|
+
if (!m) filterInput.value = '';
|
|
1028
|
+
if (m && !filterInput.value && state.dataUi.filterRaw) {
|
|
1029
|
+
filterInput.value = state.dataUi.filterRaw;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
renderDataPagination();
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async function selectDataModel(codeIdentifier) {
|
|
1037
|
+
state.dataUi.selectedModelCode = codeIdentifier;
|
|
1038
|
+
state.dataUi.skip = 0;
|
|
1039
|
+
renderDataModelsList();
|
|
1040
|
+
renderDataSelectedHeader();
|
|
1041
|
+
await loadDataPage();
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
async function saveModel() {
|
|
1045
|
+
try {
|
|
1046
|
+
const m = state.selectedModel;
|
|
1047
|
+
if (!m) return;
|
|
1048
|
+
syncFieldsFromUI();
|
|
1049
|
+
|
|
1050
|
+
const payload = {
|
|
1051
|
+
codeIdentifier: document.getElementById('model-code').value.trim(),
|
|
1052
|
+
displayName: document.getElementById('model-displayName').value.trim(),
|
|
1053
|
+
fields: m.fields || [],
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
let res;
|
|
1057
|
+
if (m.__isNew) {
|
|
1058
|
+
res = await api('/api/admin/headless/models', { method: 'POST', body: JSON.stringify(payload) });
|
|
1059
|
+
toast('Model created', 'success');
|
|
1060
|
+
state.selectedModel = null;
|
|
1061
|
+
await loadModels();
|
|
1062
|
+
await selectModel(res.item.codeIdentifier);
|
|
1063
|
+
} else {
|
|
1064
|
+
res = await api(`/api/admin/headless/models/${encodeURIComponent(m.codeIdentifier)}`, { method: 'PUT', body: JSON.stringify(payload) });
|
|
1065
|
+
toast('Schema saved', 'success');
|
|
1066
|
+
await loadModels();
|
|
1067
|
+
await selectModel(res.item.codeIdentifier);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
return res;
|
|
1071
|
+
} catch (e) {
|
|
1072
|
+
toast(e.message, 'error');
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
async function disableModel() {
|
|
1077
|
+
try {
|
|
1078
|
+
const m = state.selectedModel;
|
|
1079
|
+
if (!m || m.__isNew) return;
|
|
1080
|
+
await api(`/api/admin/headless/models/${encodeURIComponent(m.codeIdentifier)}`, { method: 'DELETE' });
|
|
1081
|
+
toast('Model disabled', 'success');
|
|
1082
|
+
state.selectedModel = null;
|
|
1083
|
+
renderSelectedModel();
|
|
1084
|
+
await loadModels();
|
|
1085
|
+
} catch (e) {
|
|
1086
|
+
toast(e.message, 'error');
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
async function loadDataPage() {
|
|
1091
|
+
const code = state.dataUi.selectedModelCode;
|
|
1092
|
+
if (!code) {
|
|
1093
|
+
state.data = { items: [], total: 0 };
|
|
1094
|
+
renderDataMeta();
|
|
1095
|
+
renderDataTable();
|
|
1096
|
+
renderDataPagination();
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
let filterObj = null;
|
|
1101
|
+
const filterRaw = String(state.dataUi.filterRaw || '').trim();
|
|
1102
|
+
if (filterRaw) {
|
|
1103
|
+
try {
|
|
1104
|
+
filterObj = JSON.parse(filterRaw);
|
|
1105
|
+
} catch {
|
|
1106
|
+
toast('Invalid filter JSON', 'error');
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const params = new URLSearchParams();
|
|
1112
|
+
params.set('limit', String(state.dataUi.limit));
|
|
1113
|
+
params.set('skip', String(state.dataUi.skip));
|
|
1114
|
+
if (filterObj) params.set('filter', JSON.stringify(filterObj));
|
|
1115
|
+
|
|
1116
|
+
try {
|
|
1117
|
+
const { items, total, limit, skip } = await api(`/api/admin/headless/collections/${encodeURIComponent(code)}?${params.toString()}`);
|
|
1118
|
+
state.data = { items: items || [], total: total || 0, limit: limit || state.dataUi.limit, skip: skip || state.dataUi.skip };
|
|
1119
|
+
state.dataUi.limit = state.data.limit;
|
|
1120
|
+
state.dataUi.skip = state.data.skip;
|
|
1121
|
+
renderDataMeta();
|
|
1122
|
+
renderDataTable();
|
|
1123
|
+
renderDataPagination();
|
|
1124
|
+
} catch (e) {
|
|
1125
|
+
toast(e.message, 'error');
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function renderDataMeta() {
|
|
1130
|
+
const meta = document.getElementById('data-meta');
|
|
1131
|
+
if (!meta) return;
|
|
1132
|
+
const code = state.dataUi.selectedModelCode;
|
|
1133
|
+
if (!code) {
|
|
1134
|
+
meta.textContent = '';
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const total = Number(state.data.total || 0);
|
|
1139
|
+
const limit = Number(state.data.limit || state.dataUi.limit);
|
|
1140
|
+
const skip = Number(state.data.skip || state.dataUi.skip);
|
|
1141
|
+
const from = total === 0 ? 0 : skip + 1;
|
|
1142
|
+
const to = Math.min(skip + limit, total);
|
|
1143
|
+
meta.textContent = `Showing ${from}-${to} of ${total}`;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function renderDataPagination() {
|
|
1147
|
+
const prev = document.getElementById('btn-page-prev');
|
|
1148
|
+
const next = document.getElementById('btn-page-next');
|
|
1149
|
+
if (!prev || !next) return;
|
|
1150
|
+
const total = Number(state.data.total || 0);
|
|
1151
|
+
const limit = Number(state.data.limit || state.dataUi.limit);
|
|
1152
|
+
const skip = Number(state.data.skip || state.dataUi.skip);
|
|
1153
|
+
|
|
1154
|
+
prev.disabled = skip <= 0;
|
|
1155
|
+
next.disabled = skip + limit >= total;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function renderDataTable() {
|
|
1159
|
+
const container = document.getElementById('data-table');
|
|
1160
|
+
|
|
1161
|
+
const code = state.dataUi.selectedModelCode;
|
|
1162
|
+
const m = state.models.find((x) => x.codeIdentifier === code);
|
|
1163
|
+
|
|
1164
|
+
if (!m) {
|
|
1165
|
+
container.innerHTML = '<div class="py-6 text-center text-gray-500">Select a collection</div>';
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const items = state.data.items || [];
|
|
1170
|
+
const fields = (m.fields || []).map((f) => f.name).filter((n) => n);
|
|
1171
|
+
const cols = ['_id', ...fields];
|
|
1172
|
+
|
|
1173
|
+
if (!items.length) {
|
|
1174
|
+
container.innerHTML = '<div class="py-6 text-center text-gray-500">No rows yet</div>';
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
let html = '<table class="min-w-full text-sm"><thead><tr class="text-left text-gray-500 border-b">';
|
|
1179
|
+
cols.forEach((c) => { html += `<th class="py-2 pr-3 mono">${c}</th>`; });
|
|
1180
|
+
html += '<th class="py-2 pr-3"></th></tr></thead><tbody>';
|
|
1181
|
+
|
|
1182
|
+
items.forEach((it) => {
|
|
1183
|
+
html += '<tr class="border-t">';
|
|
1184
|
+
cols.forEach((c) => {
|
|
1185
|
+
const val = it[c];
|
|
1186
|
+
const s = val === undefined ? '' : typeof val === 'object' ? JSON.stringify(val) : String(val);
|
|
1187
|
+
const ro = c === '_id' ? 'readonly' : '';
|
|
1188
|
+
html += `<td class="py-2 pr-3"><input ${ro} data-id="${it._id}" data-field="${c}" class="cell border rounded px-2 py-1 w-56 mono ${ro?'bg-gray-100':''}" value="${s.replace(/"/g,'"')}" /></td>`;
|
|
1189
|
+
});
|
|
1190
|
+
html += `<td class="py-2 pr-3 text-right"><button class="btn-del-row text-red-600 hover:underline" data-id="${it._id}">Delete</button></td>`;
|
|
1191
|
+
html += '</tr>';
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
html += '</tbody></table>';
|
|
1195
|
+
container.innerHTML = html;
|
|
1196
|
+
|
|
1197
|
+
container.querySelectorAll('.cell').forEach((inp) => {
|
|
1198
|
+
inp.addEventListener('keydown', async (e) => {
|
|
1199
|
+
if (e.key !== 'Enter') return;
|
|
1200
|
+
e.preventDefault();
|
|
1201
|
+
await saveCell(inp);
|
|
1202
|
+
});
|
|
1203
|
+
inp.addEventListener('blur', async () => {
|
|
1204
|
+
if (inp.dataset.field === '_id') return;
|
|
1205
|
+
await saveCell(inp);
|
|
1206
|
+
});
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
container.querySelectorAll('.btn-del-row').forEach((btn) => {
|
|
1210
|
+
btn.addEventListener('click', async () => {
|
|
1211
|
+
await deleteRow(btn.dataset.id);
|
|
1212
|
+
});
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function parseValue(v) {
|
|
1217
|
+
const s = String(v ?? '').trim();
|
|
1218
|
+
if (!s) return null;
|
|
1219
|
+
if (s === 'true') return true;
|
|
1220
|
+
if (s === 'false') return false;
|
|
1221
|
+
if (!Number.isNaN(Number(s)) && s.match(/^[+-]?(\d+\.?\d*|\d*\.?\d+)$/)) return Number(s);
|
|
1222
|
+
try {
|
|
1223
|
+
return JSON.parse(s);
|
|
1224
|
+
} catch {
|
|
1225
|
+
return s;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
async function saveCell(input) {
|
|
1230
|
+
const code = state.dataUi.selectedModelCode;
|
|
1231
|
+
const m = state.models.find((x) => x.codeIdentifier === code);
|
|
1232
|
+
if (!m) return;
|
|
1233
|
+
|
|
1234
|
+
const id = input.dataset.id;
|
|
1235
|
+
const field = input.dataset.field;
|
|
1236
|
+
if (!id || !field || field === '_id') return;
|
|
1237
|
+
|
|
1238
|
+
const isTemp = String(id || '').startsWith('temp-');
|
|
1239
|
+
|
|
1240
|
+
try {
|
|
1241
|
+
const value = parseValue(input.value);
|
|
1242
|
+
|
|
1243
|
+
if (isTemp) {
|
|
1244
|
+
const payload = { [field]: value };
|
|
1245
|
+
const { item } = await api(`/api/admin/headless/collections/${encodeURIComponent(m.codeIdentifier)}`, {
|
|
1246
|
+
method: 'POST',
|
|
1247
|
+
body: JSON.stringify(payload),
|
|
1248
|
+
});
|
|
1249
|
+
const realId = item._id;
|
|
1250
|
+
const idx = state.data.items.findIndex((it) => it._id === id);
|
|
1251
|
+
if (idx !== -1) {
|
|
1252
|
+
state.data.items[idx] = { ...state.data.items[idx], _id: realId, [field]: value };
|
|
1253
|
+
}
|
|
1254
|
+
toast('Row created', 'success');
|
|
1255
|
+
await loadDataPage();
|
|
1256
|
+
} else {
|
|
1257
|
+
await api(`/api/admin/headless/collections/${encodeURIComponent(m.codeIdentifier)}/${encodeURIComponent(id)}`, {
|
|
1258
|
+
method: 'PUT',
|
|
1259
|
+
body: JSON.stringify({ [field]: value }),
|
|
1260
|
+
});
|
|
1261
|
+
toast('Saved', 'success');
|
|
1262
|
+
await loadDataPage();
|
|
1263
|
+
}
|
|
1264
|
+
} catch (e) {
|
|
1265
|
+
toast(e.message, 'error');
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function newRow() {
|
|
1270
|
+
const code = state.dataUi.selectedModelCode;
|
|
1271
|
+
const m = state.models.find((x) => x.codeIdentifier === code);
|
|
1272
|
+
if (!m) return;
|
|
1273
|
+
|
|
1274
|
+
const tempId = `temp-${Date.now()}`;
|
|
1275
|
+
const fields = (m.fields || []).map((f) => f.name).filter((n) => n);
|
|
1276
|
+
const cols = ['_id', ...fields];
|
|
1277
|
+
|
|
1278
|
+
const newRowData = { _id: tempId };
|
|
1279
|
+
fields.forEach((f) => {
|
|
1280
|
+
const fieldDef = m.fields.find((fd) => fd.name === f);
|
|
1281
|
+
if (fieldDef) {
|
|
1282
|
+
if (fieldDef.default !== undefined) {
|
|
1283
|
+
newRowData[f] = fieldDef.default;
|
|
1284
|
+
} else if (fieldDef.type === 'boolean') {
|
|
1285
|
+
newRowData[f] = false;
|
|
1286
|
+
} else if (fieldDef.type === 'number') {
|
|
1287
|
+
newRowData[f] = 0;
|
|
1288
|
+
} else if (fieldDef.type === 'date') {
|
|
1289
|
+
newRowData[f] = new Date().toISOString().slice(0, 16);
|
|
1290
|
+
} else {
|
|
1291
|
+
newRowData[f] = '';
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
state.data.items = [...(state.data.items || []), newRowData];
|
|
1297
|
+
renderDataTable();
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
async function deleteRow(id) {
|
|
1301
|
+
const code = state.dataUi.selectedModelCode;
|
|
1302
|
+
const m = state.models.find((x) => x.codeIdentifier === code);
|
|
1303
|
+
if (!m) return;
|
|
1304
|
+
|
|
1305
|
+
const isTemp = String(id || '').startsWith('temp-');
|
|
1306
|
+
if (isTemp) {
|
|
1307
|
+
const idx = state.data.items.findIndex((it) => it._id === id);
|
|
1308
|
+
if (idx !== -1) {
|
|
1309
|
+
state.data.items.splice(idx, 1);
|
|
1310
|
+
renderDataTable();
|
|
1311
|
+
}
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
try {
|
|
1316
|
+
await api(`/api/admin/headless/collections/${encodeURIComponent(m.codeIdentifier)}/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
1317
|
+
toast('Row deleted', 'success');
|
|
1318
|
+
await loadDataPage();
|
|
1319
|
+
} catch (e) {
|
|
1320
|
+
toast(e.message, 'error');
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// API token UI
|
|
1325
|
+
function renderTokens() {
|
|
1326
|
+
const list = document.getElementById('tokens-list');
|
|
1327
|
+
list.innerHTML = '';
|
|
1328
|
+
|
|
1329
|
+
if (!state.tokens.length) {
|
|
1330
|
+
list.innerHTML = '<div class="text-gray-500 text-sm">No tokens yet</div>';
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
state.tokens.forEach((t) => {
|
|
1335
|
+
const card = document.createElement('button');
|
|
1336
|
+
const isSelected = state.apiUi.selectedTokenId === t._id;
|
|
1337
|
+
card.type = 'button';
|
|
1338
|
+
card.className = `w-full text-left bg-white border rounded p-3 ${isSelected ? 'border-blue-300 bg-blue-50' : 'border-gray-200 hover:bg-gray-50'}`;
|
|
1339
|
+
|
|
1340
|
+
const exp = t.expiresAt ? new Date(t.expiresAt).toISOString() : 'never';
|
|
1341
|
+
card.innerHTML = `
|
|
1342
|
+
<div class="flex items-center justify-between gap-3">
|
|
1343
|
+
<div>
|
|
1344
|
+
<div class="font-medium">${t.name}</div>
|
|
1345
|
+
<div class="text-xs text-gray-500">expires: <span class="mono">${exp}</span></div>
|
|
1346
|
+
</div>
|
|
1347
|
+
<div class="flex items-center gap-2">
|
|
1348
|
+
<button type="button" class="btn-copy-token bg-white border border-gray-200 rounded px-2 py-2 hover:bg-gray-100" title="Copy token" data-id="${t._id}">
|
|
1349
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1350
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
1351
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
1352
|
+
</svg>
|
|
1353
|
+
</button>
|
|
1354
|
+
<button type="button" class="btn-del-token bg-white border border-gray-200 rounded px-2 py-2 hover:bg-gray-100 text-red-600" title="Delete token" data-id="${t._id}" data-name="${(t.name || '').replace(/"/g,'"')}">
|
|
1355
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1356
|
+
<polyline points="3 6 5 6 21 6"></polyline>
|
|
1357
|
+
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
|
|
1358
|
+
<path d="M10 11v6"></path>
|
|
1359
|
+
<path d="M14 11v6"></path>
|
|
1360
|
+
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"></path>
|
|
1361
|
+
</svg>
|
|
1362
|
+
</button>
|
|
1363
|
+
</div>
|
|
1364
|
+
</div>
|
|
1365
|
+
`;
|
|
1366
|
+
|
|
1367
|
+
card.addEventListener('click', async () => {
|
|
1368
|
+
const tokenValue = getCachedTokenValue(t._id);
|
|
1369
|
+
state.apiUi.selectedTokenId = t._id;
|
|
1370
|
+
state.apiUi.selectedTokenValue = tokenValue;
|
|
1371
|
+
|
|
1372
|
+
if (!tokenValue) {
|
|
1373
|
+
const provided = window.prompt('Paste this API token to use it in cURL examples (stored in this browser only):');
|
|
1374
|
+
if (provided && String(provided).trim()) {
|
|
1375
|
+
cacheTokenValue(t._id, String(provided).trim());
|
|
1376
|
+
state.apiUi.selectedTokenValue = String(provided).trim();
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
renderTokens();
|
|
1381
|
+
renderSelectedTokenLabel();
|
|
1382
|
+
renderCurlExamples();
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
list.appendChild(card);
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
list.querySelectorAll('.btn-copy-token').forEach((btn) => {
|
|
1389
|
+
btn.addEventListener('click', async (e) => {
|
|
1390
|
+
e.preventDefault();
|
|
1391
|
+
e.stopPropagation();
|
|
1392
|
+
const id = btn.dataset.id;
|
|
1393
|
+
let tokenValue = getCachedTokenValue(id);
|
|
1394
|
+
if (!tokenValue) {
|
|
1395
|
+
const provided = window.prompt('Paste this API token to store it in this browser and copy it:');
|
|
1396
|
+
if (provided && String(provided).trim()) {
|
|
1397
|
+
tokenValue = String(provided).trim();
|
|
1398
|
+
cacheTokenValue(id, tokenValue);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
if (!tokenValue) return;
|
|
1402
|
+
const ok = await copyToClipboard(tokenValue);
|
|
1403
|
+
toast(ok ? 'Token copied' : 'Failed to copy', ok ? 'success' : 'error');
|
|
1404
|
+
});
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
list.querySelectorAll('.btn-del-token').forEach((btn) => {
|
|
1408
|
+
btn.addEventListener('click', async (e) => {
|
|
1409
|
+
e.preventDefault();
|
|
1410
|
+
e.stopPropagation();
|
|
1411
|
+
const id = btn.dataset.id;
|
|
1412
|
+
const name = btn.dataset.name || 'this token';
|
|
1413
|
+
const ok = window.confirm(`Delete token "${name}"? This cannot be undone.`);
|
|
1414
|
+
if (!ok) return;
|
|
1415
|
+
|
|
1416
|
+
try {
|
|
1417
|
+
await api(`/api/admin/headless/tokens/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
1418
|
+
toast('Token deleted', 'success');
|
|
1419
|
+
if (state.apiUi.selectedTokenId === id) {
|
|
1420
|
+
state.apiUi.selectedTokenId = null;
|
|
1421
|
+
state.apiUi.selectedTokenValue = null;
|
|
1422
|
+
renderSelectedTokenLabel();
|
|
1423
|
+
renderCurlExamples();
|
|
1424
|
+
}
|
|
1425
|
+
await loadTokens();
|
|
1426
|
+
} catch (e2) {
|
|
1427
|
+
toast(e2.message, 'error');
|
|
1428
|
+
}
|
|
1429
|
+
});
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
async function loadTokens() {
|
|
1434
|
+
try {
|
|
1435
|
+
const { items } = await api('/api/admin/headless/tokens');
|
|
1436
|
+
state.tokens = items || [];
|
|
1437
|
+
renderTokens();
|
|
1438
|
+
} catch (e) {
|
|
1439
|
+
toast(e.message, 'error');
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function renderPermissionsDraft() {
|
|
1444
|
+
const container = document.getElementById('permissions-list');
|
|
1445
|
+
container.innerHTML = '';
|
|
1446
|
+
|
|
1447
|
+
if (!state.permissionsDraft.length) {
|
|
1448
|
+
container.innerHTML = '<div class="text-gray-500 text-sm">No permissions yet</div>';
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
state.permissionsDraft.forEach((p, idx) => {
|
|
1453
|
+
const el = document.createElement('div');
|
|
1454
|
+
el.className = 'bg-white border border-gray-200 rounded p-3';
|
|
1455
|
+
|
|
1456
|
+
const modelOptions = (state.models || []).map((m) => `<option value="${m.codeIdentifier}" ${m.codeIdentifier===p.modelCode?'selected':''}>${m.displayName} (${m.codeIdentifier})</option>`).join('');
|
|
1457
|
+
|
|
1458
|
+
const ops = ['create','read','update','delete'];
|
|
1459
|
+
const opsHtml = ops.map((o) => {
|
|
1460
|
+
const checked = Array.isArray(p.operations) && p.operations.includes(o);
|
|
1461
|
+
return `<label class="inline-flex items-center gap-2 mr-3"><input type="checkbox" data-idx="${idx}" data-op="${o}" class="perm-op" ${checked?'checked':''} /> <span class="text-sm">${o}</span></label>`;
|
|
1462
|
+
}).join('');
|
|
1463
|
+
|
|
1464
|
+
el.innerHTML = `
|
|
1465
|
+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
|
1466
|
+
<div class="flex-1">
|
|
1467
|
+
<div class="text-xs text-gray-500 mb-1">Model</div>
|
|
1468
|
+
<select class="perm-model border rounded px-2 py-2 w-full" data-idx="${idx}">
|
|
1469
|
+
<option value="">Select model…</option>
|
|
1470
|
+
${modelOptions}
|
|
1471
|
+
</select>
|
|
1472
|
+
</div>
|
|
1473
|
+
<div class="flex-1">
|
|
1474
|
+
<div class="text-xs text-gray-500 mb-1">Operations</div>
|
|
1475
|
+
<div>${opsHtml}</div>
|
|
1476
|
+
</div>
|
|
1477
|
+
<div class="text-right">
|
|
1478
|
+
<button class="perm-remove text-red-600 hover:underline" data-idx="${idx}">Remove</button>
|
|
1479
|
+
</div>
|
|
1480
|
+
</div>
|
|
1481
|
+
`;
|
|
1482
|
+
|
|
1483
|
+
container.appendChild(el);
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
container.querySelectorAll('.perm-model').forEach((sel) => {
|
|
1487
|
+
sel.addEventListener('change', () => {
|
|
1488
|
+
const idx = Number(sel.dataset.idx);
|
|
1489
|
+
state.permissionsDraft[idx].modelCode = sel.value;
|
|
1490
|
+
});
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
container.querySelectorAll('.perm-op').forEach((chk) => {
|
|
1494
|
+
chk.addEventListener('change', () => {
|
|
1495
|
+
const idx = Number(chk.dataset.idx);
|
|
1496
|
+
const op = chk.dataset.op;
|
|
1497
|
+
const p = state.permissionsDraft[idx];
|
|
1498
|
+
p.operations = Array.isArray(p.operations) ? p.operations : [];
|
|
1499
|
+
if (chk.checked) {
|
|
1500
|
+
if (!p.operations.includes(op)) p.operations.push(op);
|
|
1501
|
+
} else {
|
|
1502
|
+
p.operations = p.operations.filter((x) => x !== op);
|
|
1503
|
+
}
|
|
1504
|
+
});
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
container.querySelectorAll('.perm-remove').forEach((btn) => {
|
|
1508
|
+
btn.addEventListener('click', () => {
|
|
1509
|
+
const idx = Number(btn.dataset.idx);
|
|
1510
|
+
state.permissionsDraft.splice(idx, 1);
|
|
1511
|
+
renderPermissionsDraft();
|
|
1512
|
+
});
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
function renderCurlExamples() {
|
|
1517
|
+
const model = state.selectedModel ? state.selectedModel.codeIdentifier : 'your_model_code';
|
|
1518
|
+
const tokenInline = state.apiUi.selectedTokenValue ? state.apiUi.selectedTokenValue : '${API_TOKEN}';
|
|
1519
|
+
|
|
1520
|
+
const commands = {
|
|
1521
|
+
list: `curl -s \"${baseUrl}/api/headless/${model}?limit=10&skip=0\" \\\n -H \"Authorization: Bearer ${tokenInline}\"`,
|
|
1522
|
+
create: `curl -s -X POST \"${baseUrl}/api/headless/${model}\" \\\n -H \"Authorization: Bearer ${tokenInline}\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\":\"example\"}'`,
|
|
1523
|
+
update: `curl -s -X PUT \"${baseUrl}/api/headless/${model}/\${ID}\" \\\n -H \"X-API-Token: ${tokenInline}\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\":\"changed\"}'`,
|
|
1524
|
+
delete: `curl -s -X DELETE \"${baseUrl}/api/headless/${model}/\${ID}\" \\\n -H \"X-API-Token: ${tokenInline}\"`,
|
|
1525
|
+
};
|
|
1526
|
+
|
|
1527
|
+
Object.keys(commands).forEach(op => {
|
|
1528
|
+
const el = document.getElementById(`curl-${op}`);
|
|
1529
|
+
if (el) el.textContent = commands[op];
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function renderSelectedTokenLabel() {
|
|
1534
|
+
const el = document.getElementById('curl-selected-token');
|
|
1535
|
+
if (!el) return;
|
|
1536
|
+
if (!state.apiUi.selectedTokenId) {
|
|
1537
|
+
el.textContent = '';
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
const token = state.apiUi.selectedTokenValue;
|
|
1541
|
+
el.textContent = token ? 'token selected' : 'token not stored in this browser';
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
async function createToken() {
|
|
1545
|
+
try {
|
|
1546
|
+
const name = document.getElementById('token-name').value.trim();
|
|
1547
|
+
const ttlSeconds = document.getElementById('token-ttl').value;
|
|
1548
|
+
|
|
1549
|
+
const permissions = (state.permissionsDraft || []).filter((p) => p.modelCode);
|
|
1550
|
+
|
|
1551
|
+
const payload = { name, permissions };
|
|
1552
|
+
if (ttlSeconds) payload.ttlSeconds = Number(ttlSeconds);
|
|
1553
|
+
|
|
1554
|
+
const { token, item } = await api('/api/admin/headless/tokens', {
|
|
1555
|
+
method: 'POST',
|
|
1556
|
+
body: JSON.stringify(payload),
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
if (item && item._id && token) {
|
|
1560
|
+
cacheTokenValue(item._id, token);
|
|
1561
|
+
state.apiUi.selectedTokenId = item._id;
|
|
1562
|
+
state.apiUi.selectedTokenValue = token;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
document.getElementById('created-token').classList.remove('hidden');
|
|
1566
|
+
document.getElementById('created-token-value').textContent = token;
|
|
1567
|
+
toast('Token created', 'success');
|
|
1568
|
+
await loadTokens();
|
|
1569
|
+
renderSelectedTokenLabel();
|
|
1570
|
+
renderCurlExamples();
|
|
1571
|
+
} catch (e) {
|
|
1572
|
+
toast(e.message, 'error');
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// Buttons
|
|
1577
|
+
document.getElementById('btn-refresh').addEventListener('click', async () => {
|
|
1578
|
+
await loadModels();
|
|
1579
|
+
await loadDataPage();
|
|
1580
|
+
await loadTokens();
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1583
|
+
// Copy cURL buttons
|
|
1584
|
+
function setupCurlCopyButtons() {
|
|
1585
|
+
document.querySelectorAll('.btn-copy-curl').forEach(btn => {
|
|
1586
|
+
btn.addEventListener('click', async () => {
|
|
1587
|
+
const op = btn.dataset.op;
|
|
1588
|
+
const el = document.getElementById(`curl-${op}`);
|
|
1589
|
+
if (!el) return;
|
|
1590
|
+
const ok = await copyToClipboard(el.textContent);
|
|
1591
|
+
toast(ok ? 'cURL copied' : 'Failed to copy', ok ? 'success' : 'error');
|
|
1592
|
+
});
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
function apiTestSetVisible() {
|
|
1597
|
+
const op = document.getElementById('api-test-op').value;
|
|
1598
|
+
const needsId = op === 'update' || op === 'delete';
|
|
1599
|
+
document.getElementById('api-test-pathvars').classList.toggle('hidden', !needsId);
|
|
1600
|
+
const needsBody = op === 'create' || op === 'update';
|
|
1601
|
+
document.getElementById('api-test-body-fields').parentElement.classList.toggle('hidden', !needsBody);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
function apiTestRenderModelOptions() {
|
|
1605
|
+
const sel = document.getElementById('api-test-model');
|
|
1606
|
+
if (!sel) return;
|
|
1607
|
+
sel.innerHTML = '';
|
|
1608
|
+
(state.models || []).forEach((m) => {
|
|
1609
|
+
const opt = document.createElement('option');
|
|
1610
|
+
opt.value = m.codeIdentifier;
|
|
1611
|
+
opt.textContent = `${m.displayName} (${m.codeIdentifier})`;
|
|
1612
|
+
sel.appendChild(opt);
|
|
1613
|
+
});
|
|
1614
|
+
const preferred = state.selectedModel ? state.selectedModel.codeIdentifier : null;
|
|
1615
|
+
if (preferred && (state.models || []).some((m) => m.codeIdentifier === preferred)) {
|
|
1616
|
+
sel.value = preferred;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
function apiTestCurrentModel() {
|
|
1621
|
+
const code = document.getElementById('api-test-model').value;
|
|
1622
|
+
return (state.models || []).find((m) => m.codeIdentifier === code) || null;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
function apiTestFieldInput(field) {
|
|
1626
|
+
const type = String(field.type || '').toLowerCase();
|
|
1627
|
+
const name = field.name;
|
|
1628
|
+
|
|
1629
|
+
const container = document.createElement('div');
|
|
1630
|
+
container.className = 'bg-white border rounded p-3';
|
|
1631
|
+
|
|
1632
|
+
const label = document.createElement('div');
|
|
1633
|
+
label.className = 'text-xs font-medium mb-1';
|
|
1634
|
+
label.textContent = `${name} (${type}${field.required ? ', required' : ''})`;
|
|
1635
|
+
container.appendChild(label);
|
|
1636
|
+
|
|
1637
|
+
const inputId = `api-test-body-${name}`;
|
|
1638
|
+
let input;
|
|
1639
|
+
|
|
1640
|
+
if (type === 'boolean') {
|
|
1641
|
+
input = document.createElement('input');
|
|
1642
|
+
input.type = 'checkbox';
|
|
1643
|
+
input.id = inputId;
|
|
1644
|
+
input.className = 'border rounded';
|
|
1645
|
+
} else if (type === 'number') {
|
|
1646
|
+
input = document.createElement('input');
|
|
1647
|
+
input.type = 'number';
|
|
1648
|
+
input.id = inputId;
|
|
1649
|
+
input.className = 'border rounded px-3 py-2 w-full';
|
|
1650
|
+
} else if (type === 'date') {
|
|
1651
|
+
input = document.createElement('input');
|
|
1652
|
+
input.type = 'datetime-local';
|
|
1653
|
+
input.id = inputId;
|
|
1654
|
+
input.className = 'border rounded px-3 py-2 w-full';
|
|
1655
|
+
} else if (type === 'object' || type === 'array' || type === 'ref[]') {
|
|
1656
|
+
input = document.createElement('textarea');
|
|
1657
|
+
input.id = inputId;
|
|
1658
|
+
input.className = 'border rounded px-3 py-2 w-full mono text-xs';
|
|
1659
|
+
input.rows = 3;
|
|
1660
|
+
if (type === 'object') input.placeholder = '{"key":"value"}';
|
|
1661
|
+
if (type === 'array') input.placeholder = '["value1","value2"]';
|
|
1662
|
+
if (type === 'ref[]') input.placeholder = '["<id1>","<id2>"]';
|
|
1663
|
+
} else {
|
|
1664
|
+
input = document.createElement('input');
|
|
1665
|
+
input.type = 'text';
|
|
1666
|
+
input.id = inputId;
|
|
1667
|
+
input.className = 'border rounded px-3 py-2 w-full';
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
input.dataset.fieldName = name;
|
|
1671
|
+
input.dataset.fieldType = type;
|
|
1672
|
+
container.appendChild(input);
|
|
1673
|
+
|
|
1674
|
+
const info = document.createElement('div');
|
|
1675
|
+
info.className = 'text-xs text-gray-500 mt-1';
|
|
1676
|
+
if (type === 'object' || type === 'array' || type === 'ref[]') {
|
|
1677
|
+
info.textContent = `Example schema: ${input.placeholder}`;
|
|
1678
|
+
} else if (type === 'ref') {
|
|
1679
|
+
info.textContent = 'Provide referenced id as string';
|
|
1680
|
+
} else {
|
|
1681
|
+
info.textContent = '';
|
|
1682
|
+
}
|
|
1683
|
+
container.appendChild(info);
|
|
1684
|
+
|
|
1685
|
+
return container;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function apiTestRenderBodyFields() {
|
|
1689
|
+
const wrap = document.getElementById('api-test-body-fields');
|
|
1690
|
+
if (!wrap) return;
|
|
1691
|
+
wrap.innerHTML = '';
|
|
1692
|
+
const model = apiTestCurrentModel();
|
|
1693
|
+
const help = document.getElementById('api-test-body-help');
|
|
1694
|
+
|
|
1695
|
+
if (!model) {
|
|
1696
|
+
if (help) help.textContent = 'Select a model to generate fields.';
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
const fields = Array.isArray(model.fields) ? model.fields : [];
|
|
1701
|
+
if (help) help.textContent = 'Complex fields accept per-field JSON. Empty fields are not sent.';
|
|
1702
|
+
fields.forEach((f) => {
|
|
1703
|
+
if (!f || !f.name) return;
|
|
1704
|
+
wrap.appendChild(apiTestFieldInput(f));
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
function apiTestRenderPairs(containerId, kind) {
|
|
1709
|
+
const container = document.getElementById(containerId);
|
|
1710
|
+
if (!container) return;
|
|
1711
|
+
container.innerHTML = '';
|
|
1712
|
+
const arr = state.apiTestUi[kind];
|
|
1713
|
+
arr.forEach((row, idx) => {
|
|
1714
|
+
const el = document.createElement('div');
|
|
1715
|
+
el.className = 'grid grid-cols-12 gap-2';
|
|
1716
|
+
el.innerHTML = `
|
|
1717
|
+
<input class="border rounded px-2 py-1 col-span-5" placeholder="key" data-kind="${kind}" data-idx="${idx}" data-part="key" value="${row.key || ''}" />
|
|
1718
|
+
<input class="border rounded px-2 py-1 col-span-5" placeholder="value" data-kind="${kind}" data-idx="${idx}" data-part="value" value="${row.value || ''}" />
|
|
1719
|
+
<button class="col-span-2 text-xs text-red-600 hover:underline" type="button" data-kind="${kind}" data-idx="${idx}" data-action="remove">Remove</button>
|
|
1720
|
+
`;
|
|
1721
|
+
container.appendChild(el);
|
|
1722
|
+
});
|
|
1723
|
+
|
|
1724
|
+
container.querySelectorAll('input').forEach((inp) => {
|
|
1725
|
+
inp.addEventListener('input', () => {
|
|
1726
|
+
const k = inp.dataset.kind;
|
|
1727
|
+
const i = Number(inp.dataset.idx);
|
|
1728
|
+
const part = inp.dataset.part;
|
|
1729
|
+
state.apiTestUi[k][i][part] = inp.value;
|
|
1730
|
+
});
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
container.querySelectorAll('button[data-action="remove"]').forEach((btn) => {
|
|
1734
|
+
btn.addEventListener('click', () => {
|
|
1735
|
+
const k = btn.dataset.kind;
|
|
1736
|
+
const i = Number(btn.dataset.idx);
|
|
1737
|
+
state.apiTestUi[k].splice(i, 1);
|
|
1738
|
+
apiTestRenderPairs(containerId, k);
|
|
1739
|
+
});
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
function apiTestBuildObject(kind) {
|
|
1744
|
+
const out = {};
|
|
1745
|
+
(state.apiTestUi[kind] || []).forEach((row) => {
|
|
1746
|
+
const key = String(row.key || '').trim();
|
|
1747
|
+
if (!key) return;
|
|
1748
|
+
if (kind === 'sort') {
|
|
1749
|
+
const n = Number(row.value);
|
|
1750
|
+
if (n === 1 || n === -1) out[key] = n;
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
const raw = row.value;
|
|
1755
|
+
const trimmed = String(raw || '').trim();
|
|
1756
|
+
if (!trimmed) return;
|
|
1757
|
+
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
|
1758
|
+
try {
|
|
1759
|
+
out[key] = JSON.parse(trimmed);
|
|
1760
|
+
return;
|
|
1761
|
+
} catch {
|
|
1762
|
+
out[key] = trimmed;
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
out[key] = trimmed;
|
|
1767
|
+
});
|
|
1768
|
+
return out;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
function apiTestCollectBody() {
|
|
1772
|
+
const out = {};
|
|
1773
|
+
document.querySelectorAll('#api-test-body-fields [data-field-name]').forEach((el) => {
|
|
1774
|
+
const name = el.dataset.fieldName;
|
|
1775
|
+
const type = el.dataset.fieldType;
|
|
1776
|
+
|
|
1777
|
+
if (el.tagName.toLowerCase() === 'input' && el.type === 'checkbox') {
|
|
1778
|
+
if (el.checked) out[name] = true;
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
const value = String(el.value || '').trim();
|
|
1783
|
+
if (!value) return;
|
|
1784
|
+
|
|
1785
|
+
if (type === 'number') {
|
|
1786
|
+
out[name] = Number(value);
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
if (type === 'date') {
|
|
1790
|
+
const dt = new Date(value);
|
|
1791
|
+
out[name] = isNaN(dt.getTime()) ? value : dt.toISOString();
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
if (type === 'object' || type === 'array' || type === 'ref[]') {
|
|
1795
|
+
const parsed = safeJsonParse(value);
|
|
1796
|
+
if (parsed === null) {
|
|
1797
|
+
throw new Error(`Invalid JSON for field: ${name}`);
|
|
1798
|
+
}
|
|
1799
|
+
out[name] = parsed;
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
out[name] = value;
|
|
1803
|
+
});
|
|
1804
|
+
return out;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
async function apiTestExecute() {
|
|
1808
|
+
const op = document.getElementById('api-test-op').value;
|
|
1809
|
+
const modelCode = document.getElementById('api-test-model').value;
|
|
1810
|
+
const token = document.getElementById('api-test-token').value.trim() || state.apiUi.selectedTokenValue || '';
|
|
1811
|
+
const id = document.getElementById('api-test-id').value.trim();
|
|
1812
|
+
|
|
1813
|
+
const query = {
|
|
1814
|
+
limit: document.getElementById('api-test-limit').value,
|
|
1815
|
+
skip: document.getElementById('api-test-skip').value,
|
|
1816
|
+
populate: document.getElementById('api-test-populate').value,
|
|
1817
|
+
};
|
|
1818
|
+
const filter = apiTestBuildObject('filter');
|
|
1819
|
+
const sort = apiTestBuildObject('sort');
|
|
1820
|
+
if (Object.keys(filter).length) query.filter = filter;
|
|
1821
|
+
if (Object.keys(sort).length) query.sort = sort;
|
|
1822
|
+
|
|
1823
|
+
let body;
|
|
1824
|
+
if (op === 'create' || op === 'update') {
|
|
1825
|
+
body = apiTestCollectBody();
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
const payload = {
|
|
1829
|
+
op,
|
|
1830
|
+
modelCode,
|
|
1831
|
+
pathVars: { id },
|
|
1832
|
+
query,
|
|
1833
|
+
body,
|
|
1834
|
+
auth: { type: 'bearer', token },
|
|
1835
|
+
};
|
|
1836
|
+
|
|
1837
|
+
const result = await api('/api/admin/headless/collections-api-test', {
|
|
1838
|
+
method: 'POST',
|
|
1839
|
+
body: JSON.stringify(payload),
|
|
1840
|
+
});
|
|
1841
|
+
|
|
1842
|
+
const box = document.getElementById('api-test-result');
|
|
1843
|
+
box.classList.remove('hidden');
|
|
1844
|
+
document.getElementById('api-test-result-meta').textContent = `status=${result.status} durationMs=${result.durationMs}${result.bodyTruncated ? ' (audit body truncated)' : ''}`;
|
|
1845
|
+
document.getElementById('api-test-result-body').textContent = JSON.stringify(result.body, null, 2);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
function apiTestClear() {
|
|
1849
|
+
document.getElementById('api-test-limit').value = '';
|
|
1850
|
+
document.getElementById('api-test-skip').value = '';
|
|
1851
|
+
document.getElementById('api-test-populate').value = '';
|
|
1852
|
+
document.getElementById('api-test-id').value = '';
|
|
1853
|
+
document.getElementById('api-test-token').value = '';
|
|
1854
|
+
state.apiTestUi.filter = [];
|
|
1855
|
+
state.apiTestUi.sort = [];
|
|
1856
|
+
apiTestRenderPairs('api-test-filter-rows', 'filter');
|
|
1857
|
+
apiTestRenderPairs('api-test-sort-rows', 'sort');
|
|
1858
|
+
document.getElementById('api-test-result').classList.add('hidden');
|
|
1859
|
+
document.getElementById('api-test-result-meta').textContent = '';
|
|
1860
|
+
document.getElementById('api-test-result-body').textContent = '';
|
|
1861
|
+
apiTestRenderBodyFields();
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
// Init
|
|
1865
|
+
setTab('models');
|
|
1866
|
+
const originalLoadModels = loadModels;
|
|
1867
|
+
loadModels = async function() {
|
|
1868
|
+
await originalLoadModels();
|
|
1869
|
+
renderDataModelsList();
|
|
1870
|
+
renderDataSelectedHeader();
|
|
1871
|
+
apiTestRenderModelOptions();
|
|
1872
|
+
apiTestRenderBodyFields();
|
|
1873
|
+
};
|
|
1874
|
+
loadModels();
|
|
1875
|
+
|
|
1876
|
+
// Ensure copy handlers are attached after DOM is ready
|
|
1877
|
+
if (document.readyState === 'loading') {
|
|
1878
|
+
document.addEventListener('DOMContentLoaded', setupCurlCopyButtons);
|
|
1879
|
+
} else {
|
|
1880
|
+
setupCurlCopyButtons();
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
state.apiTestUi = { filter: [], sort: [] };
|
|
1884
|
+
|
|
1885
|
+
document.getElementById('api-test-op').addEventListener('change', () => {
|
|
1886
|
+
apiTestSetVisible();
|
|
1887
|
+
renderCurlExamples();
|
|
1888
|
+
});
|
|
1889
|
+
document.getElementById('api-test-model').addEventListener('change', () => {
|
|
1890
|
+
apiTestRenderBodyFields();
|
|
1891
|
+
renderCurlExamples();
|
|
1892
|
+
});
|
|
1893
|
+
|
|
1894
|
+
document.getElementById('btn-filter-add').addEventListener('click', () => {
|
|
1895
|
+
state.apiTestUi.filter.push({ key: '', value: '' });
|
|
1896
|
+
apiTestRenderPairs('api-test-filter-rows', 'filter');
|
|
1897
|
+
});
|
|
1898
|
+
document.getElementById('btn-sort-add').addEventListener('click', () => {
|
|
1899
|
+
state.apiTestUi.sort.push({ key: '', value: '1' });
|
|
1900
|
+
apiTestRenderPairs('api-test-sort-rows', 'sort');
|
|
1901
|
+
});
|
|
1902
|
+
|
|
1903
|
+
apiTestRenderPairs('api-test-filter-rows', 'filter');
|
|
1904
|
+
apiTestRenderPairs('api-test-sort-rows', 'sort');
|
|
1905
|
+
apiTestSetVisible();
|
|
1906
|
+
|
|
1907
|
+
document.getElementById('btn-api-test-execute').addEventListener('click', async () => {
|
|
1908
|
+
try {
|
|
1909
|
+
await apiTestExecute();
|
|
1910
|
+
toast('Executed', 'success');
|
|
1911
|
+
} catch (e) {
|
|
1912
|
+
toast(e.message, 'error');
|
|
1913
|
+
}
|
|
1914
|
+
});
|
|
1915
|
+
document.getElementById('btn-api-test-clear').addEventListener('click', apiTestClear);
|
|
1916
|
+
|
|
1917
|
+
// If apis tab is active on load, ensure tokens are loaded
|
|
1918
|
+
if (state.activeTab === 'apis') {
|
|
1919
|
+
loadTokens();
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
document.getElementById('btn-new-model').addEventListener('click', () => {
|
|
1923
|
+
state.selectedModel = {
|
|
1924
|
+
__isNew: true,
|
|
1925
|
+
codeIdentifier: '',
|
|
1926
|
+
displayName: '',
|
|
1927
|
+
fields: [],
|
|
1928
|
+
permissions: []
|
|
1929
|
+
};
|
|
1930
|
+
renderSelectedModel();
|
|
1931
|
+
document.getElementById('fields-tbody').innerHTML = '<tr><td class="py-3 text-gray-500" colspan="7">No fields yet</td></tr>';
|
|
1932
|
+
setModelFormEnabled(true, true);
|
|
1933
|
+
setModelMode('simple');
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
document.getElementById('btn-add-field').addEventListener('click', () => {
|
|
1937
|
+
if (!state.selectedModel) return;
|
|
1938
|
+
state.selectedModel.fields = Array.isArray(state.selectedModel.fields) ? state.selectedModel.fields : [];
|
|
1939
|
+
state.selectedModel.fields.push({ name: '', type: 'string', required: false, unique: false });
|
|
1940
|
+
renderFieldsTable();
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
document.getElementById('btn-mode-simple').addEventListener('click', () => setModelMode('simple'));
|
|
1944
|
+
document.getElementById('btn-mode-advanced').addEventListener('click', () => setModelMode('advanced'));
|
|
1945
|
+
document.getElementById('btn-mode-ai').addEventListener('click', () => setModelMode('ai'));
|
|
1946
|
+
|
|
1947
|
+
document.getElementById('btn-advanced-load').addEventListener('click', advancedLoadSelected);
|
|
1948
|
+
document.getElementById('btn-advanced-validate').addEventListener('click', advancedValidate);
|
|
1949
|
+
document.getElementById('btn-advanced-save').addEventListener('click', advancedSave);
|
|
1950
|
+
|
|
1951
|
+
document.getElementById('btn-ai-send').addEventListener('click', aiSend);
|
|
1952
|
+
document.getElementById('ai-input').addEventListener('keydown', (e) => {
|
|
1953
|
+
if (e.key !== 'Enter') return;
|
|
1954
|
+
e.preventDefault();
|
|
1955
|
+
aiSend();
|
|
1956
|
+
});
|
|
1957
|
+
document.getElementById('btn-ai-clear').addEventListener('click', aiClear);
|
|
1958
|
+
document.getElementById('btn-ai-apply').addEventListener('click', aiApply);
|
|
1959
|
+
|
|
1960
|
+
document.getElementById('btn-save-model').addEventListener('click', saveModel);
|
|
1961
|
+
document.getElementById('btn-delete-model').addEventListener('click', disableModel);
|
|
1962
|
+
|
|
1963
|
+
document.getElementById('model-displayName').addEventListener('input', () => {
|
|
1964
|
+
if (!state.selectedModel) return;
|
|
1965
|
+
state.selectedModel.displayName = document.getElementById('model-displayName').value;
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1968
|
+
document.getElementById('model-code').addEventListener('input', () => {
|
|
1969
|
+
if (!state.selectedModel) return;
|
|
1970
|
+
state.selectedModel.codeIdentifier = document.getElementById('model-code').value;
|
|
1971
|
+
});
|
|
1972
|
+
|
|
1973
|
+
document.getElementById('btn-data-refresh-models').addEventListener('click', async () => {
|
|
1974
|
+
await loadModels();
|
|
1975
|
+
renderDataModelsList();
|
|
1976
|
+
});
|
|
1977
|
+
|
|
1978
|
+
document.getElementById('btn-data-apply').addEventListener('click', async () => {
|
|
1979
|
+
state.dataUi.filterRaw = document.getElementById('data-filter').value;
|
|
1980
|
+
state.dataUi.skip = 0;
|
|
1981
|
+
await loadDataPage();
|
|
1982
|
+
});
|
|
1983
|
+
|
|
1984
|
+
document.getElementById('btn-data-clear').addEventListener('click', async () => {
|
|
1985
|
+
document.getElementById('data-filter').value = '';
|
|
1986
|
+
state.dataUi.filterRaw = '';
|
|
1987
|
+
state.dataUi.skip = 0;
|
|
1988
|
+
await loadDataPage();
|
|
1989
|
+
});
|
|
1990
|
+
|
|
1991
|
+
document.getElementById('btn-data-new-row').addEventListener('click', () => newRow());
|
|
1992
|
+
|
|
1993
|
+
document.getElementById('btn-page-prev').addEventListener('click', async () => {
|
|
1994
|
+
state.dataUi.skip = Math.max(0, Number(state.dataUi.skip || 0) - Number(state.dataUi.limit || 25));
|
|
1995
|
+
await loadDataPage();
|
|
1996
|
+
});
|
|
1997
|
+
|
|
1998
|
+
document.getElementById('btn-page-next').addEventListener('click', async () => {
|
|
1999
|
+
state.dataUi.skip = Number(state.dataUi.skip || 0) + Number(state.dataUi.limit || 25);
|
|
2000
|
+
await loadDataPage();
|
|
2001
|
+
});
|
|
2002
|
+
|
|
2003
|
+
document.getElementById('btn-add-permission').addEventListener('click', () => {
|
|
2004
|
+
state.permissionsDraft.push({ modelCode: '', operations: ['read'] });
|
|
2005
|
+
renderPermissionsDraft();
|
|
2006
|
+
});
|
|
2007
|
+
|
|
2008
|
+
document.getElementById('btn-create-token').addEventListener('click', createToken);
|
|
2009
|
+
document.getElementById('btn-new-token').addEventListener('click', () => {
|
|
2010
|
+
document.getElementById('created-token').classList.add('hidden');
|
|
2011
|
+
document.getElementById('created-token-value').textContent = '';
|
|
2012
|
+
document.getElementById('token-name').value = '';
|
|
2013
|
+
document.getElementById('token-ttl').value = '';
|
|
2014
|
+
state.permissionsDraft = [];
|
|
2015
|
+
renderPermissionsDraft();
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
</script>
|
|
2019
|
+
</body>
|
|
2020
|
+
</html>
|