@intranefr/superbackend 1.4.4 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/.env.example +5 -0
  2. package/README.md +11 -0
  3. package/index.js +39 -1
  4. package/package.json +11 -3
  5. package/public/sdk/ui-components.iife.js +191 -0
  6. package/sdk/ui-components/browser/src/index.js +228 -0
  7. package/src/admin/endpointRegistry.js +120 -0
  8. package/src/controllers/admin.controller.js +111 -5
  9. package/src/controllers/adminBlockDefinitions.controller.js +127 -0
  10. package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
  11. package/src/controllers/adminCache.controller.js +342 -0
  12. package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
  13. package/src/controllers/adminCrons.controller.js +388 -0
  14. package/src/controllers/adminDbBrowser.controller.js +124 -0
  15. package/src/controllers/adminEjsVirtual.controller.js +13 -3
  16. package/src/controllers/adminHeadless.controller.js +91 -2
  17. package/src/controllers/adminHealthChecks.controller.js +570 -0
  18. package/src/controllers/adminI18n.controller.js +51 -29
  19. package/src/controllers/adminLlm.controller.js +126 -2
  20. package/src/controllers/adminPages.controller.js +720 -0
  21. package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
  22. package/src/controllers/adminProxy.controller.js +113 -0
  23. package/src/controllers/adminRateLimits.controller.js +138 -0
  24. package/src/controllers/adminRbac.controller.js +803 -0
  25. package/src/controllers/adminScripts.controller.js +320 -0
  26. package/src/controllers/adminSeoConfig.controller.js +71 -48
  27. package/src/controllers/adminTerminals.controller.js +39 -0
  28. package/src/controllers/adminUiComponents.controller.js +315 -0
  29. package/src/controllers/adminUiComponentsAi.controller.js +34 -0
  30. package/src/controllers/blogAdmin.controller.js +279 -0
  31. package/src/controllers/blogAiAdmin.controller.js +224 -0
  32. package/src/controllers/blogAutomationAdmin.controller.js +141 -0
  33. package/src/controllers/blogInternal.controller.js +26 -0
  34. package/src/controllers/blogPublic.controller.js +89 -0
  35. package/src/controllers/fileManager.controller.js +190 -0
  36. package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
  37. package/src/controllers/healthChecksPublic.controller.js +196 -0
  38. package/src/controllers/metrics.controller.js +64 -4
  39. package/src/controllers/orgAdmin.controller.js +366 -0
  40. package/src/controllers/uiComponentsPublic.controller.js +118 -0
  41. package/src/middleware/auth.js +7 -0
  42. package/src/middleware/internalCronAuth.js +29 -0
  43. package/src/middleware/rbac.js +62 -0
  44. package/src/middleware.js +879 -56
  45. package/src/models/BlockDefinition.js +27 -0
  46. package/src/models/BlogAutomationLock.js +14 -0
  47. package/src/models/BlogAutomationRun.js +39 -0
  48. package/src/models/BlogPost.js +42 -0
  49. package/src/models/CacheEntry.js +26 -0
  50. package/src/models/ConsoleEntry.js +32 -0
  51. package/src/models/ConsoleLog.js +23 -0
  52. package/src/models/ContextBlockDefinition.js +33 -0
  53. package/src/models/CronExecution.js +47 -0
  54. package/src/models/CronJob.js +70 -0
  55. package/src/models/ExternalDbConnection.js +49 -0
  56. package/src/models/FileEntry.js +22 -0
  57. package/src/models/HeadlessModelDefinition.js +10 -0
  58. package/src/models/HealthAutoHealAttempt.js +57 -0
  59. package/src/models/HealthCheck.js +132 -0
  60. package/src/models/HealthCheckRun.js +51 -0
  61. package/src/models/HealthIncident.js +49 -0
  62. package/src/models/Page.js +95 -0
  63. package/src/models/PageCollection.js +42 -0
  64. package/src/models/ProxyEntry.js +66 -0
  65. package/src/models/RateLimitCounter.js +19 -0
  66. package/src/models/RateLimitMetricBucket.js +20 -0
  67. package/src/models/RbacGrant.js +25 -0
  68. package/src/models/RbacGroup.js +16 -0
  69. package/src/models/RbacGroupMember.js +13 -0
  70. package/src/models/RbacGroupRole.js +13 -0
  71. package/src/models/RbacRole.js +25 -0
  72. package/src/models/RbacUserRole.js +13 -0
  73. package/src/models/ScriptDefinition.js +42 -0
  74. package/src/models/ScriptRun.js +22 -0
  75. package/src/models/UiComponent.js +29 -0
  76. package/src/models/UiComponentProject.js +26 -0
  77. package/src/models/UiComponentProjectComponent.js +18 -0
  78. package/src/routes/admin.routes.js +1 -0
  79. package/src/routes/adminBlog.routes.js +21 -0
  80. package/src/routes/adminBlogAi.routes.js +16 -0
  81. package/src/routes/adminBlogAutomation.routes.js +27 -0
  82. package/src/routes/adminCache.routes.js +20 -0
  83. package/src/routes/adminConsoleManager.routes.js +302 -0
  84. package/src/routes/adminCrons.routes.js +25 -0
  85. package/src/routes/adminDbBrowser.routes.js +65 -0
  86. package/src/routes/adminEjsVirtual.routes.js +2 -1
  87. package/src/routes/adminHeadless.routes.js +8 -1
  88. package/src/routes/adminHealthChecks.routes.js +28 -0
  89. package/src/routes/adminI18n.routes.js +4 -3
  90. package/src/routes/adminLlm.routes.js +4 -2
  91. package/src/routes/adminPages.routes.js +55 -0
  92. package/src/routes/adminProxy.routes.js +15 -0
  93. package/src/routes/adminRateLimits.routes.js +17 -0
  94. package/src/routes/adminRbac.routes.js +38 -0
  95. package/src/routes/adminScripts.routes.js +21 -0
  96. package/src/routes/adminSeoConfig.routes.js +5 -4
  97. package/src/routes/adminTerminals.routes.js +13 -0
  98. package/src/routes/adminUiComponents.routes.js +30 -0
  99. package/src/routes/blogInternal.routes.js +14 -0
  100. package/src/routes/blogPublic.routes.js +9 -0
  101. package/src/routes/fileManager.routes.js +62 -0
  102. package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
  103. package/src/routes/healthChecksPublic.routes.js +9 -0
  104. package/src/routes/log.routes.js +43 -60
  105. package/src/routes/metrics.routes.js +4 -2
  106. package/src/routes/orgAdmin.routes.js +6 -0
  107. package/src/routes/pages.routes.js +123 -0
  108. package/src/routes/proxy.routes.js +46 -0
  109. package/src/routes/rbac.routes.js +47 -0
  110. package/src/routes/uiComponentsPublic.routes.js +9 -0
  111. package/src/routes/webhook.routes.js +2 -1
  112. package/src/routes/workflows.routes.js +4 -0
  113. package/src/services/blockDefinitionsAi.service.js +247 -0
  114. package/src/services/blog.service.js +99 -0
  115. package/src/services/blogAutomation.service.js +978 -0
  116. package/src/services/blogCronsBootstrap.service.js +184 -0
  117. package/src/services/blogPublishing.service.js +58 -0
  118. package/src/services/cacheLayer.service.js +696 -0
  119. package/src/services/consoleManager.service.js +700 -0
  120. package/src/services/consoleOverride.service.js +6 -1
  121. package/src/services/cronScheduler.service.js +350 -0
  122. package/src/services/dbBrowser.service.js +536 -0
  123. package/src/services/ejsVirtual.service.js +102 -32
  124. package/src/services/fileManager.service.js +475 -0
  125. package/src/services/fileManagerStoragePolicy.service.js +285 -0
  126. package/src/services/headlessExternalModels.service.js +292 -0
  127. package/src/services/headlessModels.service.js +26 -6
  128. package/src/services/healthChecks.service.js +650 -0
  129. package/src/services/healthChecksBootstrap.service.js +109 -0
  130. package/src/services/healthChecksScheduler.service.js +106 -0
  131. package/src/services/llmDefaults.service.js +190 -0
  132. package/src/services/migrationAssets/s3.js +2 -2
  133. package/src/services/pages.service.js +602 -0
  134. package/src/services/pagesContext.service.js +331 -0
  135. package/src/services/pagesContextBlocksAi.service.js +349 -0
  136. package/src/services/proxy.service.js +535 -0
  137. package/src/services/rateLimiter.service.js +623 -0
  138. package/src/services/rbac.service.js +212 -0
  139. package/src/services/scriptsRunner.service.js +259 -0
  140. package/src/services/terminals.service.js +152 -0
  141. package/src/services/terminalsWs.service.js +100 -0
  142. package/src/services/uiComponentsAi.service.js +299 -0
  143. package/src/services/uiComponentsCrypto.service.js +39 -0
  144. package/src/services/workflow.service.js +23 -8
  145. package/src/utils/orgRoles.js +14 -0
  146. package/src/utils/rbac/engine.js +60 -0
  147. package/src/utils/rbac/rightsRegistry.js +29 -0
  148. package/views/admin-blog-automation.ejs +877 -0
  149. package/views/admin-blog-edit.ejs +542 -0
  150. package/views/admin-blog.ejs +399 -0
  151. package/views/admin-cache.ejs +681 -0
  152. package/views/admin-console-manager.ejs +680 -0
  153. package/views/admin-crons.ejs +645 -0
  154. package/views/admin-db-browser.ejs +445 -0
  155. package/views/admin-ejs-virtual.ejs +16 -10
  156. package/views/admin-file-manager.ejs +942 -0
  157. package/views/admin-headless.ejs +294 -24
  158. package/views/admin-health-checks.ejs +725 -0
  159. package/views/admin-i18n.ejs +59 -5
  160. package/views/admin-llm.ejs +99 -1
  161. package/views/admin-organizations.ejs +528 -10
  162. package/views/admin-pages.ejs +2424 -0
  163. package/views/admin-proxy.ejs +491 -0
  164. package/views/admin-rate-limiter.ejs +625 -0
  165. package/views/admin-rbac.ejs +1331 -0
  166. package/views/admin-scripts.ejs +497 -0
  167. package/views/admin-seo-config.ejs +61 -7
  168. package/views/admin-terminals.ejs +328 -0
  169. package/views/admin-ui-components.ejs +741 -0
  170. package/views/admin-users.ejs +261 -4
  171. package/views/admin-workflows.ejs +7 -7
  172. package/views/file-manager.ejs +866 -0
  173. package/views/pages/blocks/contact.ejs +27 -0
  174. package/views/pages/blocks/cta.ejs +18 -0
  175. package/views/pages/blocks/faq.ejs +20 -0
  176. package/views/pages/blocks/features.ejs +19 -0
  177. package/views/pages/blocks/hero.ejs +13 -0
  178. package/views/pages/blocks/html.ejs +5 -0
  179. package/views/pages/blocks/image.ejs +14 -0
  180. package/views/pages/blocks/testimonials.ejs +26 -0
  181. package/views/pages/blocks/text.ejs +10 -0
  182. package/views/pages/layouts/default.ejs +51 -0
  183. package/views/pages/layouts/minimal.ejs +42 -0
  184. package/views/pages/layouts/sidebar.ejs +54 -0
  185. package/views/pages/partials/footer.ejs +13 -0
  186. package/views/pages/partials/header.ejs +12 -0
  187. package/views/pages/partials/sidebar.ejs +8 -0
  188. package/views/pages/runtime/page.ejs +10 -0
  189. package/views/pages/templates/article.ejs +20 -0
  190. package/views/pages/templates/default.ejs +12 -0
  191. package/views/pages/templates/landing.ejs +14 -0
  192. package/views/pages/templates/listing.ejs +15 -0
  193. package/views/partials/admin-image-upload-modal.ejs +221 -0
  194. package/views/partials/dashboard/nav-items.ejs +14 -0
  195. package/views/partials/llm-provider-model-picker.ejs +183 -0
@@ -0,0 +1,542 @@
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>Blog Post - Admin</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
9
+ <style>
10
+ .toast { animation: slideIn 0.3s ease-out; }
11
+ @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
12
+ .fade-out { animation: fadeOut 0.3s ease-out forwards; }
13
+ @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
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-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
20
+ <div class="flex items-center justify-between">
21
+ <div>
22
+ <h1 id="page-title" class="text-2xl font-bold text-gray-900">Blog Post</h1>
23
+ <p id="page-subtitle" class="text-sm text-gray-600 mt-1">-</p>
24
+ </div>
25
+ <div class="flex items-center gap-2 flex-wrap">
26
+ <a href="<%= adminPath %>/blog?tab=posts" class="bg-gray-100 text-gray-800 px-3 py-2 rounded hover:bg-gray-200">
27
+ <i class="ti ti-arrow-left mr-1"></i>Back
28
+ </a>
29
+ <button id="btn-save" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
30
+ <i class="ti ti-device-floppy mr-2"></i>Save
31
+ </button>
32
+ <button id="btn-publish" class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 hidden">
33
+ <i class="ti ti-world mr-2"></i>Publish
34
+ </button>
35
+ <button id="btn-unpublish" class="bg-orange-500 text-white px-4 py-2 rounded hover:bg-orange-600 hidden">
36
+ <i class="ti ti-eye-off mr-2"></i>Unpublish
37
+ </button>
38
+ <button id="btn-archive" class="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 hidden">
39
+ <i class="ti ti-archive mr-2"></i>Archive
40
+ </button>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ </div>
45
+
46
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
47
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
48
+ <div class="lg:col-span-2 space-y-6">
49
+ <div class="bg-white rounded-lg shadow p-6">
50
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
51
+ <div class="md:col-span-2">
52
+ <label class="block text-sm font-medium text-gray-700 mb-1">Title</label>
53
+ <input id="field-title" type="text" class="w-full border rounded px-3 py-2" placeholder="Post title" />
54
+ </div>
55
+ <div class="md:col-span-2">
56
+ <label class="block text-sm font-medium text-gray-700 mb-1">Slug</label>
57
+ <input id="field-slug" type="text" class="w-full border rounded px-3 py-2" placeholder="post-slug" />
58
+ </div>
59
+ <div class="md:col-span-2">
60
+ <label class="block text-sm font-medium text-gray-700 mb-1">Excerpt</label>
61
+ <textarea id="field-excerpt" class="w-full border rounded px-3 py-2" rows="3" placeholder="Short excerpt..."></textarea>
62
+ </div>
63
+ </div>
64
+ </div>
65
+
66
+ <div class="bg-white rounded-lg shadow p-6">
67
+ <div class="flex items-center justify-between mb-2">
68
+ <label class="block text-sm font-medium text-gray-700">Markdown</label>
69
+ <div class="flex gap-2">
70
+ <button id="btn-ai-format" class="bg-gray-100 text-gray-800 px-3 py-1.5 rounded hover:bg-gray-200 text-sm">
71
+ Format
72
+ </button>
73
+ <button id="btn-ai-refine" class="bg-gray-100 text-gray-800 px-3 py-1.5 rounded hover:bg-gray-200 text-sm">
74
+ Refine
75
+ </button>
76
+ </div>
77
+ </div>
78
+ <textarea id="field-markdown" class="w-full border rounded px-3 py-2 font-mono text-sm" rows="18" placeholder="# Hello world"></textarea>
79
+ </div>
80
+
81
+ <div class="bg-white rounded-lg shadow p-6">
82
+ <label class="block text-sm font-medium text-gray-700 mb-1">HTML (optional)</label>
83
+ <textarea id="field-html" class="w-full border rounded px-3 py-2 font-mono text-sm" rows="10" placeholder="<p>Rendered html...</p>"></textarea>
84
+ </div>
85
+ </div>
86
+
87
+ <div class="space-y-6">
88
+ <div class="bg-white rounded-lg shadow p-6">
89
+ <div class="grid grid-cols-1 gap-4">
90
+ <div>
91
+ <label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
92
+ <div id="status-badge" class="text-sm text-gray-600">-</div>
93
+ </div>
94
+
95
+ <div>
96
+ <label class="block text-sm font-medium text-gray-700 mb-1">Scheduled At</label>
97
+ <input id="field-scheduledAt" type="datetime-local" class="w-full border rounded px-3 py-2" />
98
+ <button id="btn-schedule" class="mt-2 bg-blue-500 text-white px-3 py-2 rounded hover:bg-blue-600 w-full">
99
+ Schedule
100
+ </button>
101
+ </div>
102
+
103
+ <div>
104
+ <label class="block text-sm font-medium text-gray-700 mb-1">Category</label>
105
+ <input id="field-category" type="text" class="w-full border rounded px-3 py-2" placeholder="category" />
106
+ </div>
107
+
108
+ <div>
109
+ <label class="block text-sm font-medium text-gray-700 mb-1">Tags (comma-separated)</label>
110
+ <input id="field-tags" type="text" class="w-full border rounded px-3 py-2" placeholder="tag1, tag2" />
111
+ </div>
112
+
113
+ <div>
114
+ <label class="block text-sm font-medium text-gray-700 mb-1">Author</label>
115
+ <input id="field-authorName" type="text" class="w-full border rounded px-3 py-2" placeholder="Author Name" />
116
+ </div>
117
+
118
+ <div>
119
+ <label class="block text-sm font-medium text-gray-700 mb-1">Cover Image URL</label>
120
+ <div class="flex gap-2">
121
+ <input id="field-coverImageUrl" type="text" class="flex-1 border rounded px-3 py-2" placeholder="/public/assets/..." />
122
+ <button id="btn-upload-cover" class="bg-gray-100 text-gray-800 px-3 py-2 rounded hover:bg-gray-200">
123
+ Upload
124
+ </button>
125
+ </div>
126
+ <div id="cover-preview" class="mt-3 hidden">
127
+ <img id="cover-preview-img" class="w-full rounded border" alt="Cover preview" />
128
+ </div>
129
+ </div>
130
+ </div>
131
+ </div>
132
+
133
+ <div class="bg-white rounded-lg shadow p-6">
134
+ <div class="flex items-center justify-between mb-2">
135
+ <h3 class="text-sm font-semibold text-gray-900">SEO</h3>
136
+ <button id="btn-ai-generate-all" class="bg-gray-100 text-gray-800 px-3 py-1.5 rounded hover:bg-gray-200 text-sm">
137
+ Generate all
138
+ </button>
139
+ </div>
140
+ <div class="grid grid-cols-1 gap-3">
141
+ <div>
142
+ <label class="block text-sm font-medium text-gray-700 mb-1">SEO Title</label>
143
+ <div class="flex gap-2">
144
+ <input id="field-seoTitle" type="text" class="flex-1 border rounded px-3 py-2" />
145
+ <button data-ai-field="seoTitle" class="btn-ai-field bg-gray-100 text-gray-800 px-3 py-2 rounded hover:bg-gray-200">AI</button>
146
+ </div>
147
+ </div>
148
+ <div>
149
+ <label class="block text-sm font-medium text-gray-700 mb-1">SEO Description</label>
150
+ <div class="flex gap-2">
151
+ <textarea id="field-seoDescription" class="flex-1 border rounded px-3 py-2" rows="3"></textarea>
152
+ <button data-ai-field="seoDescription" class="btn-ai-field bg-gray-100 text-gray-800 px-3 py-2 rounded hover:bg-gray-200">AI</button>
153
+ </div>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ </div>
159
+ </div>
160
+ </div>
161
+
162
+ <div id="toast-container" class="fixed top-4 right-4 z-50 space-y-2"></div>
163
+
164
+ <%- include('partials/admin-image-upload-modal', { baseUrl: baseUrl }) %>
165
+
166
+ <script>
167
+ const MODE = '<%= mode %>';
168
+ const POST_ID = '<%= postId %>';
169
+ const API_BASE = '<%= baseUrl %>/api/admin/blog-posts';
170
+
171
+ function showToast(message, type = 'success') {
172
+ const container = document.getElementById('toast-container');
173
+ const toast = document.createElement('div');
174
+ toast.className = `toast p-4 rounded shadow-lg ${
175
+ type === 'success' ? 'bg-green-500 text-white' :
176
+ type === 'error' ? 'bg-red-500 text-white' :
177
+ 'bg-blue-500 text-white'
178
+ }`;
179
+ toast.innerHTML = `
180
+ <div class="flex items-center">
181
+ <i class="ti ti-${type === 'success' ? 'check' : type === 'error' ? 'alert-triangle' : 'info'} mr-2"></i>
182
+ <span>${message}</span>
183
+ </div>
184
+ `;
185
+ container.appendChild(toast);
186
+ setTimeout(() => {
187
+ toast.classList.add('fade-out');
188
+ setTimeout(() => toast.remove(), 300);
189
+ }, 3000);
190
+ }
191
+
192
+ function setStatusBadge(status) {
193
+ const el = document.getElementById('status-badge');
194
+ const map = {
195
+ draft: 'bg-gray-100 text-gray-800',
196
+ scheduled: 'bg-blue-100 text-blue-800',
197
+ published: 'bg-green-100 text-green-800',
198
+ archived: 'bg-red-100 text-red-800',
199
+ };
200
+ const cls = map[status] || 'bg-gray-100 text-gray-800';
201
+ el.innerHTML = `<span class="px-2 py-1 text-xs font-medium rounded-full ${cls}">${status || '-'}</span>`;
202
+ }
203
+
204
+ function getValue(id) {
205
+ const el = document.getElementById(id);
206
+ return el ? el.value : '';
207
+ }
208
+
209
+ function setValue(id, value) {
210
+ const el = document.getElementById(id);
211
+ if (el) el.value = value == null ? '' : String(value);
212
+ }
213
+
214
+ function toDateTimeLocalValue(dateStr) {
215
+ if (!dateStr) return '';
216
+ const d = new Date(dateStr);
217
+ if (Number.isNaN(d.getTime())) return '';
218
+ const pad = (n) => String(n).padStart(2, '0');
219
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
220
+ }
221
+
222
+ function updateCoverPreview(url) {
223
+ const wrap = document.getElementById('cover-preview');
224
+ const img = document.getElementById('cover-preview-img');
225
+ if (!wrap || !img) return;
226
+ const u = String(url || '').trim();
227
+ if (!u) {
228
+ wrap.classList.add('hidden');
229
+ img.src = '';
230
+ return;
231
+ }
232
+ wrap.classList.remove('hidden');
233
+ img.src = u;
234
+ }
235
+
236
+ function payloadFromForm() {
237
+ return {
238
+ title: getValue('field-title'),
239
+ slug: getValue('field-slug'),
240
+ excerpt: getValue('field-excerpt'),
241
+ markdown: getValue('field-markdown'),
242
+ html: getValue('field-html'),
243
+ coverImageUrl: getValue('field-coverImageUrl'),
244
+ category: getValue('field-category'),
245
+ tags: getValue('field-tags'),
246
+ authorName: getValue('field-authorName'),
247
+ seoTitle: getValue('field-seoTitle'),
248
+ seoDescription: getValue('field-seoDescription'),
249
+ };
250
+ }
251
+
252
+ async function loadPost() {
253
+ if (!POST_ID) return;
254
+ const res = await fetch(`${API_BASE}/${POST_ID}`);
255
+ const data = await res.json().catch(() => ({}));
256
+ if (!res.ok) throw new Error(data?.error || 'Failed to load post');
257
+ const item = data?.item || {};
258
+
259
+ setValue('field-title', item.title);
260
+ setValue('field-slug', item.slug);
261
+ setValue('field-excerpt', item.excerpt);
262
+ setValue('field-markdown', item.markdown);
263
+ setValue('field-html', item.html);
264
+ setValue('field-coverImageUrl', item.coverImageUrl);
265
+ setValue('field-category', item.category);
266
+ setValue('field-tags', Array.isArray(item.tags) ? item.tags.join(', ') : item.tags);
267
+ setValue('field-authorName', item.authorName);
268
+ setValue('field-seoTitle', item.seoTitle);
269
+ setValue('field-seoDescription', item.seoDescription);
270
+ setValue('field-scheduledAt', toDateTimeLocalValue(item.scheduledAt));
271
+
272
+ setStatusBadge(item.status);
273
+ updateCoverPreview(item.coverImageUrl);
274
+
275
+ const subtitle = document.getElementById('page-subtitle');
276
+ if (subtitle) subtitle.textContent = item.slug ? item.slug : `ID: ${item._id}`;
277
+
278
+ updateActionButtons(item.status);
279
+ }
280
+
281
+ function updateActionButtons(status) {
282
+ const publishBtn = document.getElementById('btn-publish');
283
+ const unpublishBtn = document.getElementById('btn-unpublish');
284
+ const archiveBtn = document.getElementById('btn-archive');
285
+
286
+ const show = (btn, yes) => {
287
+ if (!btn) return;
288
+ if (yes) btn.classList.remove('hidden');
289
+ else btn.classList.add('hidden');
290
+ };
291
+
292
+ const hasId = Boolean(POST_ID);
293
+ show(publishBtn, hasId && (status === 'draft' || status === 'scheduled'));
294
+ show(unpublishBtn, hasId && status === 'published');
295
+ show(archiveBtn, hasId && status !== 'archived');
296
+ }
297
+
298
+ async function savePost() {
299
+ const payload = payloadFromForm();
300
+ if (!payload.title || !String(payload.title).trim()) {
301
+ showToast('Title is required', 'error');
302
+ return;
303
+ }
304
+ if (!payload.markdown || !String(payload.markdown).trim()) {
305
+ showToast('Markdown is required', 'error');
306
+ return;
307
+ }
308
+
309
+ const isNew = MODE === 'new' || !POST_ID;
310
+ const url = isNew ? API_BASE : `${API_BASE}/${POST_ID}`;
311
+ const method = isNew ? 'POST' : 'PUT';
312
+
313
+ const res = await fetch(url, {
314
+ method,
315
+ headers: { 'Content-Type': 'application/json' },
316
+ body: JSON.stringify(payload),
317
+ });
318
+ const data = await res.json().catch(() => ({}));
319
+ if (!res.ok) throw new Error(data?.error || 'Failed to save');
320
+
321
+ const item = data?.item;
322
+ showToast('Saved');
323
+
324
+ if (isNew && item?._id) {
325
+ window.location.href = `<%= adminPath %>/blog/edit/${item._id}`;
326
+ } else {
327
+ setStatusBadge(item?.status);
328
+ updateActionButtons(item?.status);
329
+ }
330
+ }
331
+
332
+ async function publishPost() {
333
+ if (!POST_ID) return;
334
+ const res = await fetch(`${API_BASE}/${POST_ID}/publish`, { method: 'PUT' });
335
+ const data = await res.json().catch(() => ({}));
336
+ if (!res.ok) throw new Error(data?.error || 'Failed to publish');
337
+ showToast('Published');
338
+ setStatusBadge(data?.item?.status);
339
+ updateActionButtons(data?.item?.status);
340
+ }
341
+
342
+ async function unpublishPost() {
343
+ if (!POST_ID) return;
344
+ const res = await fetch(`${API_BASE}/${POST_ID}/unpublish`, { method: 'PUT' });
345
+ const data = await res.json().catch(() => ({}));
346
+ if (!res.ok) throw new Error(data?.error || 'Failed to unpublish');
347
+ showToast('Unpublished');
348
+ setStatusBadge(data?.item?.status);
349
+ updateActionButtons(data?.item?.status);
350
+ }
351
+
352
+ async function archivePost() {
353
+ if (!POST_ID) return;
354
+ if (!confirm('Archive this post?')) return;
355
+ const res = await fetch(`${API_BASE}/${POST_ID}/archive`, { method: 'PUT' });
356
+ const data = await res.json().catch(() => ({}));
357
+ if (!res.ok) throw new Error(data?.error || 'Failed to archive');
358
+ showToast('Archived');
359
+ setStatusBadge(data?.item?.status);
360
+ updateActionButtons(data?.item?.status);
361
+ }
362
+
363
+ async function schedulePost() {
364
+ if (!POST_ID) {
365
+ showToast('Save the post first', 'error');
366
+ return;
367
+ }
368
+ const raw = getValue('field-scheduledAt');
369
+ if (!raw) {
370
+ showToast('Scheduled At is required', 'error');
371
+ return;
372
+ }
373
+ const scheduledAt = new Date(raw);
374
+ if (Number.isNaN(scheduledAt.getTime())) {
375
+ showToast('Scheduled At must be a valid date', 'error');
376
+ return;
377
+ }
378
+
379
+ const res = await fetch(`${API_BASE}/${POST_ID}/schedule`, {
380
+ method: 'PUT',
381
+ headers: { 'Content-Type': 'application/json' },
382
+ body: JSON.stringify({ scheduledAt: scheduledAt.toISOString() }),
383
+ });
384
+ const data = await res.json().catch(() => ({}));
385
+ if (!res.ok) throw new Error(data?.error || 'Failed to schedule');
386
+ showToast('Scheduled');
387
+ setStatusBadge(data?.item?.status);
388
+ updateActionButtons(data?.item?.status);
389
+ }
390
+
391
+ async function aiGenerateField(field) {
392
+ if (!field) return;
393
+ const payload = {
394
+ field,
395
+ title: getValue('field-title'),
396
+ excerpt: getValue('field-excerpt'),
397
+ category: getValue('field-category'),
398
+ tags: getValue('field-tags'),
399
+ authorName: getValue('field-authorName'),
400
+ markdown: getValue('field-markdown'),
401
+ };
402
+
403
+ const res = await fetch('<%= baseUrl %>/api/admin/blog-ai/generate-field', {
404
+ method: 'POST',
405
+ headers: { 'Content-Type': 'application/json' },
406
+ body: JSON.stringify(payload),
407
+ });
408
+ const data = await res.json().catch(() => ({}));
409
+ if (!res.ok) throw new Error(data?.error || 'AI generate failed');
410
+
411
+ if (field === 'seoTitle') setValue('field-seoTitle', data?.value || '');
412
+ if (field === 'seoDescription') setValue('field-seoDescription', data?.value || '');
413
+ showToast('AI generated');
414
+ }
415
+
416
+ async function aiGenerateAll() {
417
+ const payload = {
418
+ title: getValue('field-title'),
419
+ excerpt: getValue('field-excerpt'),
420
+ category: getValue('field-category'),
421
+ tags: getValue('field-tags'),
422
+ authorName: getValue('field-authorName'),
423
+ markdown: getValue('field-markdown'),
424
+ };
425
+
426
+ const res = await fetch('<%= baseUrl %>/api/admin/blog-ai/generate-all', {
427
+ method: 'POST',
428
+ headers: { 'Content-Type': 'application/json' },
429
+ body: JSON.stringify(payload),
430
+ });
431
+ const data = await res.json().catch(() => ({}));
432
+ if (!res.ok) throw new Error(data?.error || 'AI generate-all failed');
433
+
434
+ if (data?.seoTitle !== undefined) setValue('field-seoTitle', data.seoTitle);
435
+ if (data?.seoDescription !== undefined) setValue('field-seoDescription', data.seoDescription);
436
+ if (data?.excerpt !== undefined) setValue('field-excerpt', data.excerpt);
437
+ if (data?.category !== undefined) setValue('field-category', data.category);
438
+ if (data?.tags !== undefined) setValue('field-tags', Array.isArray(data.tags) ? data.tags.join(', ') : data.tags);
439
+ if (data?.authorName !== undefined) setValue('field-authorName', data.authorName);
440
+ showToast('AI generated all');
441
+ }
442
+
443
+ async function aiFormatMarkdown() {
444
+ const res = await fetch('<%= baseUrl %>/api/admin/blog-ai/format-markdown', {
445
+ method: 'POST',
446
+ headers: { 'Content-Type': 'application/json' },
447
+ body: JSON.stringify({ markdown: getValue('field-markdown') }),
448
+ });
449
+ const data = await res.json().catch(() => ({}));
450
+ if (!res.ok) throw new Error(data?.error || 'AI format failed');
451
+ setValue('field-markdown', data?.markdown || '');
452
+ showToast('Formatted');
453
+ }
454
+
455
+ async function aiRefineMarkdown() {
456
+ const selectionStart = document.getElementById('field-markdown')?.selectionStart ?? null;
457
+ const selectionEnd = document.getElementById('field-markdown')?.selectionEnd ?? null;
458
+ const markdown = getValue('field-markdown');
459
+
460
+ const res = await fetch('<%= baseUrl %>/api/admin/blog-ai/refine-markdown', {
461
+ method: 'POST',
462
+ headers: { 'Content-Type': 'application/json' },
463
+ body: JSON.stringify({ markdown, selectionStart, selectionEnd }),
464
+ });
465
+ const data = await res.json().catch(() => ({}));
466
+ if (!res.ok) throw new Error(data?.error || 'AI refine failed');
467
+ setValue('field-markdown', data?.markdown || '');
468
+ showToast('Refined');
469
+ }
470
+
471
+ document.getElementById('btn-save').addEventListener('click', () => {
472
+ savePost().catch((e) => showToast(e?.message ? String(e.message) : 'Save failed', 'error'));
473
+ });
474
+
475
+ document.getElementById('btn-publish').addEventListener('click', () => {
476
+ publishPost().catch((e) => showToast(e?.message ? String(e.message) : 'Publish failed', 'error'));
477
+ });
478
+
479
+ document.getElementById('btn-unpublish').addEventListener('click', () => {
480
+ unpublishPost().catch((e) => showToast(e?.message ? String(e.message) : 'Unpublish failed', 'error'));
481
+ });
482
+
483
+ document.getElementById('btn-archive').addEventListener('click', () => {
484
+ archivePost().catch((e) => showToast(e?.message ? String(e.message) : 'Archive failed', 'error'));
485
+ });
486
+
487
+ document.getElementById('btn-schedule').addEventListener('click', () => {
488
+ schedulePost().catch((e) => showToast(e?.message ? String(e.message) : 'Schedule failed', 'error'));
489
+ });
490
+
491
+ document.getElementById('field-coverImageUrl').addEventListener('input', (e) => {
492
+ updateCoverPreview(e.target.value);
493
+ });
494
+
495
+ document.getElementById('btn-upload-cover').addEventListener('click', () => {
496
+ if (typeof window.openImageUploadModal === 'function') {
497
+ window.openImageUploadModal({
498
+ namespace: 'blog-images',
499
+ visibility: 'public',
500
+ onSelect: (url) => {
501
+ setValue('field-coverImageUrl', url);
502
+ updateCoverPreview(url);
503
+ },
504
+ });
505
+ }
506
+ });
507
+
508
+ document.querySelectorAll('.btn-ai-field').forEach((btn) => {
509
+ btn.addEventListener('click', () => {
510
+ const field = btn.getAttribute('data-ai-field');
511
+ aiGenerateField(field).catch((e) => showToast(e?.message ? String(e.message) : 'AI failed', 'error'));
512
+ });
513
+ });
514
+
515
+ document.getElementById('btn-ai-generate-all').addEventListener('click', () => {
516
+ aiGenerateAll().catch((e) => showToast(e?.message ? String(e.message) : 'AI failed', 'error'));
517
+ });
518
+
519
+ document.getElementById('btn-ai-format').addEventListener('click', () => {
520
+ aiFormatMarkdown().catch((e) => showToast(e?.message ? String(e.message) : 'AI failed', 'error'));
521
+ });
522
+
523
+ document.getElementById('btn-ai-refine').addEventListener('click', () => {
524
+ aiRefineMarkdown().catch((e) => showToast(e?.message ? String(e.message) : 'AI failed', 'error'));
525
+ });
526
+
527
+ (function init() {
528
+ const title = document.getElementById('page-title');
529
+ const subtitle = document.getElementById('page-subtitle');
530
+ if (MODE === 'new') {
531
+ if (title) title.textContent = 'New Blog Post';
532
+ if (subtitle) subtitle.textContent = 'Draft';
533
+ setStatusBadge('draft');
534
+ } else {
535
+ if (title) title.textContent = 'Edit Blog Post';
536
+ }
537
+
538
+ loadPost().catch((e) => showToast(e?.message ? String(e.message) : 'Failed to load', 'error'));
539
+ })();
540
+ </script>
541
+ </body>
542
+ </html>