@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,877 @@
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 Automation - 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
+ .json-editor { font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; }
15
+ </style>
16
+ </head>
17
+ <body class="bg-gray-100">
18
+ <div class="min-h-screen">
19
+ <div class="bg-white shadow">
20
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
21
+ <div class="flex justify-between items-center">
22
+ <div>
23
+ <h1 class="text-2xl font-bold text-gray-900">Blog Automation</h1>
24
+ <p class="text-sm text-gray-600 mt-1">Configure automated blog post generation</p>
25
+ </div>
26
+ <div class="flex items-center gap-4"></div>
27
+ </div>
28
+ </div>
29
+ </div>
30
+
31
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
32
+ <!-- Configuration Tabs -->
33
+ <div class="bg-white rounded-lg shadow">
34
+ <div class="border-b border-gray-200">
35
+ <nav class="flex -mb-px">
36
+ <button class="tab-btn px-6 py-3 border-b-2 border-blue-500 text-blue-600 font-medium text-sm" data-tab="config">
37
+ Configuration
38
+ </button>
39
+ <button class="tab-btn px-6 py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 font-medium text-sm" data-tab="style">
40
+ Style Guide
41
+ </button>
42
+ <button class="tab-btn px-6 py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 font-medium text-sm" data-tab="runs">
43
+ Run History
44
+ </button>
45
+ </nav>
46
+ </div>
47
+
48
+ <!-- Configuration Tab -->
49
+ <div id="tab-config" class="tab-content p-6">
50
+ <div class="mb-4">
51
+ <div class="flex flex-wrap items-end gap-3">
52
+ <div class="min-w-[280px]">
53
+ <label class="block text-sm font-medium text-gray-700 mb-2">Configuration</label>
54
+ <select id="config-select" class="w-full border rounded px-3 py-2 text-sm">
55
+ <option value="">Loading...</option>
56
+ </select>
57
+ <p class="text-xs text-gray-500 mt-1">Each configuration can have its own schedule and generation settings.</p>
58
+ </div>
59
+ <div class="flex gap-2">
60
+ <button id="btn-new-config" class="bg-gray-100 text-gray-800 px-3 py-2 rounded hover:bg-gray-200">
61
+ <i class="ti ti-plus mr-2"></i>New
62
+ </button>
63
+ <button id="btn-delete-config" class="bg-red-500 text-white px-3 py-2 rounded hover:bg-red-600">
64
+ <i class="ti ti-trash mr-2"></i>Delete
65
+ </button>
66
+ </div>
67
+ <div class="flex gap-2 ml-auto">
68
+ <button id="btn-run-now" class="bg-blue-500 text-white px-3 py-2 rounded hover:bg-blue-600 hidden">
69
+ <i class="ti ti-player-play mr-2"></i>Run Now
70
+ </button>
71
+ <button id="btn-load-config" class="bg-gray-100 text-gray-800 px-3 py-2 rounded hover:bg-gray-200">
72
+ <i class="ti ti-refresh mr-2"></i>Reload
73
+ </button>
74
+ <button id="btn-save-config" class="bg-green-500 text-white px-3 py-2 rounded hover:bg-green-600">
75
+ <i class="ti ti-device-floppy mr-2"></i>Save
76
+ </button>
77
+ </div>
78
+ </div>
79
+ </div>
80
+
81
+ <div id="cfg-actions-hint" class="mb-4 text-xs text-gray-500 hidden">Select a configuration to enable Run Now and prompt preview.</div>
82
+
83
+ <div class="border-b border-gray-200 mb-4">
84
+ <nav class="flex -mb-px">
85
+ <button id="subtab-btn-form" class="subtab-btn px-4 py-2 border-b-2 border-blue-500 text-blue-600 font-medium text-sm" data-subtab="form">Form</button>
86
+ <button id="subtab-btn-advanced" class="subtab-btn px-4 py-2 border-b-2 border-transparent text-gray-500 hover:text-gray-700 font-medium text-sm" data-subtab="advanced">Advanced (JSON)</button>
87
+ </nav>
88
+ </div>
89
+
90
+ <div id="subtab-form">
91
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
92
+ <div class="bg-gray-50 border rounded p-4">
93
+ <div class="flex items-center justify-between mb-3">
94
+ <h3 class="text-sm font-semibold text-gray-800">Basics</h3>
95
+ <span class="text-xs text-gray-500" title="Enable controls whether this config is eligible for scheduled runs.">info</span>
96
+ </div>
97
+
98
+ <div class="mb-3">
99
+ <label class="block text-sm font-medium text-gray-700 mb-1">Name</label>
100
+ <input id="cfg-name" type="text" class="w-full border rounded px-3 py-2 text-sm" placeholder="e.g. Micro-exits weekly" />
101
+ </div>
102
+
103
+ <div class="mb-3 flex items-center gap-2">
104
+ <input id="cfg-enabled" type="checkbox" class="h-4 w-4" />
105
+ <label for="cfg-enabled" class="text-sm text-gray-700">Enabled</label>
106
+ </div>
107
+
108
+ <div class="mb-3">
109
+ <label class="block text-sm font-medium text-gray-700 mb-1">Schedule mode</label>
110
+ <select id="cfg-schedule-managedBy" class="w-full border rounded px-3 py-2 text-sm">
111
+ <option value="cronScheduler">CronScheduler</option>
112
+ <option value="manualOnly">Manual only</option>
113
+ </select>
114
+ <p class="text-xs text-gray-500 mt-1">When set to Manual only, the system will delete the CronJob for this config.</p>
115
+ </div>
116
+
117
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
118
+ <div>
119
+ <label class="block text-sm font-medium text-gray-700 mb-1">Cron expression</label>
120
+ <input id="cfg-schedule-cronExpression" type="text" class="w-full border rounded px-3 py-2 text-sm" placeholder="0 9 * * 2,4" />
121
+ </div>
122
+ <div>
123
+ <label class="block text-sm font-medium text-gray-700 mb-1">Timezone</label>
124
+ <input id="cfg-schedule-timezone" type="text" class="w-full border rounded px-3 py-2 text-sm" placeholder="UTC" />
125
+ </div>
126
+ </div>
127
+ </div>
128
+
129
+ <div class="bg-gray-50 border rounded p-4">
130
+ <div class="flex items-center justify-between mb-3">
131
+ <h3 class="text-sm font-semibold text-gray-800">Defaults</h3>
132
+ <span class="text-xs text-gray-500" title="These values influence category/tags/author saved on created draft posts.">info</span>
133
+ </div>
134
+
135
+ <div class="mb-3">
136
+ <label class="block text-sm font-medium text-gray-700 mb-1">Default category</label>
137
+ <input id="cfg-defaultCategory" list="dl-categories" class="w-full border rounded px-3 py-2 text-sm" placeholder="e.g. Operations" />
138
+ </div>
139
+
140
+ <div class="mb-3">
141
+ <label class="block text-sm font-medium text-gray-700 mb-1">Default author</label>
142
+ <input id="cfg-defaultAuthorName" list="dl-authors" class="w-full border rounded px-3 py-2 text-sm" placeholder="e.g. superbackend" />
143
+ </div>
144
+
145
+ <div class="mb-3">
146
+ <label class="block text-sm font-medium text-gray-700 mb-1">Default tags (comma separated)</label>
147
+ <input id="cfg-defaultTags" class="w-full border rounded px-3 py-2 text-sm" placeholder="e.g. SaaS, Growth" />
148
+ <p class="text-xs text-gray-500 mt-1">Autocomplete suggestions come from existing blog posts.</p>
149
+ </div>
150
+ </div>
151
+ </div>
152
+
153
+ <div class="mt-6 bg-gray-50 border rounded p-4">
154
+ <div class="flex items-center justify-between mb-3">
155
+ <h3 class="text-sm font-semibold text-gray-800">Limits</h3>
156
+ <span class="text-xs text-gray-500" title="Guardrails to control automation frequency and output size.">info</span>
157
+ </div>
158
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
159
+ <div>
160
+ <label class="block text-sm font-medium text-gray-700 mb-1">Runs/day limit</label>
161
+ <input id="cfg-runsPerDayLimit" type="number" min="0" class="w-full border rounded px-3 py-2 text-sm" />
162
+ </div>
163
+ <div>
164
+ <label class="block text-sm font-medium text-gray-700 mb-1">Max posts/run</label>
165
+ <input id="cfg-maxPostsPerRun" type="number" min="1" class="w-full border rounded px-3 py-2 text-sm" />
166
+ </div>
167
+ <div>
168
+ <label class="block text-sm font-medium text-gray-700 mb-1">Dedupe window (days)</label>
169
+ <input id="cfg-dedupeWindowDays" type="number" min="0" class="w-full border rounded px-3 py-2 text-sm" />
170
+ </div>
171
+ </div>
172
+ </div>
173
+
174
+ <div class="mt-6 bg-gray-50 border rounded p-4">
175
+ <div class="flex items-center justify-between mb-3">
176
+ <h3 class="text-sm font-semibold text-gray-800">LLM (providers/models)</h3>
177
+ <span class="text-xs text-gray-500" title="Provider/model values are validated by the existing LLM config. Use autocomplete.">info</span>
178
+ </div>
179
+
180
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
181
+ <div>
182
+ <h4 class="text-sm font-semibold text-gray-800 mb-2">Research</h4>
183
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
184
+ <%- include('partials/llm-provider-model-picker', {
185
+ providerInputId: 'cfg-research-providerKey',
186
+ modelInputId: 'cfg-research-model',
187
+ providerLabel: 'Provider',
188
+ modelLabel: 'Model'
189
+ }) %>
190
+ </div>
191
+ </div>
192
+
193
+ <div>
194
+ <h4 class="text-sm font-semibold text-gray-800 mb-2">Text generation</h4>
195
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
196
+ <%- include('partials/llm-provider-model-picker', {
197
+ providerInputId: 'cfg-textGeneration-providerKey',
198
+ modelInputId: 'cfg-textGeneration-model',
199
+ providerLabel: 'Provider',
200
+ modelLabel: 'Model'
201
+ }) %>
202
+ </div>
203
+ </div>
204
+
205
+ <div>
206
+ <h4 class="text-sm font-semibold text-gray-800 mb-2">Image generation</h4>
207
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
208
+ <%- include('partials/llm-provider-model-picker', {
209
+ providerInputId: 'cfg-imageGeneration-providerKey',
210
+ modelInputId: 'cfg-imageGeneration-model',
211
+ providerLabel: 'Provider',
212
+ modelLabel: 'Model'
213
+ }) %>
214
+ </div>
215
+ </div>
216
+ </div>
217
+ </div>
218
+
219
+ <div class="mt-6 bg-gray-50 border rounded p-4">
220
+ <div class="flex items-center justify-between mb-3">
221
+ <h3 class="text-sm font-semibold text-gray-800">Style guide override (per config)</h3>
222
+ <span class="text-xs text-gray-500" title="Optional override for the selected configuration. If enabled, this replaces the global style guide for runs of that config.">info</span>
223
+ </div>
224
+ <div class="flex items-center gap-2 mb-3">
225
+ <input id="cfg-style-override-enabled" type="checkbox" class="h-4 w-4" />
226
+ <label for="cfg-style-override-enabled" class="text-sm text-gray-700">Enable override for selected config</label>
227
+ <button id="btn-save-style-override" class="bg-green-500 text-white px-3 py-2 rounded hover:bg-green-600 ml-auto">
228
+ <i class="ti ti-device-floppy mr-2"></i>Save override
229
+ </button>
230
+ </div>
231
+ <textarea id="cfg-style-override" class="w-full h-40 border rounded p-3 text-sm" placeholder="Override style guide for this config (optional)"></textarea>
232
+ <p class="text-xs text-gray-500 mt-1">If override is disabled, the global style guide is used.</p>
233
+ </div>
234
+
235
+ <div class="mt-6 bg-gray-50 border rounded p-4">
236
+ <div class="flex items-center justify-between mb-3">
237
+ <h3 class="text-sm font-semibold text-gray-800">Topics</h3>
238
+ <span class="text-xs text-gray-500" title="Weighted topic selection: higher weight increases probability.">info</span>
239
+ </div>
240
+ <textarea id="cfg-topics-json" class="json-editor w-full h-40 border rounded p-3 text-sm" spellcheck="false" placeholder='[{"key":"operations","label":"Operations","weight":4,"keywords":[]}]'></textarea>
241
+ <div class="text-xs text-gray-500 mt-2 space-y-1">
242
+ <div><strong>Selection:</strong> each run picks one topic using weighted randomness (higher <code>weight</code> = more likely).</div>
243
+ <div><strong>Shape:</strong> <code>{"key": "jokes", "label": "jokes", "weight": 4, "keywords": []}</code></div>
244
+ <div><strong>Iteration:</strong> changing weights changes how frequently topics appear across runs. <code>keywords</code> are currently used as flexible hints in prompts.</div>
245
+ </div>
246
+ </div>
247
+
248
+ <div class="mt-6 bg-gray-50 border rounded p-4">
249
+ <div class="flex items-center justify-between mb-3">
250
+ <h3 class="text-sm font-semibold text-gray-800">Images</h3>
251
+ <span class="text-xs text-gray-500" title="Optional prompt instruction used for image generation (cover/inline).">info</span>
252
+ </div>
253
+ <div class="mb-3">
254
+ <label class="block text-sm font-medium text-gray-700 mb-1">Image prompt extra instruction</label>
255
+ <textarea id="cfg-images-promptExtraInstruction" class="w-full h-24 border rounded p-3 text-sm" placeholder="Optional. Example: Use a flat minimal illustration style, pastel palette, no logos."></textarea>
256
+ </div>
257
+ </div>
258
+
259
+ <div class="mt-6 bg-gray-50 border rounded p-4">
260
+ <div class="flex items-center justify-between mb-3">
261
+ <h3 class="text-sm font-semibold text-gray-800">Prompt preview (readonly)</h3>
262
+ <button id="btn-preview-prompts" class="bg-gray-100 text-gray-800 px-3 py-2 rounded hover:bg-gray-200">
263
+ <i class="ti ti-refresh mr-2"></i>Refresh
264
+ </button>
265
+ </div>
266
+ <div class="grid grid-cols-1 gap-3">
267
+ <div>
268
+ <label class="block text-sm font-medium text-gray-700 mb-1">Post prompt</label>
269
+ <textarea id="preview-post-prompt" class="json-editor w-full h-40 border rounded p-3 text-sm" readonly></textarea>
270
+ </div>
271
+ <div>
272
+ <label class="block text-sm font-medium text-gray-700 mb-1">Cover image prompt</label>
273
+ <textarea id="preview-cover-prompt" class="json-editor w-full h-24 border rounded p-3 text-sm" readonly></textarea>
274
+ </div>
275
+ <div>
276
+ <label class="block text-sm font-medium text-gray-700 mb-1">Inline image prompt</label>
277
+ <textarea id="preview-inline-prompt" class="json-editor w-full h-24 border rounded p-3 text-sm" readonly></textarea>
278
+ </div>
279
+ </div>
280
+ </div>
281
+ </div>
282
+
283
+ <div id="subtab-advanced" class="hidden">
284
+ <div class="mb-4">
285
+ <label class="block text-sm font-medium text-gray-700 mb-2">Configuration (JSON)</label>
286
+ <textarea id="config-json" class="json-editor w-full h-96 border rounded p-3 text-sm" spellcheck="false"></textarea>
287
+ <p class="text-xs text-gray-500 mt-1">Advanced mode. Editing invalid JSON will prevent saving.</p>
288
+ </div>
289
+ </div>
290
+ </div>
291
+
292
+ <!-- Style Guide Tab -->
293
+ <div id="tab-style" class="tab-content p-6 hidden">
294
+ <div class="mb-4">
295
+ <button id="btn-load-style" class="bg-gray-100 text-gray-800 px-3 py-2 rounded hover:bg-gray-200">
296
+ <i class="ti ti-refresh mr-2"></i>Reload
297
+ </button>
298
+ <button id="btn-save-style" class="bg-green-500 text-white px-3 py-2 rounded hover:bg-green-600 ml-2">
299
+ <i class="ti ti-device-floppy mr-2"></i>Save
300
+ </button>
301
+ </div>
302
+ <div class="mb-4">
303
+ <label class="block text-sm font-medium text-gray-700 mb-2">Style Guide</label>
304
+ <textarea id="style-guide" class="w-full h-96 border rounded p-3 text-sm" placeholder="Enter writing style guide..."></textarea>
305
+ </div>
306
+ </div>
307
+
308
+ <!-- Run History Tab -->
309
+ <div id="tab-runs" class="tab-content p-6 hidden">
310
+ <div class="mb-4 flex justify-between items-center">
311
+ <h3 class="text-lg font-medium text-gray-900">Recent Runs</h3>
312
+ <button id="btn-refresh-runs" class="bg-gray-100 text-gray-800 px-3 py-2 rounded hover:bg-gray-200">
313
+ <i class="ti ti-refresh mr-2"></i>Refresh
314
+ </button>
315
+ </div>
316
+ <div class="overflow-x-auto">
317
+ <table class="min-w-full divide-y divide-gray-200">
318
+ <thead class="bg-gray-50">
319
+ <tr>
320
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Trigger</th>
321
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
322
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Started</th>
323
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Duration</th>
324
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Result</th>
325
+ </tr>
326
+ </thead>
327
+ <tbody id="runs-tbody" class="bg-white divide-y divide-gray-200">
328
+ </tbody>
329
+ </table>
330
+ </div>
331
+ </div>
332
+ </div>
333
+ </div>
334
+ </div>
335
+
336
+ <!-- Toast Container -->
337
+ <div id="toast-container" class="fixed top-4 right-4 z-50 space-y-2"></div>
338
+
339
+ <script>
340
+ const API_BASE = '<%= baseUrl %>/api/admin/blog-automation';
341
+ const BLOG_API_BASE = '<%= baseUrl %>/api/admin/blog-posts';
342
+ const LLM_API_BASE = '<%= baseUrl %>/api/admin/llm';
343
+
344
+ let configsCache = null;
345
+ let activeConfigId = '';
346
+ let suggestionsCache = null;
347
+ let llmCache = null;
348
+
349
+ // Utility functions
350
+ function showToast(message, type = 'success') {
351
+ const container = document.getElementById('toast-container');
352
+ const toast = document.createElement('div');
353
+ toast.className = `toast p-4 rounded shadow-lg ${
354
+ type === 'success' ? 'bg-green-500 text-white' :
355
+ type === 'error' ? 'bg-red-500 text-white' :
356
+ 'bg-blue-500 text-white'
357
+ }`;
358
+ toast.innerHTML = `
359
+ <div class="flex items-center">
360
+ <i class="ti ti-${type === 'success' ? 'check' : type === 'error' ? 'alert-triangle' : 'info'} mr-2"></i>
361
+ <span>${message}</span>
362
+ </div>
363
+ `;
364
+ container.appendChild(toast);
365
+ setTimeout(() => {
366
+ toast.classList.add('fade-out');
367
+ setTimeout(() => toast.remove(), 300);
368
+ }, 3000);
369
+ }
370
+
371
+ function formatDate(dateString) {
372
+ if (!dateString) return '-';
373
+ const date = new Date(dateString);
374
+ return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
375
+ }
376
+
377
+ function formatDuration(started, finished) {
378
+ if (!started || !finished) return '-';
379
+ const duration = new Date(finished) - new Date(started);
380
+ const seconds = Math.floor(duration / 1000);
381
+ if (seconds < 60) return `${seconds}s`;
382
+ const minutes = Math.floor(seconds / 60);
383
+ const remainingSeconds = seconds % 60;
384
+ return `${minutes}m ${remainingSeconds}s`;
385
+ }
386
+
387
+ function getStatusBadge(status) {
388
+ const badges = {
389
+ queued: '<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded-full">Queued</span>',
390
+ running: '<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded-full">Running</span>',
391
+ succeeded: '<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">Succeeded</span>',
392
+ failed: '<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">Failed</span>',
393
+ partial: '<span class="px-2 py-1 text-xs font-medium bg-orange-100 text-orange-800 rounded-full">Partial</span>',
394
+ skipped: '<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded-full">Skipped</span>'
395
+ };
396
+ return badges[status] || status;
397
+ }
398
+
399
+ // Tab switching
400
+ document.querySelectorAll('.tab-btn').forEach(btn => {
401
+ btn.addEventListener('click', () => {
402
+ const tabName = btn.dataset.tab;
403
+
404
+ // Update button styles
405
+ document.querySelectorAll('.tab-btn').forEach(b => {
406
+ b.classList.remove('border-blue-500', 'text-blue-600');
407
+ b.classList.add('border-transparent', 'text-gray-500');
408
+ });
409
+ btn.classList.remove('border-transparent', 'text-gray-500');
410
+ btn.classList.add('border-blue-500', 'text-blue-600');
411
+
412
+ // Show/hide content
413
+ document.querySelectorAll('.tab-content').forEach(content => {
414
+ content.classList.add('hidden');
415
+ });
416
+ document.getElementById(`tab-${tabName}`).classList.remove('hidden');
417
+
418
+ // Load data if needed
419
+ if (tabName === 'runs') {
420
+ loadRuns();
421
+ }
422
+ });
423
+ });
424
+
425
+ // Configuration
426
+ function getSelectedConfigId() {
427
+ return String(document.getElementById('config-select')?.value || '').trim();
428
+ }
429
+
430
+ function setSubtab(name) {
431
+ const formBtn = document.getElementById('subtab-btn-form');
432
+ const advBtn = document.getElementById('subtab-btn-advanced');
433
+ const form = document.getElementById('subtab-form');
434
+ const adv = document.getElementById('subtab-advanced');
435
+
436
+ const active = ['border-blue-500', 'text-blue-600'];
437
+ const inactive = ['border-transparent', 'text-gray-500', 'hover:text-gray-700'];
438
+
439
+ const setBtn = (btn, isActive) => {
440
+ if (!btn) return;
441
+ btn.classList.remove(...active, ...inactive);
442
+ if (isActive) btn.classList.add(...active);
443
+ else btn.classList.add(...inactive);
444
+ };
445
+
446
+ const isAdv = name === 'advanced';
447
+ setBtn(formBtn, !isAdv);
448
+ setBtn(advBtn, isAdv);
449
+ if (isAdv) {
450
+ form.classList.add('hidden');
451
+ adv.classList.remove('hidden');
452
+ } else {
453
+ adv.classList.add('hidden');
454
+ form.classList.remove('hidden');
455
+ }
456
+ }
457
+
458
+ async function initProviderPickers() {
459
+ if (!window.__llmProviderModelPicker || !window.__llmProviderModelPicker.init) return;
460
+ const apiBase = '<%= baseUrl %>';
461
+ await window.__llmProviderModelPicker.init({ apiBase, providerInputId: 'cfg-research-providerKey', modelInputId: 'cfg-research-model' });
462
+ await window.__llmProviderModelPicker.init({ apiBase, providerInputId: 'cfg-textGeneration-providerKey', modelInputId: 'cfg-textGeneration-model' });
463
+ await window.__llmProviderModelPicker.init({ apiBase, providerInputId: 'cfg-imageGeneration-providerKey', modelInputId: 'cfg-imageGeneration-model' });
464
+ }
465
+
466
+ function fillStyleOverrideFromConfig(cfg) {
467
+ const enabled = Boolean(String(cfg?.styleGuideOverride || '').trim());
468
+ const checkbox = document.getElementById('cfg-style-override-enabled');
469
+ const textarea = document.getElementById('cfg-style-override');
470
+ if (checkbox) checkbox.checked = enabled;
471
+ if (textarea) textarea.value = String(cfg?.styleGuideOverride || '');
472
+ }
473
+
474
+ document.querySelectorAll('.subtab-btn').forEach(btn => {
475
+ btn.addEventListener('click', () => setSubtab(btn.dataset.subtab));
476
+ });
477
+
478
+ async function loadConfigs() {
479
+ const response = await fetch(`${API_BASE}/configs`);
480
+ const data = await response.json();
481
+ if (!response.ok) throw new Error(data.error || 'Failed to load configs');
482
+ configsCache = data.configs;
483
+ const select = document.getElementById('config-select');
484
+ select.innerHTML = '';
485
+ for (const c of (configsCache.items || [])) {
486
+ const opt = document.createElement('option');
487
+ opt.value = c.id;
488
+ opt.textContent = `${c.name}${c.enabled ? '' : ' (disabled)'}`;
489
+ select.appendChild(opt);
490
+ }
491
+ if (!select.value && (configsCache.items || []).length) {
492
+ select.value = configsCache.items[0].id;
493
+ }
494
+ activeConfigId = getSelectedConfigId();
495
+ await loadConfigById(activeConfigId);
496
+ }
497
+
498
+ async function loadSuggestions() {
499
+ try {
500
+ const response = await fetch(`${BLOG_API_BASE}/suggestions`);
501
+ const data = await response.json();
502
+ if (!response.ok) return;
503
+ suggestionsCache = data;
504
+ const dlCats = document.getElementById('dl-categories');
505
+ const dlAuthors = document.getElementById('dl-authors');
506
+ const dlTags = document.getElementById('dl-tags');
507
+ if (dlCats) dlCats.innerHTML = (data.categories || []).map((x) => `<option value="${String(x).replace(/"/g, '&quot;')}"></option>`).join('');
508
+ if (dlAuthors) dlAuthors.innerHTML = (data.authorNames || []).map((x) => `<option value="${String(x).replace(/"/g, '&quot;')}"></option>`).join('');
509
+ if (dlTags) dlTags.innerHTML = (data.tags || []).map((x) => `<option value="${String(x).replace(/"/g, '&quot;')}"></option>`).join('');
510
+ } catch {
511
+ // ignore
512
+ }
513
+ }
514
+
515
+ async function loadLlmConfig() {
516
+ try {
517
+ const response = await fetch(`${LLM_API_BASE}/config`);
518
+ const data = await response.json();
519
+ if (!response.ok) return;
520
+ llmCache = data;
521
+ const providers = Object.keys(data.providers || {}).map((p) => String(p)).filter(Boolean).sort();
522
+ const dl = document.getElementById('dl-llm-providers');
523
+ if (dl) dl.innerHTML = providers.map((x) => `<option value="${String(x).replace(/"/g, '&quot;')}"></option>`).join('');
524
+ } catch {
525
+ // ignore
526
+ }
527
+ }
528
+
529
+ function readFormToConfigPatch() {
530
+ const topicsText = String(document.getElementById('cfg-topics-json')?.value || '').trim();
531
+ let topics = undefined;
532
+ if (topicsText) {
533
+ try {
534
+ const parsed = JSON.parse(topicsText);
535
+ if (Array.isArray(parsed)) topics = parsed;
536
+ } catch {
537
+ // ignore
538
+ }
539
+ }
540
+
541
+ const imagesExtra = String(document.getElementById('cfg-images-promptExtraInstruction')?.value || '').trim();
542
+
543
+ return {
544
+ name: String(document.getElementById('cfg-name')?.value || '').trim(),
545
+ enabled: Boolean(document.getElementById('cfg-enabled')?.checked),
546
+ schedule: {
547
+ managedBy: String(document.getElementById('cfg-schedule-managedBy')?.value || 'cronScheduler'),
548
+ cronExpression: String(document.getElementById('cfg-schedule-cronExpression')?.value || '').trim(),
549
+ timezone: String(document.getElementById('cfg-schedule-timezone')?.value || '').trim(),
550
+ },
551
+ runsPerDayLimit: Number(document.getElementById('cfg-runsPerDayLimit')?.value || 0),
552
+ maxPostsPerRun: Number(document.getElementById('cfg-maxPostsPerRun')?.value || 1),
553
+ dedupeWindowDays: Number(document.getElementById('cfg-dedupeWindowDays')?.value || 0),
554
+ defaultCategory: String(document.getElementById('cfg-defaultCategory')?.value || '').trim(),
555
+ defaultAuthorName: String(document.getElementById('cfg-defaultAuthorName')?.value || '').trim(),
556
+ defaultTags: String(document.getElementById('cfg-defaultTags')?.value || '')
557
+ .split(',')
558
+ .map((x) => String(x).trim())
559
+ .filter(Boolean),
560
+ research: {
561
+ providerKey: String(document.getElementById('cfg-research-providerKey')?.value || '').trim(),
562
+ model: String(document.getElementById('cfg-research-model')?.value || '').trim(),
563
+ },
564
+ textGeneration: {
565
+ providerKey: String(document.getElementById('cfg-textGeneration-providerKey')?.value || '').trim(),
566
+ model: String(document.getElementById('cfg-textGeneration-model')?.value || '').trim(),
567
+ },
568
+ imageGeneration: {
569
+ providerKey: String(document.getElementById('cfg-imageGeneration-providerKey')?.value || '').trim(),
570
+ model: String(document.getElementById('cfg-imageGeneration-model')?.value || '').trim(),
571
+ },
572
+ images: { promptExtraInstruction: imagesExtra },
573
+ ...(topics ? { topics } : {}),
574
+ };
575
+ }
576
+
577
+ let currentConfig = null;
578
+
579
+ function fillFormFromConfig(cfg) {
580
+ currentConfig = cfg;
581
+ document.getElementById('cfg-name').value = String(cfg.name || '');
582
+ document.getElementById('cfg-enabled').checked = Boolean(cfg.enabled);
583
+ document.getElementById('cfg-schedule-managedBy').value = String(cfg?.schedule?.managedBy || 'cronScheduler');
584
+ document.getElementById('cfg-schedule-cronExpression').value = String(cfg?.schedule?.cronExpression || '');
585
+ document.getElementById('cfg-schedule-timezone').value = String(cfg?.schedule?.timezone || '');
586
+
587
+ document.getElementById('cfg-runsPerDayLimit').value = Number(cfg.runsPerDayLimit || 0);
588
+ document.getElementById('cfg-maxPostsPerRun').value = Number(cfg.maxPostsPerRun || 1);
589
+ document.getElementById('cfg-dedupeWindowDays').value = Number(cfg.dedupeWindowDays || 0);
590
+ document.getElementById('cfg-defaultCategory').value = String(cfg.defaultCategory || '');
591
+ document.getElementById('cfg-defaultAuthorName').value = String(cfg.defaultAuthorName || '');
592
+ document.getElementById('cfg-defaultTags').value = Array.isArray(cfg.defaultTags) ? cfg.defaultTags.join(', ') : '';
593
+ document.getElementById('cfg-research-providerKey').value = String(cfg?.research?.providerKey || '');
594
+ document.getElementById('cfg-research-model').value = String(cfg?.research?.model || '');
595
+ document.getElementById('cfg-textGeneration-providerKey').value = String(cfg?.textGeneration?.providerKey || cfg?.generation?.providerKey || '');
596
+ document.getElementById('cfg-textGeneration-model').value = String(cfg?.textGeneration?.model || cfg?.generation?.model || '');
597
+ document.getElementById('cfg-imageGeneration-providerKey').value = String(cfg?.imageGeneration?.providerKey || '');
598
+ document.getElementById('cfg-imageGeneration-model').value = String(cfg?.imageGeneration?.model || '');
599
+ document.getElementById('cfg-images-promptExtraInstruction').value = String(cfg?.images?.promptExtraInstruction || '');
600
+ document.getElementById('cfg-topics-json').value = JSON.stringify(cfg.topics || [], null, 2);
601
+ fillStyleOverrideFromConfig(cfg);
602
+ }
603
+
604
+ async function saveStyleOverride() {
605
+ try {
606
+ const id = getSelectedConfigId();
607
+ if (!id) throw new Error('configId is required');
608
+
609
+ const enabled = Boolean(document.getElementById('cfg-style-override-enabled')?.checked);
610
+ const override = enabled ? String(document.getElementById('cfg-style-override')?.value || '').trim() : '';
611
+
612
+ const response = await fetch(`${API_BASE}/configs/${encodeURIComponent(id)}`, {
613
+ method: 'PUT',
614
+ headers: { 'Content-Type': 'application/json' },
615
+ body: JSON.stringify({ config: { styleGuideOverride: override } })
616
+ });
617
+
618
+ const data = await response.json();
619
+ if (!response.ok) throw new Error(data.error || 'Failed to save override');
620
+ showToast('Override saved');
621
+ await loadConfig();
622
+ } catch (error) {
623
+ showToast(error.message, 'error');
624
+ }
625
+ }
626
+
627
+ async function loadConfigById(id) {
628
+ try {
629
+ if (!id) throw new Error('configId is required');
630
+ const response = await fetch(`${API_BASE}/configs/${encodeURIComponent(id)}`);
631
+ const data = await response.json();
632
+
633
+ if (!response.ok) throw new Error(data.error || 'Failed to load config');
634
+
635
+ const cfg = data.config;
636
+ document.getElementById('config-json').value = JSON.stringify(cfg, null, 2);
637
+ fillFormFromConfig(cfg);
638
+ await initProviderPickers();
639
+ updateConfigDependentUi();
640
+ await loadPromptPreview();
641
+ } catch (error) {
642
+ showToast(error.message, 'error');
643
+ }
644
+ }
645
+
646
+ async function loadConfig() {
647
+ const id = getSelectedConfigId();
648
+ if (!id) {
649
+ showToast('configId is required', 'error');
650
+ return;
651
+ }
652
+ await loadConfigById(id);
653
+ }
654
+
655
+ function updateConfigDependentUi() {
656
+ const id = getSelectedConfigId();
657
+ const runBtn = document.getElementById('btn-run-now');
658
+ const hint = document.getElementById('cfg-actions-hint');
659
+ if (!id) {
660
+ if (runBtn) runBtn.classList.add('hidden');
661
+ if (hint) hint.classList.remove('hidden');
662
+ return;
663
+ }
664
+ if (runBtn) runBtn.classList.remove('hidden');
665
+ if (hint) hint.classList.add('hidden');
666
+ }
667
+
668
+ async function loadPromptPreview() {
669
+ try {
670
+ const id = getSelectedConfigId();
671
+ if (!id) return;
672
+ const response = await fetch(`${API_BASE}/configs/${encodeURIComponent(id)}/preview-prompts`, {
673
+ method: 'POST',
674
+ headers: { 'Content-Type': 'application/json' },
675
+ body: JSON.stringify({})
676
+ });
677
+ const data = await response.json();
678
+ if (!response.ok) throw new Error(data.error || 'Failed to preview prompts');
679
+ const prompts = data.prompts || {};
680
+ document.getElementById('preview-post-prompt').value = String(prompts.postPrompt || '');
681
+ document.getElementById('preview-cover-prompt').value = String(prompts.imageCoverPrompt || '');
682
+ document.getElementById('preview-inline-prompt').value = String(prompts.imageInlinePrompt || '');
683
+ } catch (error) {
684
+ showToast(error.message, 'error');
685
+ }
686
+ }
687
+
688
+ async function loadStyleGuide() {
689
+ try {
690
+ const response = await fetch(`${API_BASE}/style-guide`);
691
+ const data = await response.json();
692
+ if (!response.ok) throw new Error(data.error || 'Failed to load style guide');
693
+ document.getElementById('style-guide').value = String(data.styleGuide || '');
694
+ } catch (error) {
695
+ showToast(error.message, 'error');
696
+ }
697
+ }
698
+
699
+ async function saveStyleGuide() {
700
+ try {
701
+ const styleGuide = String(document.getElementById('style-guide')?.value || '');
702
+ const response = await fetch(`${API_BASE}/style-guide`, {
703
+ method: 'PUT',
704
+ headers: { 'Content-Type': 'application/json' },
705
+ body: JSON.stringify({ styleGuide })
706
+ });
707
+ const data = await response.json();
708
+ if (!response.ok) throw new Error(data.error || 'Failed to save style guide');
709
+ showToast('Style guide saved');
710
+ } catch (error) {
711
+ showToast(error.message, 'error');
712
+ }
713
+ }
714
+
715
+ async function saveConfig() {
716
+ try {
717
+ const id = getSelectedConfigId();
718
+ if (!id) throw new Error('configId is required');
719
+
720
+ let config;
721
+ const isAdv = !document.getElementById('subtab-advanced').classList.contains('hidden');
722
+ if (isAdv) {
723
+ const configText = document.getElementById('config-json').value;
724
+ config = JSON.parse(configText);
725
+ } else {
726
+ config = readFormToConfigPatch();
727
+ }
728
+
729
+ const response = await fetch(`${API_BASE}/configs/${encodeURIComponent(id)}`, {
730
+ method: 'PUT',
731
+ headers: { 'Content-Type': 'application/json' },
732
+ body: JSON.stringify({ config })
733
+ });
734
+
735
+ const data = await response.json();
736
+
737
+ if (!response.ok) throw new Error(data.error || 'Failed to save config');
738
+
739
+ showToast('Configuration saved successfully');
740
+ await loadConfigs();
741
+ } catch (error) {
742
+ showToast(error.message, 'error');
743
+ }
744
+ }
745
+
746
+ async function createConfig() {
747
+ try {
748
+ const name = prompt('New configuration name');
749
+ if (name === null) return;
750
+ const response = await fetch(`${API_BASE}/configs`, {
751
+ method: 'POST',
752
+ headers: { 'Content-Type': 'application/json' },
753
+ body: JSON.stringify({ name })
754
+ });
755
+ const data = await response.json();
756
+ if (!response.ok) throw new Error(data.error || 'Failed to create config');
757
+ showToast('Configuration created');
758
+ await loadConfigs();
759
+ const select = document.getElementById('config-select');
760
+ select.value = data.config.id;
761
+ activeConfigId = data.config.id;
762
+ await loadConfigById(activeConfigId);
763
+ } catch (error) {
764
+ showToast(error.message, 'error');
765
+ }
766
+ }
767
+
768
+ async function deleteConfig() {
769
+ try {
770
+ const id = getSelectedConfigId();
771
+ if (!id) throw new Error('configId is required');
772
+ const name = (configsCache?.items || []).find((x) => x.id === id)?.name || id;
773
+ const confirmText = prompt(`Type DELETE to delete configuration: ${name}`);
774
+ if (confirmText !== 'DELETE') return;
775
+
776
+ const response = await fetch(`${API_BASE}/configs/${encodeURIComponent(id)}`, { method: 'DELETE' });
777
+ const data = await response.json();
778
+ if (!response.ok) throw new Error(data.error || 'Failed to delete config');
779
+ showToast('Configuration deleted');
780
+ await loadConfigs();
781
+ } catch (error) {
782
+ showToast(error.message, 'error');
783
+ }
784
+ }
785
+
786
+ // Run History
787
+ async function loadRuns() {
788
+ try {
789
+ const id = getSelectedConfigId();
790
+ if (!id) throw new Error('configId is required');
791
+ const response = await fetch(`${API_BASE}/runs?configId=${encodeURIComponent(id)}`);
792
+ const data = await response.json();
793
+
794
+ if (!response.ok) throw new Error(data.error || 'Failed to load runs');
795
+
796
+ renderRuns(data.runs || []);
797
+ } catch (error) {
798
+ showToast(error.message, 'error');
799
+ }
800
+ }
801
+
802
+ function renderRuns(runs) {
803
+ const tbody = document.getElementById('runs-tbody');
804
+
805
+ if (!runs || runs.length === 0) {
806
+ tbody.innerHTML = '<tr><td colspan="5" class="px-6 py-4 text-center text-gray-500">No runs found</td></tr>';
807
+ return;
808
+ }
809
+
810
+ tbody.innerHTML = runs.map(run => `
811
+ <tr>
812
+ <td class="px-6 py-4 text-sm text-gray-900">${run.trigger || '-'}</td>
813
+ <td class="px-6 py-4">${getStatusBadge(run.status)}</td>
814
+ <td class="px-6 py-4 text-sm text-gray-900">${formatDate(run.startedAt)}</td>
815
+ <td class="px-6 py-4 text-sm text-gray-900">${formatDuration(run.startedAt, run.finishedAt)}</td>
816
+ <td class="px-6 py-4 text-sm text-gray-900">
817
+ ${run.results?.postId ? `Post ID: ${run.results.postId}` : '-'}
818
+ ${run.error ? `<br><span class="text-red-600">${run.error}</span>` : ''}
819
+ </td>
820
+ </tr>
821
+ `).join('');
822
+ }
823
+
824
+ // Run Now
825
+ async function runNow() {
826
+ if (!confirm('Are you sure you want to run blog automation now?')) return;
827
+
828
+ try {
829
+ const id = getSelectedConfigId();
830
+ if (!id) throw new Error('configId is required');
831
+ const response = await fetch(`${API_BASE}/run-now`, {
832
+ method: 'POST',
833
+ headers: { 'Content-Type': 'application/json' },
834
+ body: JSON.stringify({ configId: id })
835
+ });
836
+ const data = await response.json();
837
+
838
+ if (!response.ok) throw new Error(data.error || 'Failed to run automation');
839
+
840
+ showToast('Automation started successfully');
841
+
842
+ // Switch to runs tab and refresh
843
+ document.querySelector('[data-tab="runs"]').click();
844
+ } catch (error) {
845
+ showToast(error.message, 'error');
846
+ }
847
+ }
848
+
849
+ // Event listeners
850
+ document.getElementById('btn-load-config').addEventListener('click', loadConfig);
851
+ document.getElementById('btn-save-config').addEventListener('click', saveConfig);
852
+ document.getElementById('btn-new-config').addEventListener('click', createConfig);
853
+ document.getElementById('btn-delete-config').addEventListener('click', deleteConfig);
854
+ document.getElementById('config-select').addEventListener('change', async () => {
855
+ activeConfigId = getSelectedConfigId();
856
+ updateConfigDependentUi();
857
+ await loadConfigById(activeConfigId);
858
+ });
859
+ document.getElementById('btn-load-style').addEventListener('click', loadStyleGuide);
860
+ document.getElementById('btn-save-style').addEventListener('click', saveStyleGuide);
861
+ document.getElementById('btn-save-style-override').addEventListener('click', saveStyleOverride);
862
+ document.getElementById('btn-preview-prompts').addEventListener('click', loadPromptPreview);
863
+ document.getElementById('btn-refresh-runs').addEventListener('click', loadRuns);
864
+ document.getElementById('btn-run-now').addEventListener('click', runNow);
865
+
866
+ // Initial load
867
+ setSubtab('form');
868
+ loadSuggestions();
869
+ loadConfigs();
870
+ loadStyleGuide();
871
+ </script>
872
+
873
+ <datalist id="dl-categories"></datalist>
874
+ <datalist id="dl-authors"></datalist>
875
+ <datalist id="dl-tags"></datalist>
876
+ </body>
877
+ </html>