@intranefr/superbackend 1.5.0 → 1.5.2
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/.env.example +15 -0
- package/README.md +11 -0
- package/analysis-only.skill +0 -0
- package/index.js +23 -0
- package/package.json +8 -2
- package/src/admin/endpointRegistry.js +120 -0
- package/src/controllers/admin.controller.js +90 -6
- package/src/controllers/adminBlockDefinitions.controller.js +127 -0
- package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
- package/src/controllers/adminCache.controller.js +342 -0
- package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
- package/src/controllers/adminCrons.controller.js +388 -0
- package/src/controllers/adminDbBrowser.controller.js +124 -0
- package/src/controllers/adminEjsVirtual.controller.js +13 -3
- package/src/controllers/adminExperiments.controller.js +200 -0
- package/src/controllers/adminHeadless.controller.js +9 -2
- package/src/controllers/adminHealthChecks.controller.js +570 -0
- package/src/controllers/adminI18n.controller.js +51 -29
- package/src/controllers/adminLlm.controller.js +126 -2
- package/src/controllers/adminPages.controller.js +720 -0
- package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
- package/src/controllers/adminProxy.controller.js +113 -0
- package/src/controllers/adminRateLimits.controller.js +138 -0
- package/src/controllers/adminRbac.controller.js +803 -0
- package/src/controllers/adminScripts.controller.js +126 -4
- package/src/controllers/adminSeoConfig.controller.js +71 -48
- package/src/controllers/blogAdmin.controller.js +279 -0
- package/src/controllers/blogAiAdmin.controller.js +224 -0
- package/src/controllers/blogAutomationAdmin.controller.js +141 -0
- package/src/controllers/blogInternal.controller.js +26 -0
- package/src/controllers/blogPublic.controller.js +89 -0
- package/src/controllers/experiments.controller.js +85 -0
- package/src/controllers/fileManager.controller.js +190 -0
- package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
- package/src/controllers/healthChecksPublic.controller.js +196 -0
- package/src/controllers/internalExperiments.controller.js +17 -0
- package/src/controllers/metrics.controller.js +64 -4
- package/src/controllers/orgAdmin.controller.js +80 -0
- package/src/helpers/mongooseHelper.js +258 -0
- package/src/helpers/scriptBase.js +230 -0
- package/src/helpers/scriptRunner.js +335 -0
- package/src/middleware/rbac.js +62 -0
- package/src/middleware.js +810 -48
- package/src/models/BlockDefinition.js +27 -0
- package/src/models/BlogAutomationLock.js +14 -0
- package/src/models/BlogAutomationRun.js +39 -0
- package/src/models/BlogPost.js +42 -0
- package/src/models/CacheEntry.js +26 -0
- package/src/models/ConsoleEntry.js +32 -0
- package/src/models/ConsoleLog.js +23 -0
- package/src/models/ContextBlockDefinition.js +33 -0
- package/src/models/CronExecution.js +47 -0
- package/src/models/CronJob.js +70 -0
- package/src/models/Experiment.js +75 -0
- package/src/models/ExperimentAssignment.js +23 -0
- package/src/models/ExperimentEvent.js +26 -0
- package/src/models/ExperimentMetricBucket.js +30 -0
- package/src/models/ExternalDbConnection.js +49 -0
- package/src/models/FileEntry.js +22 -0
- package/src/models/GlobalSetting.js +1 -2
- package/src/models/HealthAutoHealAttempt.js +57 -0
- package/src/models/HealthCheck.js +132 -0
- package/src/models/HealthCheckRun.js +51 -0
- package/src/models/HealthIncident.js +49 -0
- package/src/models/Page.js +95 -0
- package/src/models/PageCollection.js +42 -0
- package/src/models/ProxyEntry.js +66 -0
- package/src/models/RateLimitCounter.js +19 -0
- package/src/models/RateLimitMetricBucket.js +20 -0
- package/src/models/RbacGrant.js +25 -0
- package/src/models/RbacGroup.js +16 -0
- package/src/models/RbacGroupMember.js +13 -0
- package/src/models/RbacGroupRole.js +13 -0
- package/src/models/RbacRole.js +25 -0
- package/src/models/RbacUserRole.js +13 -0
- package/src/models/ScriptDefinition.js +1 -0
- package/src/models/Webhook.js +2 -0
- package/src/routes/admin.routes.js +2 -0
- package/src/routes/adminBlog.routes.js +21 -0
- package/src/routes/adminBlogAi.routes.js +16 -0
- package/src/routes/adminBlogAutomation.routes.js +27 -0
- package/src/routes/adminCache.routes.js +20 -0
- package/src/routes/adminConsoleManager.routes.js +302 -0
- package/src/routes/adminCrons.routes.js +25 -0
- package/src/routes/adminDbBrowser.routes.js +65 -0
- package/src/routes/adminEjsVirtual.routes.js +2 -1
- package/src/routes/adminExperiments.routes.js +29 -0
- package/src/routes/adminHeadless.routes.js +2 -1
- package/src/routes/adminHealthChecks.routes.js +28 -0
- package/src/routes/adminI18n.routes.js +4 -3
- package/src/routes/adminLlm.routes.js +4 -2
- package/src/routes/adminPages.routes.js +55 -0
- package/src/routes/adminProxy.routes.js +15 -0
- package/src/routes/adminRateLimits.routes.js +17 -0
- package/src/routes/adminRbac.routes.js +38 -0
- package/src/routes/adminSeoConfig.routes.js +5 -4
- package/src/routes/adminUiComponents.routes.js +2 -1
- package/src/routes/blogInternal.routes.js +14 -0
- package/src/routes/blogPublic.routes.js +9 -0
- package/src/routes/experiments.routes.js +30 -0
- package/src/routes/fileManager.routes.js +62 -0
- package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
- package/src/routes/healthChecksPublic.routes.js +9 -0
- package/src/routes/internalExperiments.routes.js +15 -0
- package/src/routes/log.routes.js +43 -60
- package/src/routes/metrics.routes.js +4 -2
- package/src/routes/orgAdmin.routes.js +1 -0
- package/src/routes/pages.routes.js +123 -0
- package/src/routes/proxy.routes.js +46 -0
- package/src/routes/rbac.routes.js +47 -0
- package/src/routes/webhook.routes.js +2 -1
- package/src/routes/workflows.routes.js +4 -0
- package/src/services/blockDefinitionsAi.service.js +247 -0
- package/src/services/blog.service.js +99 -0
- package/src/services/blogAutomation.service.js +978 -0
- package/src/services/blogCronsBootstrap.service.js +185 -0
- package/src/services/blogPublishing.service.js +58 -0
- package/src/services/cacheLayer.service.js +696 -0
- package/src/services/consoleManager.service.js +738 -0
- package/src/services/consoleOverride.service.js +7 -1
- package/src/services/cronScheduler.service.js +350 -0
- package/src/services/dbBrowser.service.js +536 -0
- package/src/services/ejsVirtual.service.js +102 -32
- package/src/services/experiments.service.js +273 -0
- package/src/services/experimentsAggregation.service.js +308 -0
- package/src/services/experimentsCronsBootstrap.service.js +118 -0
- package/src/services/experimentsRetention.service.js +43 -0
- package/src/services/experimentsWs.service.js +134 -0
- package/src/services/fileManager.service.js +475 -0
- package/src/services/fileManagerStoragePolicy.service.js +285 -0
- package/src/services/globalSettings.service.js +15 -0
- package/src/services/healthChecks.service.js +650 -0
- package/src/services/healthChecksBootstrap.service.js +109 -0
- package/src/services/healthChecksScheduler.service.js +106 -0
- package/src/services/jsonConfigs.service.js +2 -2
- package/src/services/llmDefaults.service.js +190 -0
- package/src/services/migrationAssets/s3.js +2 -2
- package/src/services/pages.service.js +602 -0
- package/src/services/pagesContext.service.js +331 -0
- package/src/services/pagesContextBlocksAi.service.js +349 -0
- package/src/services/proxy.service.js +535 -0
- package/src/services/rateLimiter.service.js +623 -0
- package/src/services/rbac.service.js +212 -0
- package/src/services/scriptsRunner.service.js +215 -15
- package/src/services/uiComponentsAi.service.js +6 -19
- package/src/services/workflow.service.js +23 -8
- package/src/utils/orgRoles.js +14 -0
- package/src/utils/rbac/engine.js +60 -0
- package/src/utils/rbac/rightsRegistry.js +33 -0
- package/views/admin-blog-automation.ejs +877 -0
- package/views/admin-blog-edit.ejs +542 -0
- package/views/admin-blog.ejs +399 -0
- package/views/admin-cache.ejs +681 -0
- package/views/admin-console-manager.ejs +680 -0
- package/views/admin-crons.ejs +645 -0
- package/views/admin-dashboard.ejs +28 -8
- package/views/admin-db-browser.ejs +445 -0
- package/views/admin-ejs-virtual.ejs +16 -10
- package/views/admin-experiments.ejs +91 -0
- package/views/admin-file-manager.ejs +942 -0
- package/views/admin-health-checks.ejs +725 -0
- package/views/admin-i18n.ejs +59 -5
- package/views/admin-llm.ejs +99 -1
- package/views/admin-organizations.ejs +163 -1
- package/views/admin-pages.ejs +2424 -0
- package/views/admin-proxy.ejs +491 -0
- package/views/admin-rate-limiter.ejs +625 -0
- package/views/admin-rbac.ejs +1331 -0
- package/views/admin-scripts.ejs +597 -3
- package/views/admin-seo-config.ejs +61 -7
- package/views/admin-ui-components.ejs +57 -25
- package/views/admin-workflows.ejs +7 -7
- package/views/file-manager.ejs +866 -0
- package/views/pages/blocks/contact.ejs +27 -0
- package/views/pages/blocks/cta.ejs +18 -0
- package/views/pages/blocks/faq.ejs +20 -0
- package/views/pages/blocks/features.ejs +19 -0
- package/views/pages/blocks/hero.ejs +13 -0
- package/views/pages/blocks/html.ejs +5 -0
- package/views/pages/blocks/image.ejs +14 -0
- package/views/pages/blocks/testimonials.ejs +26 -0
- package/views/pages/blocks/text.ejs +10 -0
- package/views/pages/layouts/default.ejs +51 -0
- package/views/pages/layouts/minimal.ejs +42 -0
- package/views/pages/layouts/sidebar.ejs +54 -0
- package/views/pages/partials/footer.ejs +13 -0
- package/views/pages/partials/header.ejs +12 -0
- package/views/pages/partials/sidebar.ejs +8 -0
- package/views/pages/runtime/page.ejs +10 -0
- package/views/pages/templates/article.ejs +20 -0
- package/views/pages/templates/default.ejs +12 -0
- package/views/pages/templates/landing.ejs +14 -0
- package/views/pages/templates/listing.ejs +15 -0
- package/views/partials/admin-image-upload-modal.ejs +221 -0
- package/views/partials/dashboard/nav-items.ejs +12 -0
- package/views/partials/dashboard/palette.ejs +5 -3
- package/views/partials/llm-provider-model-picker.ejs +183 -0
- package/src/routes/llmUi.routes.js +0 -26
|
@@ -0,0 +1,2424 @@
|
|
|
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>Page Builder - 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
|
+
.tab-active { border-bottom: 2px solid #3b82f6; color: #3b82f6; }
|
|
11
|
+
.block-item { cursor: grab; }
|
|
12
|
+
.block-item:active { cursor: grabbing; }
|
|
13
|
+
</style>
|
|
14
|
+
</head>
|
|
15
|
+
<body class="bg-gray-100 min-h-screen">
|
|
16
|
+
<div id="app" class="container mx-auto px-4 py-8">
|
|
17
|
+
<div class="flex items-center justify-between mb-6">
|
|
18
|
+
<div>
|
|
19
|
+
<h1 class="text-2xl font-bold text-gray-900">Page Builder</h1>
|
|
20
|
+
<p class="text-gray-600 text-sm">Create and manage pages with drag-and-drop blocks</p>
|
|
21
|
+
</div>
|
|
22
|
+
<a href="<%= adminPath %>" class="text-blue-600 hover:text-blue-800 text-sm flex items-center gap-1">
|
|
23
|
+
<i class="ti ti-arrow-left"></i> Back to Dashboard
|
|
24
|
+
</a>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div class="flex gap-2 mb-6 border-b border-gray-200">
|
|
28
|
+
<button id="tab-pages" class="px-4 py-2 text-sm font-medium tab-active" onclick="switchTab('pages')">Pages</button>
|
|
29
|
+
<button id="tab-collections" class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900" onclick="switchTab('collections')">Collections</button>
|
|
30
|
+
<button id="tab-templates" class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900" onclick="switchTab('templates')">Templates</button>
|
|
31
|
+
<button id="tab-layouts" class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900" onclick="switchTab('layouts')">Layouts</button>
|
|
32
|
+
<button id="tab-blocks" class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900" onclick="switchTab('blocks')">Blocks</button>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div id="panel-pages">
|
|
36
|
+
<div class="bg-white rounded-lg shadow-sm border p-4 mb-4">
|
|
37
|
+
<div class="flex items-center gap-4 flex-wrap">
|
|
38
|
+
<input type="text" id="pages-search" placeholder="Search pages..." class="border rounded px-3 py-2 text-sm flex-1 min-w-48">
|
|
39
|
+
<select id="pages-status" class="border rounded px-3 py-2 text-sm">
|
|
40
|
+
<option value="">All statuses</option>
|
|
41
|
+
<option value="draft">Draft</option>
|
|
42
|
+
<option value="published">Published</option>
|
|
43
|
+
<option value="archived">Archived</option>
|
|
44
|
+
</select>
|
|
45
|
+
<select id="pages-collection" class="border rounded px-3 py-2 text-sm">
|
|
46
|
+
<option value="">All collections</option>
|
|
47
|
+
</select>
|
|
48
|
+
<button onclick="loadPages()" class="bg-gray-100 text-gray-800 px-3 py-2 rounded text-sm hover:bg-gray-200">
|
|
49
|
+
<i class="ti ti-refresh"></i> Refresh
|
|
50
|
+
</button>
|
|
51
|
+
<button onclick="openCreatePageModal()" class="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700">
|
|
52
|
+
<i class="ti ti-plus"></i> New Page
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div class="bg-white rounded-lg shadow-sm border overflow-hidden">
|
|
58
|
+
<table class="w-full text-sm">
|
|
59
|
+
<thead class="bg-gray-50 border-b">
|
|
60
|
+
<tr>
|
|
61
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Title</th>
|
|
62
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Slug</th>
|
|
63
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Collection</th>
|
|
64
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Status</th>
|
|
65
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Updated</th>
|
|
66
|
+
<th class="text-right px-4 py-3 font-medium text-gray-700">Actions</th>
|
|
67
|
+
</tr>
|
|
68
|
+
</thead>
|
|
69
|
+
<tbody id="pages-tbody"></tbody>
|
|
70
|
+
</table>
|
|
71
|
+
<div id="pages-empty" class="hidden p-8 text-center text-gray-500">
|
|
72
|
+
No pages found. Create your first page!
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div class="flex items-center justify-between mt-4">
|
|
77
|
+
<div id="pages-info" class="text-sm text-gray-600"></div>
|
|
78
|
+
<div class="flex gap-2">
|
|
79
|
+
<button id="pages-prev" onclick="pagesPrev()" class="px-3 py-1 text-sm border rounded disabled:opacity-50" disabled>Previous</button>
|
|
80
|
+
<button id="pages-next" onclick="pagesNext()" class="px-3 py-1 text-sm border rounded disabled:opacity-50" disabled>Next</button>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div id="panel-collections" class="hidden">
|
|
86
|
+
<div class="bg-white rounded-lg shadow-sm border p-4 mb-4">
|
|
87
|
+
<div class="flex items-center gap-4 flex-wrap">
|
|
88
|
+
<input type="text" id="collections-search" placeholder="Search collections..." class="border rounded px-3 py-2 text-sm flex-1 min-w-48">
|
|
89
|
+
<button onclick="loadCollections()" class="bg-gray-100 text-gray-800 px-3 py-2 rounded text-sm hover:bg-gray-200">
|
|
90
|
+
<i class="ti ti-refresh"></i> Refresh
|
|
91
|
+
</button>
|
|
92
|
+
<button onclick="openCreateCollectionModal()" class="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700">
|
|
93
|
+
<i class="ti ti-plus"></i> New Collection
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div class="bg-white rounded-lg shadow-sm border overflow-hidden">
|
|
99
|
+
<table class="w-full text-sm">
|
|
100
|
+
<thead class="bg-gray-50 border-b">
|
|
101
|
+
<tr>
|
|
102
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Name</th>
|
|
103
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Slug</th>
|
|
104
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Status</th>
|
|
105
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Updated</th>
|
|
106
|
+
<th class="text-right px-4 py-3 font-medium text-gray-700">Actions</th>
|
|
107
|
+
</tr>
|
|
108
|
+
</thead>
|
|
109
|
+
<tbody id="collections-tbody"></tbody>
|
|
110
|
+
</table>
|
|
111
|
+
<div id="collections-empty" class="hidden p-8 text-center text-gray-500">
|
|
112
|
+
No collections found. Create your first collection!
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div id="panel-templates" class="hidden">
|
|
118
|
+
<div class="bg-white rounded-lg shadow-sm border p-4 mb-4">
|
|
119
|
+
<div class="flex items-center gap-4 flex-wrap">
|
|
120
|
+
<button onclick="loadTemplates()" class="bg-gray-100 text-gray-800 px-3 py-2 rounded text-sm hover:bg-gray-200">
|
|
121
|
+
<i class="ti ti-refresh"></i> Refresh
|
|
122
|
+
</button>
|
|
123
|
+
<button onclick="openCreateEjsFileModal('template')" class="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700">
|
|
124
|
+
<i class="ti ti-plus"></i> New Template
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div class="bg-white rounded-lg shadow-sm border overflow-hidden">
|
|
130
|
+
<table class="w-full text-sm">
|
|
131
|
+
<thead class="bg-gray-50 border-b">
|
|
132
|
+
<tr>
|
|
133
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Key</th>
|
|
134
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Name</th>
|
|
135
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Description</th>
|
|
136
|
+
<th class="text-right px-4 py-3 font-medium text-gray-700">Actions</th>
|
|
137
|
+
</tr>
|
|
138
|
+
</thead>
|
|
139
|
+
<tbody id="templates-tbody"></tbody>
|
|
140
|
+
</table>
|
|
141
|
+
<div id="templates-empty" class="hidden p-8 text-center text-gray-500">
|
|
142
|
+
No templates found.
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div id="panel-layouts" class="hidden">
|
|
148
|
+
<div class="bg-white rounded-lg shadow-sm border p-4 mb-4">
|
|
149
|
+
<div class="flex items-center gap-4 flex-wrap">
|
|
150
|
+
<button onclick="loadLayouts()" class="bg-gray-100 text-gray-800 px-3 py-2 rounded text-sm hover:bg-gray-200">
|
|
151
|
+
<i class="ti ti-refresh"></i> Refresh
|
|
152
|
+
</button>
|
|
153
|
+
<button onclick="openCreateEjsFileModal('layout')" class="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700">
|
|
154
|
+
<i class="ti ti-plus"></i> New Layout
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<div class="bg-white rounded-lg shadow-sm border overflow-hidden">
|
|
160
|
+
<table class="w-full text-sm">
|
|
161
|
+
<thead class="bg-gray-50 border-b">
|
|
162
|
+
<tr>
|
|
163
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Key</th>
|
|
164
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Name</th>
|
|
165
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Description</th>
|
|
166
|
+
<th class="text-right px-4 py-3 font-medium text-gray-700">Actions</th>
|
|
167
|
+
</tr>
|
|
168
|
+
</thead>
|
|
169
|
+
<tbody id="layouts-tbody"></tbody>
|
|
170
|
+
</table>
|
|
171
|
+
<div id="layouts-empty" class="hidden p-8 text-center text-gray-500">
|
|
172
|
+
No layouts found.
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<div id="panel-blocks" class="hidden">
|
|
178
|
+
<div class="bg-white rounded-lg shadow-sm border p-4 mb-4">
|
|
179
|
+
<div class="flex items-center gap-4 flex-wrap">
|
|
180
|
+
<button onclick="loadBlockDefinitions()" class="bg-gray-100 text-gray-800 px-3 py-2 rounded text-sm hover:bg-gray-200">
|
|
181
|
+
<i class="ti ti-refresh"></i> Refresh
|
|
182
|
+
</button>
|
|
183
|
+
<button onclick="openCreateBlockDefinitionModal()" class="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700">
|
|
184
|
+
<i class="ti ti-plus"></i> New Block
|
|
185
|
+
</button>
|
|
186
|
+
<button onclick="openAiGenerateBlockModal()" class="bg-purple-600 text-white px-4 py-2 rounded text-sm hover:bg-purple-700">
|
|
187
|
+
<i class="ti ti-sparkles"></i> Generate with AI
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<div class="flex gap-2 mb-4 border-b border-gray-200">
|
|
193
|
+
<button id="blocks-subtab-definitions" class="px-4 py-2 text-sm font-medium tab-active" onclick="switchBlocksSubTab('definitions')">Definitions</button>
|
|
194
|
+
<button id="blocks-subtab-context" class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900" onclick="switchBlocksSubTab('context')">Context Blocks</button>
|
|
195
|
+
<button id="blocks-subtab-settings" class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900" onclick="switchBlocksSubTab('settings')">Settings</button>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<div id="panel-blocks-definitions">
|
|
199
|
+
<div class="bg-white rounded-lg shadow-sm border overflow-hidden">
|
|
200
|
+
<table class="w-full text-sm">
|
|
201
|
+
<thead class="bg-gray-50 border-b">
|
|
202
|
+
<tr>
|
|
203
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Code</th>
|
|
204
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Label</th>
|
|
205
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Status</th>
|
|
206
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Updated</th>
|
|
207
|
+
<th class="text-right px-4 py-3 font-medium text-gray-700">Actions</th>
|
|
208
|
+
</tr>
|
|
209
|
+
</thead>
|
|
210
|
+
<tbody id="blockdefs-tbody"></tbody>
|
|
211
|
+
</table>
|
|
212
|
+
<div id="blockdefs-empty" class="hidden p-8 text-center text-gray-500">
|
|
213
|
+
No block definitions found.
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<div id="panel-blocks-settings" class="hidden">
|
|
219
|
+
<div class="bg-white rounded-lg shadow-sm border p-4">
|
|
220
|
+
<div class="text-sm text-gray-700 mb-4">
|
|
221
|
+
Configure which LLM provider/model the Blocks AI Assistant uses.
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
225
|
+
<%- include('partials/llm-provider-model-picker', {
|
|
226
|
+
providerInputId: 'blocks-ai-provider',
|
|
227
|
+
modelInputId: 'blocks-ai-model',
|
|
228
|
+
providerLabel: 'Provider (required)',
|
|
229
|
+
modelLabel: 'Model (optional)',
|
|
230
|
+
showOpenRouterFetch: true,
|
|
231
|
+
}) %>
|
|
232
|
+
<div>
|
|
233
|
+
<div id="blocks-ai-model-hint" class="text-xs text-gray-500 mt-1"></div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<div class="flex justify-end gap-2 pt-4 border-t mt-4">
|
|
238
|
+
<button type="button" onclick="loadBlocksAiSettings()" class="px-4 py-2 border rounded hover:bg-gray-50">Reload</button>
|
|
239
|
+
<button type="button" onclick="saveBlocksAiSettings()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Save</button>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<div class="text-xs text-gray-500 mt-3">
|
|
243
|
+
Resolution order: request override → System defaults (<span class="font-mono">llm.systemDefaults.pageBuilder.blocks.generate</span>) → Global defaults (<span class="font-mono">llm.defaults.*</span>) → legacy settings (<span class="font-mono">pageBuilder.blocks.ai.*</span>) → env.
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<div id="panel-blocks-context" class="hidden">
|
|
249
|
+
<div class="bg-white rounded-lg shadow-sm border p-4">
|
|
250
|
+
<div class="flex items-center justify-between gap-3 flex-wrap mb-3">
|
|
251
|
+
<div>
|
|
252
|
+
<div class="font-semibold text-gray-900">Context Block Definitions</div>
|
|
253
|
+
<div class="text-xs text-gray-500">Create reusable <span class="font-mono">context.*</span> block templates and attach them to Pages.</div>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
258
|
+
<div>
|
|
259
|
+
<div class="flex items-center justify-between mb-2">
|
|
260
|
+
<div class="text-sm font-semibold text-gray-800">Library</div>
|
|
261
|
+
<div class="flex gap-2">
|
|
262
|
+
<button type="button" onclick="openCreateContextBlockDefinition()" class="px-3 py-2 rounded text-sm bg-gray-100 hover:bg-gray-200 text-gray-800">New</button>
|
|
263
|
+
<button type="button" onclick="loadContextBlockDefinitions()" class="px-3 py-2 rounded text-sm bg-gray-100 hover:bg-gray-200 text-gray-800">Reload</button>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<div class="border rounded overflow-hidden">
|
|
268
|
+
<table class="w-full text-sm">
|
|
269
|
+
<thead class="bg-gray-50 border-b">
|
|
270
|
+
<tr>
|
|
271
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Code</th>
|
|
272
|
+
<th class="text-left px-4 py-3 font-medium text-gray-700">Type</th>
|
|
273
|
+
<th class="text-right px-4 py-3 font-medium text-gray-700">Actions</th>
|
|
274
|
+
</tr>
|
|
275
|
+
</thead>
|
|
276
|
+
<tbody id="ctxblockdefs-tbody"></tbody>
|
|
277
|
+
</table>
|
|
278
|
+
<div id="ctxblockdefs-empty" class="hidden p-6 text-center text-gray-500">No context block definitions found.</div>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
<div>
|
|
283
|
+
<div class="text-sm font-semibold text-gray-800 mb-2">Editor</div>
|
|
284
|
+
|
|
285
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
286
|
+
<div>
|
|
287
|
+
<label class="block text-xs font-medium text-gray-600 mb-1">Code *</label>
|
|
288
|
+
<input id="ctxblockdef-code" type="text" class="w-full border rounded px-2 py-2 text-sm" placeholder="blog-post-by-slug" />
|
|
289
|
+
</div>
|
|
290
|
+
<div>
|
|
291
|
+
<label class="block text-xs font-medium text-gray-600 mb-1">Label *</label>
|
|
292
|
+
<input id="ctxblockdef-label" type="text" class="w-full border rounded px-2 py-2 text-sm" placeholder="Blog post by slug" />
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<div class="mt-3">
|
|
297
|
+
<label class="block text-xs font-medium text-gray-600 mb-1">Description</label>
|
|
298
|
+
<input id="ctxblockdef-description" type="text" class="w-full border rounded px-2 py-2 text-sm" placeholder="Used by blog post page" />
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
<div class="mt-3">
|
|
302
|
+
<label class="block text-xs font-medium text-gray-600 mb-1">Type *</label>
|
|
303
|
+
<select id="ctxblockdef-type" class="w-full border rounded px-2 py-2 text-sm">
|
|
304
|
+
<option value="context.db_query">context.db_query</option>
|
|
305
|
+
<option value="context.service_invoke">context.service_invoke</option>
|
|
306
|
+
</select>
|
|
307
|
+
<div class="text-[11px] text-gray-500 mt-1">Uses $ctx interpolation: <span class="font-mono">{"$ctx":"params.slug"}</span></div>
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
<div class="mt-3">
|
|
311
|
+
<label class="block text-xs font-medium text-gray-600 mb-1">Props (JSON object) *</label>
|
|
312
|
+
<textarea id="ctxblockdef-props" class="w-full border rounded px-2 py-2 font-mono text-xs" rows="10" placeholder='{"assignTo":"post","model":"BlogPost","op":"findOne","filter":{"slug":{"$ctx":"params.slug"}}}'></textarea>
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
<div class="mt-3">
|
|
316
|
+
<label class="block text-xs font-medium text-gray-600 mb-1">AI prompt</label>
|
|
317
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
318
|
+
<%- include('partials/llm-provider-model-picker', {
|
|
319
|
+
providerInputId: 'ctxblocks-ai-provider',
|
|
320
|
+
modelInputId: 'ctxblocks-ai-model',
|
|
321
|
+
providerLabel: 'Provider (optional)',
|
|
322
|
+
modelLabel: 'Model (optional)',
|
|
323
|
+
showOpenRouterFetch: true,
|
|
324
|
+
}) %>
|
|
325
|
+
<div>
|
|
326
|
+
<textarea id="ctxblocks-ai-prompt" class="w-full border rounded px-2 py-2 text-sm" rows="4" placeholder="Example: Load the published BlogPost for params.slug into vars.post. Add cache 30s."></textarea>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
<div class="flex gap-2 mt-2">
|
|
331
|
+
<button type="button" onclick="aiGenerateContextBlockIntoEditor()" class="px-3 py-2 rounded text-sm bg-purple-600 hover:bg-purple-700 text-white">AI Generate Props</button>
|
|
332
|
+
<button type="button" onclick="testContextBlockDefinitionFromEditor()" class="px-3 py-2 rounded text-sm bg-gray-100 hover:bg-gray-200 text-gray-800">Test</button>
|
|
333
|
+
</div>
|
|
334
|
+
<div id="ctxblocks-ai-warnings" class="hidden mt-2 text-xs bg-amber-50 border border-amber-200 rounded p-2"></div>
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
<div class="flex flex-wrap gap-2 mt-4 pt-4 border-t">
|
|
338
|
+
<button type="button" onclick="saveContextBlockDefinitionFromEditor()" class="px-3 py-2 rounded text-sm bg-blue-600 hover:bg-blue-700 text-white">Save Definition</button>
|
|
339
|
+
<button type="button" onclick="deleteContextBlockDefinitionFromEditor()" class="px-3 py-2 rounded text-sm bg-red-600 hover:bg-red-700 text-white">Delete</button>
|
|
340
|
+
</div>
|
|
341
|
+
|
|
342
|
+
<div class="mt-3 text-[11px] text-gray-500">
|
|
343
|
+
Invokable helpers: <span class="font-mono">helpers.services.*</span>, <span class="font-mono">helpers.models.*</span>, <span class="font-mono">helpers.mongoose.*</span>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
<div id="modal-page" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
|
353
|
+
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
|
354
|
+
<div class="flex items-center justify-between p-4 border-b">
|
|
355
|
+
<h2 id="modal-page-title" class="text-lg font-semibold">Create Page</h2>
|
|
356
|
+
<button onclick="closePageModal()" class="text-gray-500 hover:text-gray-700"><i class="ti ti-x text-xl"></i></button>
|
|
357
|
+
</div>
|
|
358
|
+
<form id="page-form" class="p-4 space-y-4">
|
|
359
|
+
<input type="hidden" id="page-id">
|
|
360
|
+
<div class="grid grid-cols-2 gap-4">
|
|
361
|
+
<div>
|
|
362
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Title *</label>
|
|
363
|
+
<input type="text" id="page-title" required class="w-full border rounded px-3 py-2">
|
|
364
|
+
</div>
|
|
365
|
+
<div>
|
|
366
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Slug *</label>
|
|
367
|
+
<input type="text" id="page-slug" required pattern="[a-z0-9]+(-[a-z0-9]+)*" class="w-full border rounded px-3 py-2" placeholder="my-page">
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
<div class="grid grid-cols-2 gap-4">
|
|
371
|
+
<div>
|
|
372
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Collection</label>
|
|
373
|
+
<select id="page-collection" class="w-full border rounded px-3 py-2">
|
|
374
|
+
<option value="">No collection (root level)</option>
|
|
375
|
+
</select>
|
|
376
|
+
</div>
|
|
377
|
+
<div>
|
|
378
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
|
379
|
+
<select id="page-status" class="w-full border rounded px-3 py-2">
|
|
380
|
+
<option value="draft">Draft</option>
|
|
381
|
+
<option value="published">Published</option>
|
|
382
|
+
<option value="archived">Archived</option>
|
|
383
|
+
</select>
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
<div class="grid grid-cols-2 gap-4">
|
|
387
|
+
<div>
|
|
388
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Template</label>
|
|
389
|
+
<select id="page-template" class="w-full border rounded px-3 py-2">
|
|
390
|
+
<option value="default">Default</option>
|
|
391
|
+
<option value="landing">Landing Page</option>
|
|
392
|
+
<option value="article">Article</option>
|
|
393
|
+
<option value="listing">Listing</option>
|
|
394
|
+
</select>
|
|
395
|
+
</div>
|
|
396
|
+
<div>
|
|
397
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Layout</label>
|
|
398
|
+
<select id="page-layout" class="w-full border rounded px-3 py-2">
|
|
399
|
+
<option value="default">Default</option>
|
|
400
|
+
<option value="minimal">Minimal</option>
|
|
401
|
+
<option value="sidebar">Sidebar</option>
|
|
402
|
+
</select>
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
<div>
|
|
406
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">SEO Title</label>
|
|
407
|
+
<input type="text" id="page-seo-title" class="w-full border rounded px-3 py-2">
|
|
408
|
+
</div>
|
|
409
|
+
<div>
|
|
410
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">SEO Description</label>
|
|
411
|
+
<textarea id="page-seo-description" rows="2" class="w-full border rounded px-3 py-2"></textarea>
|
|
412
|
+
</div>
|
|
413
|
+
<div>
|
|
414
|
+
<div class="flex items-center justify-between mb-2">
|
|
415
|
+
<label class="block text-sm font-medium text-gray-700">Blocks</label>
|
|
416
|
+
<div class="flex items-center gap-2">
|
|
417
|
+
<select id="blocks-add-type" class="border rounded px-3 py-2 text-sm">
|
|
418
|
+
<option value="">Select block type</option>
|
|
419
|
+
</select>
|
|
420
|
+
<button type="button" onclick="addSelectedBlock()" class="bg-gray-100 text-gray-800 px-3 py-2 rounded text-sm hover:bg-gray-200">
|
|
421
|
+
<i class="ti ti-plus"></i> Add Block
|
|
422
|
+
</button>
|
|
423
|
+
<select id="blocks-add-contextdef" class="border rounded px-3 py-2 text-sm">
|
|
424
|
+
<option value="">Select context block</option>
|
|
425
|
+
</select>
|
|
426
|
+
<button type="button" onclick="addSelectedContextBlockDefinition()" class="bg-gray-100 text-gray-800 px-3 py-2 rounded text-sm hover:bg-gray-200">
|
|
427
|
+
<i class="ti ti-plus"></i> Add Context Block
|
|
428
|
+
</button>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
<div class="text-xs text-gray-500 mb-2">
|
|
432
|
+
Drag to reorder. Edit fields per block. Types/fields come from the JSON Config alias: <span class="font-mono">page-builder-blocks-schema</span>.
|
|
433
|
+
</div>
|
|
434
|
+
<div id="blocks-editor" class="space-y-3"></div>
|
|
435
|
+
</div>
|
|
436
|
+
<div>
|
|
437
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Custom CSS</label>
|
|
438
|
+
<textarea id="page-css" rows="3" class="w-full border rounded px-3 py-2 font-mono text-sm"></textarea>
|
|
439
|
+
</div>
|
|
440
|
+
<div>
|
|
441
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Custom JS</label>
|
|
442
|
+
<textarea id="page-js" rows="3" class="w-full border rounded px-3 py-2 font-mono text-sm"></textarea>
|
|
443
|
+
</div>
|
|
444
|
+
|
|
445
|
+
<div>
|
|
446
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Repeat (JSON)</label>
|
|
447
|
+
<textarea id="page-repeat" rows="3" class="w-full border rounded px-3 py-2 font-mono text-sm" placeholder='{"paramKey":"slug"}'></textarea>
|
|
448
|
+
<p class="text-xs text-gray-500 mt-1">Optional. For dynamic pages via a single Page definition. Convention: use slug <span class="font-mono">_</span> for repeat pages.</p>
|
|
449
|
+
</div>
|
|
450
|
+
<div class="flex justify-end gap-2 pt-4 border-t">
|
|
451
|
+
<button type="button" onclick="closePageModal()" class="px-4 py-2 border rounded hover:bg-gray-50">Cancel</button>
|
|
452
|
+
<button type="button" onclick="testContextPhaseForCurrentPage()" class="px-4 py-2 border rounded hover:bg-gray-50">Test Context</button>
|
|
453
|
+
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Save Page</button>
|
|
454
|
+
</div>
|
|
455
|
+
</form>
|
|
456
|
+
</div>
|
|
457
|
+
</div>
|
|
458
|
+
|
|
459
|
+
<div id="modal-collection" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
|
460
|
+
<div class="bg-white rounded-lg shadow-xl w-full max-w-md m-4">
|
|
461
|
+
<div class="flex items-center justify-between p-4 border-b">
|
|
462
|
+
<h2 id="modal-collection-title" class="text-lg font-semibold">Create Collection</h2>
|
|
463
|
+
<button onclick="closeCollectionModal()" class="text-gray-500 hover:text-gray-700"><i class="ti ti-x text-xl"></i></button>
|
|
464
|
+
</div>
|
|
465
|
+
<form id="collection-form" class="p-4 space-y-4">
|
|
466
|
+
<input type="hidden" id="collection-id">
|
|
467
|
+
<div>
|
|
468
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
|
469
|
+
<input type="text" id="collection-name" required class="w-full border rounded px-3 py-2">
|
|
470
|
+
</div>
|
|
471
|
+
<div>
|
|
472
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Slug *</label>
|
|
473
|
+
<input type="text" id="collection-slug" required pattern="[a-z0-9]+(-[a-z0-9]+)*" class="w-full border rounded px-3 py-2" placeholder="my-collection">
|
|
474
|
+
</div>
|
|
475
|
+
<div>
|
|
476
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
|
477
|
+
<textarea id="collection-description" rows="2" class="w-full border rounded px-3 py-2"></textarea>
|
|
478
|
+
</div>
|
|
479
|
+
<div>
|
|
480
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
|
481
|
+
<select id="collection-status" class="w-full border rounded px-3 py-2">
|
|
482
|
+
<option value="active">Active</option>
|
|
483
|
+
<option value="archived">Archived</option>
|
|
484
|
+
</select>
|
|
485
|
+
</div>
|
|
486
|
+
<div class="flex justify-end gap-2 pt-4 border-t">
|
|
487
|
+
<button type="button" onclick="closeCollectionModal()" class="px-4 py-2 border rounded hover:bg-gray-50">Cancel</button>
|
|
488
|
+
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Save Collection</button>
|
|
489
|
+
</div>
|
|
490
|
+
</form>
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
|
|
494
|
+
<div id="modal-ejs" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
|
495
|
+
<div class="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto m-4">
|
|
496
|
+
<div class="flex items-center justify-between p-4 border-b">
|
|
497
|
+
<div>
|
|
498
|
+
<h2 id="modal-ejs-title" class="text-lg font-semibold">Edit EJS</h2>
|
|
499
|
+
<div id="modal-ejs-path" class="text-xs text-gray-500 font-mono"></div>
|
|
500
|
+
</div>
|
|
501
|
+
<button onclick="closeEjsModal()" class="text-gray-500 hover:text-gray-700"><i class="ti ti-x text-xl"></i></button>
|
|
502
|
+
</div>
|
|
503
|
+
<div class="p-4 space-y-3">
|
|
504
|
+
<div class="flex items-center gap-3">
|
|
505
|
+
<label class="flex items-center gap-2 text-sm text-gray-700">
|
|
506
|
+
<input id="ejs-enabled" type="checkbox" />
|
|
507
|
+
<span>Override enabled</span>
|
|
508
|
+
</label>
|
|
509
|
+
<button type="button" onclick="revertEjsOverride()" class="px-3 py-2 text-sm border rounded hover:bg-gray-50">Revert</button>
|
|
510
|
+
<button type="button" onclick="loadEjsHistory()" class="px-3 py-2 text-sm border rounded hover:bg-gray-50">History</button>
|
|
511
|
+
</div>
|
|
512
|
+
|
|
513
|
+
<textarea id="ejs-content" class="w-full border rounded px-3 py-2 font-mono text-sm" rows="18"></textarea>
|
|
514
|
+
|
|
515
|
+
<div>
|
|
516
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">AI vibe prompt</label>
|
|
517
|
+
<div class="flex gap-2">
|
|
518
|
+
<input id="ejs-ai-prompt" type="text" class="flex-1 border rounded px-3 py-2 text-sm" placeholder="Describe the change to apply..." />
|
|
519
|
+
<button type="button" onclick="runEjsVibe()" class="bg-purple-600 text-white px-4 py-2 rounded text-sm hover:bg-purple-700">Run</button>
|
|
520
|
+
</div>
|
|
521
|
+
</div>
|
|
522
|
+
|
|
523
|
+
<div id="ejs-history" class="hidden border rounded p-3 bg-gray-50">
|
|
524
|
+
<div class="text-sm font-medium text-gray-800 mb-2">History</div>
|
|
525
|
+
<div id="ejs-history-items" class="space-y-2"></div>
|
|
526
|
+
</div>
|
|
527
|
+
|
|
528
|
+
<div class="flex justify-end gap-2 pt-4 border-t">
|
|
529
|
+
<button type="button" onclick="closeEjsModal()" class="px-4 py-2 border rounded hover:bg-gray-50">Close</button>
|
|
530
|
+
<button type="button" onclick="saveEjsOverride()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Save</button>
|
|
531
|
+
</div>
|
|
532
|
+
</div>
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
|
|
536
|
+
<div id="modal-create-ejs" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
|
537
|
+
<div class="bg-white rounded-lg shadow-xl w-full max-w-md m-4">
|
|
538
|
+
<div class="flex items-center justify-between p-4 border-b">
|
|
539
|
+
<h2 id="modal-create-ejs-title" class="text-lg font-semibold">Create</h2>
|
|
540
|
+
<button onclick="closeCreateEjsModal()" class="text-gray-500 hover:text-gray-700"><i class="ti ti-x text-xl"></i></button>
|
|
541
|
+
</div>
|
|
542
|
+
<form id="create-ejs-form" class="p-4 space-y-4">
|
|
543
|
+
<input type="hidden" id="create-ejs-kind" />
|
|
544
|
+
<div>
|
|
545
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Key *</label>
|
|
546
|
+
<input type="text" id="create-ejs-key" required pattern="[a-z0-9]+(-[a-z0-9]+)*" class="w-full border rounded px-3 py-2" placeholder="custom-template" />
|
|
547
|
+
</div>
|
|
548
|
+
<div class="flex justify-end gap-2 pt-4 border-t">
|
|
549
|
+
<button type="button" onclick="closeCreateEjsModal()" class="px-4 py-2 border rounded hover:bg-gray-50">Cancel</button>
|
|
550
|
+
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Create</button>
|
|
551
|
+
</div>
|
|
552
|
+
</form>
|
|
553
|
+
</div>
|
|
554
|
+
</div>
|
|
555
|
+
|
|
556
|
+
<div id="modal-blockdef" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
|
557
|
+
<div class="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto m-4">
|
|
558
|
+
<div class="flex items-center justify-between p-4 border-b">
|
|
559
|
+
<h2 id="modal-blockdef-title" class="text-lg font-semibold">Block</h2>
|
|
560
|
+
<button onclick="closeBlockDefinitionModal()" class="text-gray-500 hover:text-gray-700"><i class="ti ti-x text-xl"></i></button>
|
|
561
|
+
</div>
|
|
562
|
+
<form id="blockdef-form" class="p-4 space-y-4">
|
|
563
|
+
<div class="grid grid-cols-2 gap-4">
|
|
564
|
+
<div>
|
|
565
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Code *</label>
|
|
566
|
+
<input type="text" id="blockdef-code" required pattern="[a-z][a-z0-9_-]{1,63}" class="w-full border rounded px-3 py-2" placeholder="pricing" />
|
|
567
|
+
<div class="text-xs text-gray-500 mt-1">Lowercase, stable identifier used as <span class="font-mono">block.type</span>.</div>
|
|
568
|
+
</div>
|
|
569
|
+
<div>
|
|
570
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Label *</label>
|
|
571
|
+
<input type="text" id="blockdef-label" required class="w-full border rounded px-3 py-2" placeholder="Pricing" />
|
|
572
|
+
</div>
|
|
573
|
+
</div>
|
|
574
|
+
<div>
|
|
575
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
|
576
|
+
<input type="text" id="blockdef-description" class="w-full border rounded px-3 py-2" />
|
|
577
|
+
</div>
|
|
578
|
+
<div>
|
|
579
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Fields (JSON object)</label>
|
|
580
|
+
<textarea id="blockdef-fields" rows="10" class="w-full border rounded px-3 py-2 font-mono text-xs"></textarea>
|
|
581
|
+
<div class="text-xs text-gray-500 mt-1">Keys are prop names; values define field type/label/options/example.</div>
|
|
582
|
+
</div>
|
|
583
|
+
|
|
584
|
+
<div>
|
|
585
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">AI prompt</label>
|
|
586
|
+
<div class="flex gap-2">
|
|
587
|
+
<input id="blockdef-ai-prompt" type="text" class="flex-1 border rounded px-3 py-2 text-sm" placeholder="Describe the block or the edits..." />
|
|
588
|
+
<button type="button" onclick="runAiForBlockDefinition()" class="bg-purple-600 text-white px-4 py-2 rounded text-sm hover:bg-purple-700">Run</button>
|
|
589
|
+
</div>
|
|
590
|
+
<div class="text-xs text-gray-500 mt-1">For existing blocks, uses AI propose; for new blocks, uses AI generate.</div>
|
|
591
|
+
</div>
|
|
592
|
+
|
|
593
|
+
<div class="flex justify-end gap-2 pt-4 border-t">
|
|
594
|
+
<button type="button" onclick="closeBlockDefinitionModal()" class="px-4 py-2 border rounded hover:bg-gray-50">Cancel</button>
|
|
595
|
+
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Save</button>
|
|
596
|
+
</div>
|
|
597
|
+
</form>
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
|
|
601
|
+
<div id="toast" class="fixed bottom-4 right-4 px-4 py-2 rounded shadow-lg text-white hidden z-50"></div>
|
|
602
|
+
|
|
603
|
+
<script>
|
|
604
|
+
const API_BASE = window.location.origin + "<%= baseUrl || '' %>";
|
|
605
|
+
const ADMIN_PATH = '<%= adminPath %>';
|
|
606
|
+
|
|
607
|
+
const state = {
|
|
608
|
+
pages: { items: [], offset: 0, limit: 25, total: 0 },
|
|
609
|
+
collections: { items: [], offset: 0, limit: 25, total: 0 },
|
|
610
|
+
selectedBlockId: null,
|
|
611
|
+
ctxBlocksAiProposal: null,
|
|
612
|
+
contextBlockDefs: { items: [], selectedCode: null },
|
|
613
|
+
pageContextBlockDefs: { items: [] },
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
function showToast(message, type = 'success') {
|
|
617
|
+
const toast = document.getElementById('toast');
|
|
618
|
+
toast.textContent = message;
|
|
619
|
+
toast.className = `fixed bottom-4 right-4 px-4 py-2 rounded shadow-lg text-white z-50 ${type === 'error' ? 'bg-red-600' : 'bg-green-600'}`;
|
|
620
|
+
setTimeout(() => toast.classList.add('hidden'), 3000);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function switchTab(tab) {
|
|
624
|
+
const tabs = ['pages', 'collections', 'templates', 'layouts', 'blocks'];
|
|
625
|
+
tabs.forEach((t) => {
|
|
626
|
+
document.getElementById(`tab-${t}`).classList.toggle('tab-active', tab === t);
|
|
627
|
+
document.getElementById(`tab-${t}`).classList.toggle('text-gray-600', tab !== t);
|
|
628
|
+
document.getElementById(`panel-${t}`).classList.toggle('hidden', tab !== t);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
if (tab === 'blocks') {
|
|
632
|
+
if (!state.blocksSubTab) state.blocksSubTab = 'definitions';
|
|
633
|
+
switchBlocksSubTab(state.blocksSubTab);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function switchBlocksSubTab(subTab) {
|
|
638
|
+
const tabs = ['definitions', 'context', 'settings'];
|
|
639
|
+
const normalized = tabs.includes(subTab) ? subTab : 'definitions';
|
|
640
|
+
state.blocksSubTab = normalized;
|
|
641
|
+
|
|
642
|
+
tabs.forEach((t) => {
|
|
643
|
+
document.getElementById(`blocks-subtab-${t}`)?.classList.toggle('tab-active', normalized === t);
|
|
644
|
+
document.getElementById(`blocks-subtab-${t}`)?.classList.toggle('text-gray-600', normalized !== t);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
document.getElementById('panel-blocks-definitions')?.classList.toggle('hidden', normalized !== 'definitions');
|
|
648
|
+
document.getElementById('panel-blocks-context')?.classList.toggle('hidden', normalized !== 'context');
|
|
649
|
+
document.getElementById('panel-blocks-settings')?.classList.toggle('hidden', normalized !== 'settings');
|
|
650
|
+
|
|
651
|
+
if (normalized === 'settings') {
|
|
652
|
+
loadBlocksAiSettings();
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (normalized === 'context') {
|
|
656
|
+
loadContextBlockDefinitions();
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function safeJsonParse(raw, fallback) {
|
|
661
|
+
try {
|
|
662
|
+
return JSON.parse(String(raw || ''));
|
|
663
|
+
} catch (_) {
|
|
664
|
+
return fallback;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function setContextBlocksWarnings(warnings) {
|
|
669
|
+
const el = document.getElementById('ctxblocks-ai-warnings');
|
|
670
|
+
if (!el) return;
|
|
671
|
+
const arr = Array.isArray(warnings) ? warnings : [];
|
|
672
|
+
if (!arr.length) {
|
|
673
|
+
el.classList.add('hidden');
|
|
674
|
+
el.textContent = '';
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
el.classList.remove('hidden');
|
|
678
|
+
el.innerHTML = '<div class="font-semibold text-amber-900 mb-1">Warnings</div>' + arr.map((w) => `<div class="text-amber-900">- ${escapeHtml(String(w || ''))}</div>`).join('');
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function setContextBlocksProposal(proposal) {
|
|
682
|
+
state.ctxBlocksAiProposal = proposal && typeof proposal === 'object' ? proposal : null;
|
|
683
|
+
const preview = document.getElementById('ctxblocks-ai-preview');
|
|
684
|
+
if (preview) {
|
|
685
|
+
preview.textContent = state.ctxBlocksAiProposal ? JSON.stringify(state.ctxBlocksAiProposal, null, 2) : '';
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function openCreateContextBlockDefinition() {
|
|
690
|
+
state.contextBlockDefs.selectedCode = null;
|
|
691
|
+
const codeEl = document.getElementById('ctxblockdef-code');
|
|
692
|
+
if (codeEl) {
|
|
693
|
+
codeEl.disabled = false;
|
|
694
|
+
codeEl.value = '';
|
|
695
|
+
}
|
|
696
|
+
const labelEl = document.getElementById('ctxblockdef-label');
|
|
697
|
+
if (labelEl) labelEl.value = '';
|
|
698
|
+
const descEl = document.getElementById('ctxblockdef-description');
|
|
699
|
+
if (descEl) descEl.value = '';
|
|
700
|
+
const typeEl = document.getElementById('ctxblockdef-type');
|
|
701
|
+
if (typeEl) typeEl.value = 'context.db_query';
|
|
702
|
+
const propsEl = document.getElementById('ctxblockdef-props');
|
|
703
|
+
if (propsEl) propsEl.value = JSON.stringify({}, null, 2);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function setContextBlockDefinitionEditor(item) {
|
|
707
|
+
const code = String(item?.code || '').trim();
|
|
708
|
+
state.contextBlockDefs.selectedCode = code || null;
|
|
709
|
+
const codeEl = document.getElementById('ctxblockdef-code');
|
|
710
|
+
if (codeEl) {
|
|
711
|
+
codeEl.value = code;
|
|
712
|
+
codeEl.disabled = Boolean(code);
|
|
713
|
+
}
|
|
714
|
+
const labelEl = document.getElementById('ctxblockdef-label');
|
|
715
|
+
if (labelEl) labelEl.value = String(item?.label || '');
|
|
716
|
+
const descEl = document.getElementById('ctxblockdef-description');
|
|
717
|
+
if (descEl) descEl.value = String(item?.description || '');
|
|
718
|
+
const typeEl = document.getElementById('ctxblockdef-type');
|
|
719
|
+
if (typeEl) typeEl.value = String(item?.type || 'context.db_query');
|
|
720
|
+
const propsEl = document.getElementById('ctxblockdef-props');
|
|
721
|
+
if (propsEl) propsEl.value = JSON.stringify(item?.props || {}, null, 2);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function loadContextBlockDefinitions() {
|
|
725
|
+
try {
|
|
726
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/context-block-definitions`, { credentials: 'same-origin' });
|
|
727
|
+
const data = await res.json();
|
|
728
|
+
if (!res.ok) throw new Error(data.error);
|
|
729
|
+
state.contextBlockDefs.items = Array.isArray(data.items) ? data.items : [];
|
|
730
|
+
renderContextBlockDefinitions();
|
|
731
|
+
if (!state.contextBlockDefs.selectedCode) {
|
|
732
|
+
openCreateContextBlockDefinition();
|
|
733
|
+
}
|
|
734
|
+
} catch (e) {
|
|
735
|
+
state.contextBlockDefs.items = [];
|
|
736
|
+
renderContextBlockDefinitions();
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function loadActiveContextBlockDefinitionsForPageModal() {
|
|
741
|
+
try {
|
|
742
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/context-block-definitions?active=true`, { credentials: 'same-origin' });
|
|
743
|
+
const data = await res.json();
|
|
744
|
+
if (!res.ok) throw new Error(data.error);
|
|
745
|
+
state.pageContextBlockDefs.items = Array.isArray(data.items) ? data.items : [];
|
|
746
|
+
populateContextBlockDefinitionSelect();
|
|
747
|
+
} catch (_) {
|
|
748
|
+
state.pageContextBlockDefs.items = [];
|
|
749
|
+
populateContextBlockDefinitionSelect();
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function populateContextBlockDefinitionSelect() {
|
|
754
|
+
const select = document.getElementById('blocks-add-contextdef');
|
|
755
|
+
if (!select) return;
|
|
756
|
+
const current = select.value;
|
|
757
|
+
select.innerHTML = '<option value="">Select context block</option>';
|
|
758
|
+
(state.pageContextBlockDefs.items || []).forEach((d) => {
|
|
759
|
+
const code = String(d.code || '').trim();
|
|
760
|
+
if (!code) return;
|
|
761
|
+
const label = String(d.label || code);
|
|
762
|
+
const type = String(d.type || '');
|
|
763
|
+
select.innerHTML += `<option value="${escapeHtml(code)}">${escapeHtml(label)} (${escapeHtml(code)}) — ${escapeHtml(type)}</option>`;
|
|
764
|
+
});
|
|
765
|
+
select.value = current;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function renderContextBlockDefinitions() {
|
|
769
|
+
const tbody = document.getElementById('ctxblockdefs-tbody');
|
|
770
|
+
const empty = document.getElementById('ctxblockdefs-empty');
|
|
771
|
+
if (!tbody || !empty) return;
|
|
772
|
+
const items = state.contextBlockDefs.items || [];
|
|
773
|
+
|
|
774
|
+
if (!items.length) {
|
|
775
|
+
tbody.innerHTML = '';
|
|
776
|
+
empty.classList.remove('hidden');
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
empty.classList.add('hidden');
|
|
781
|
+
tbody.innerHTML = items
|
|
782
|
+
.map((d) => {
|
|
783
|
+
const code = String(d.code || '');
|
|
784
|
+
const type = String(d.type || '');
|
|
785
|
+
return `
|
|
786
|
+
<tr class="border-b hover:bg-gray-50">
|
|
787
|
+
<td class="px-4 py-3 font-mono">${escapeHtml(code)}</td>
|
|
788
|
+
<td class="px-4 py-3 font-mono text-xs text-gray-600">${escapeHtml(type)}</td>
|
|
789
|
+
<td class="px-4 py-3 text-right">
|
|
790
|
+
<button onclick="editContextBlockDefinition('${escapeHtml(code)}')" class="text-blue-600 hover:text-blue-800"><i class="ti ti-edit"></i></button>
|
|
791
|
+
</td>
|
|
792
|
+
</tr>
|
|
793
|
+
`;
|
|
794
|
+
})
|
|
795
|
+
.join('');
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function editContextBlockDefinition(code) {
|
|
799
|
+
try {
|
|
800
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/context-block-definitions/${encodeURIComponent(code)}`, { credentials: 'same-origin' });
|
|
801
|
+
const data = await res.json();
|
|
802
|
+
if (!res.ok) throw new Error(data.error);
|
|
803
|
+
setContextBlockDefinitionEditor(data.item);
|
|
804
|
+
} catch (e) {
|
|
805
|
+
showToast(e.message, 'error');
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function readContextBlockDefinitionEditor() {
|
|
810
|
+
const code = String(document.getElementById('ctxblockdef-code')?.value || '').trim().toLowerCase();
|
|
811
|
+
const label = String(document.getElementById('ctxblockdef-label')?.value || '').trim();
|
|
812
|
+
const description = String(document.getElementById('ctxblockdef-description')?.value || '');
|
|
813
|
+
const type = String(document.getElementById('ctxblockdef-type')?.value || '').trim();
|
|
814
|
+
const propsRaw = String(document.getElementById('ctxblockdef-props')?.value || '').trim();
|
|
815
|
+
|
|
816
|
+
let props = {};
|
|
817
|
+
if (propsRaw) {
|
|
818
|
+
try {
|
|
819
|
+
props = JSON.parse(propsRaw);
|
|
820
|
+
} catch (e) {
|
|
821
|
+
return { error: 'Props must be valid JSON', item: null };
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return {
|
|
826
|
+
error: null,
|
|
827
|
+
item: { code, label, description, type, props },
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function saveContextBlockDefinitionFromEditor() {
|
|
832
|
+
const { error, item } = readContextBlockDefinitionEditor();
|
|
833
|
+
if (error) {
|
|
834
|
+
showToast(error, 'error');
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (!item.code) {
|
|
838
|
+
showToast('code is required', 'error');
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (!item.label) {
|
|
842
|
+
showToast('label is required', 'error');
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (!item.type) {
|
|
846
|
+
showToast('type is required', 'error');
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const isEdit = Boolean(state.contextBlockDefs.selectedCode);
|
|
851
|
+
try {
|
|
852
|
+
const url = isEdit
|
|
853
|
+
? `${API_BASE}/api/admin/pages/context-block-definitions/${encodeURIComponent(state.contextBlockDefs.selectedCode)}`
|
|
854
|
+
: `${API_BASE}/api/admin/pages/context-block-definitions`;
|
|
855
|
+
const method = isEdit ? 'PUT' : 'POST';
|
|
856
|
+
|
|
857
|
+
const res = await fetch(url, {
|
|
858
|
+
method,
|
|
859
|
+
headers: { 'Content-Type': 'application/json' },
|
|
860
|
+
credentials: 'same-origin',
|
|
861
|
+
body: JSON.stringify({
|
|
862
|
+
code: item.code,
|
|
863
|
+
label: item.label,
|
|
864
|
+
description: item.description,
|
|
865
|
+
type: item.type,
|
|
866
|
+
props: item.props,
|
|
867
|
+
isActive: true,
|
|
868
|
+
}),
|
|
869
|
+
});
|
|
870
|
+
const data = await res.json();
|
|
871
|
+
if (!res.ok) throw new Error(data.error);
|
|
872
|
+
showToast(isEdit ? 'Context block updated' : 'Context block created');
|
|
873
|
+
setContextBlockDefinitionEditor(data.item);
|
|
874
|
+
await loadContextBlockDefinitions();
|
|
875
|
+
} catch (e) {
|
|
876
|
+
showToast(e.message, 'error');
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async function deleteContextBlockDefinitionFromEditor() {
|
|
881
|
+
const code = state.contextBlockDefs.selectedCode;
|
|
882
|
+
if (!code) {
|
|
883
|
+
showToast('Select a definition first', 'error');
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (!confirm(`Delete context block definition "${code}"?`)) return;
|
|
887
|
+
|
|
888
|
+
try {
|
|
889
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/context-block-definitions/${encodeURIComponent(code)}`, {
|
|
890
|
+
method: 'DELETE',
|
|
891
|
+
credentials: 'same-origin',
|
|
892
|
+
});
|
|
893
|
+
const data = await res.json();
|
|
894
|
+
if (!res.ok) throw new Error(data.error);
|
|
895
|
+
showToast('Context block deleted');
|
|
896
|
+
openCreateContextBlockDefinition();
|
|
897
|
+
await loadContextBlockDefinitions();
|
|
898
|
+
} catch (e) {
|
|
899
|
+
showToast(e.message, 'error');
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async function aiGenerateContextBlockIntoEditor() {
|
|
904
|
+
const prompt = String(document.getElementById('ctxblocks-ai-prompt')?.value || '').trim();
|
|
905
|
+
const blockType = String(document.getElementById('ctxblockdef-type')?.value || '').trim();
|
|
906
|
+
const providerKey = String(document.getElementById('ctxblocks-ai-provider')?.value || '').trim();
|
|
907
|
+
const model = String(document.getElementById('ctxblocks-ai-model')?.value || '').trim();
|
|
908
|
+
if (!prompt) return;
|
|
909
|
+
if (!blockType) return;
|
|
910
|
+
|
|
911
|
+
try {
|
|
912
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/ai/context-blocks/generate`, {
|
|
913
|
+
method: 'POST',
|
|
914
|
+
headers: { 'Content-Type': 'application/json' },
|
|
915
|
+
credentials: 'same-origin',
|
|
916
|
+
body: JSON.stringify({ prompt, blockType, providerKey, model }),
|
|
917
|
+
});
|
|
918
|
+
const data = await res.json();
|
|
919
|
+
if (!res.ok) throw new Error(data.error);
|
|
920
|
+
|
|
921
|
+
const proposal = data.proposal;
|
|
922
|
+
document.getElementById('ctxblockdef-type').value = proposal.type;
|
|
923
|
+
document.getElementById('ctxblockdef-props').value = JSON.stringify(proposal.props || {}, null, 2);
|
|
924
|
+
setContextBlocksWarnings(data.warnings);
|
|
925
|
+
showToast('AI props loaded into editor');
|
|
926
|
+
} catch (e) {
|
|
927
|
+
showToast(e.message, 'error');
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function testContextBlockDefinitionFromEditor() {
|
|
932
|
+
const { error, item } = readContextBlockDefinitionEditor();
|
|
933
|
+
if (error) {
|
|
934
|
+
showToast(error, 'error');
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
try {
|
|
939
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/test-block`, {
|
|
940
|
+
method: 'POST',
|
|
941
|
+
headers: { 'Content-Type': 'application/json' },
|
|
942
|
+
credentials: 'same-origin',
|
|
943
|
+
body: JSON.stringify({ block: { type: item.type, props: item.props }, routePath: '/_test', mockContext: null }),
|
|
944
|
+
});
|
|
945
|
+
const data = await res.json();
|
|
946
|
+
if (!res.ok) throw new Error(data.error);
|
|
947
|
+
const varsStr = JSON.stringify(data.vars || {}, null, 2);
|
|
948
|
+
showToast(`Test OK (${data.elapsedMs}ms). Vars: ${varsStr.substring(0, 200)}${varsStr.length > 200 ? '...' : ''}`);
|
|
949
|
+
} catch (e) {
|
|
950
|
+
showToast(e.message, 'error');
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function getSelectedBlock() {
|
|
955
|
+
const id = state.selectedBlockId;
|
|
956
|
+
if (!id) return null;
|
|
957
|
+
const blocks = state.currentBlocks || [];
|
|
958
|
+
return blocks.find((b) => b && String(b.id) === String(id)) || null;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function setSelectedBlock(blockId) {
|
|
962
|
+
state.selectedBlockId = blockId ? String(blockId) : null;
|
|
963
|
+
renderBlocksEditor();
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
async function aiGenerateContextBlock() {
|
|
967
|
+
const prompt = String(document.getElementById('ctxblocks-ai-prompt')?.value || '').trim();
|
|
968
|
+
const blockType = String(document.getElementById('ctxblocks-ai-type')?.value || '').trim();
|
|
969
|
+
const providerKey = String(document.getElementById('ctxblocks-ai-provider')?.value || '').trim();
|
|
970
|
+
const model = String(document.getElementById('ctxblocks-ai-model')?.value || '').trim();
|
|
971
|
+
if (!prompt) return;
|
|
972
|
+
if (!blockType) return;
|
|
973
|
+
|
|
974
|
+
try {
|
|
975
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/ai/context-blocks/generate`, {
|
|
976
|
+
method: 'POST',
|
|
977
|
+
headers: { 'Content-Type': 'application/json' },
|
|
978
|
+
credentials: 'same-origin',
|
|
979
|
+
body: JSON.stringify({ prompt, blockType, providerKey, model }),
|
|
980
|
+
});
|
|
981
|
+
const data = await res.json();
|
|
982
|
+
if (!res.ok) throw new Error(data.error);
|
|
983
|
+
setContextBlocksProposal(data.proposal);
|
|
984
|
+
setContextBlocksWarnings(data.warnings);
|
|
985
|
+
showToast('AI proposal loaded');
|
|
986
|
+
} catch (err) {
|
|
987
|
+
showToast(err.message, 'error');
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
async function aiProposeContextBlock() {
|
|
992
|
+
const prompt = String(document.getElementById('ctxblocks-ai-prompt')?.value || '').trim();
|
|
993
|
+
const providerKey = String(document.getElementById('ctxblocks-ai-provider')?.value || '').trim();
|
|
994
|
+
const model = String(document.getElementById('ctxblocks-ai-model')?.value || '').trim();
|
|
995
|
+
if (!prompt) return;
|
|
996
|
+
|
|
997
|
+
const current = getSelectedBlock();
|
|
998
|
+
if (!current) {
|
|
999
|
+
showToast('Select a block first', 'error');
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
if (!String(current.type || '').startsWith('context.')) {
|
|
1003
|
+
showToast('Selected block is not a context.* block', 'error');
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
try {
|
|
1008
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/ai/context-blocks/propose`, {
|
|
1009
|
+
method: 'POST',
|
|
1010
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1011
|
+
credentials: 'same-origin',
|
|
1012
|
+
body: JSON.stringify({ prompt, currentBlock: { type: current.type, props: current.props || {} }, providerKey, model }),
|
|
1013
|
+
});
|
|
1014
|
+
const data = await res.json();
|
|
1015
|
+
if (!res.ok) throw new Error(data.error);
|
|
1016
|
+
setContextBlocksProposal(data.proposal);
|
|
1017
|
+
setContextBlocksWarnings(data.warnings);
|
|
1018
|
+
showToast('AI proposal loaded');
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
showToast(err.message, 'error');
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function applyContextBlockProposal() {
|
|
1025
|
+
const proposal = state.ctxBlocksAiProposal;
|
|
1026
|
+
if (!proposal) return;
|
|
1027
|
+
|
|
1028
|
+
const type = String(proposal.type || '').trim();
|
|
1029
|
+
if (!type.startsWith('context.')) {
|
|
1030
|
+
showToast('Proposal must be context.*', 'error');
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const blocks = state.currentBlocks || [];
|
|
1035
|
+
const selected = getSelectedBlock();
|
|
1036
|
+
if (selected && String(selected.type || '').startsWith('context.')) {
|
|
1037
|
+
const idx = blocks.findIndex((b) => b && String(b.id) === String(selected.id));
|
|
1038
|
+
if (idx !== -1) {
|
|
1039
|
+
blocks[idx] = { ...blocks[idx], type: proposal.type, props: proposal.props || {} };
|
|
1040
|
+
state.currentBlocks = blocks;
|
|
1041
|
+
renderBlocksEditor();
|
|
1042
|
+
showToast('Applied to selected block');
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
state.currentBlocks = [...blocks, { id: uuid(), type: proposal.type, props: proposal.props || {} }];
|
|
1048
|
+
renderBlocksEditor();
|
|
1049
|
+
showToast('Added new context block');
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
async function testContextBlockProposal() {
|
|
1053
|
+
const proposal = state.ctxBlocksAiProposal;
|
|
1054
|
+
if (!proposal) return;
|
|
1055
|
+
|
|
1056
|
+
const pageId = String(document.getElementById('page-id')?.value || '').trim();
|
|
1057
|
+
const url = pageId
|
|
1058
|
+
? `${API_BASE}/api/admin/pages/pages/${encodeURIComponent(pageId)}/test-block`
|
|
1059
|
+
: `${API_BASE}/api/admin/pages/test-block`;
|
|
1060
|
+
|
|
1061
|
+
const mockContextRaw = String(document.getElementById('ctxblocks-ai-mock-context')?.value || '').trim();
|
|
1062
|
+
const mockContext = mockContextRaw ? safeJsonParse(mockContextRaw, null) : null;
|
|
1063
|
+
|
|
1064
|
+
try {
|
|
1065
|
+
const res = await fetch(url, {
|
|
1066
|
+
method: 'POST',
|
|
1067
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1068
|
+
credentials: 'same-origin',
|
|
1069
|
+
body: JSON.stringify({ block: proposal, routePath: '/_test', mockContext }),
|
|
1070
|
+
});
|
|
1071
|
+
const data = await res.json();
|
|
1072
|
+
if (!res.ok) throw new Error(data.error);
|
|
1073
|
+
const varsStr = JSON.stringify(data.vars || {}, null, 2);
|
|
1074
|
+
showToast(`Test OK (${data.elapsedMs}ms). Vars: ${varsStr.substring(0, 200)}${varsStr.length > 200 ? '...' : ''}`);
|
|
1075
|
+
} catch (err) {
|
|
1076
|
+
showToast(err.message, 'error');
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
async function loadLlmProvidersForBlocksSettings() {
|
|
1081
|
+
try {
|
|
1082
|
+
const res = await fetch(`${API_BASE}/api/admin/llm/config`, { credentials: 'same-origin' });
|
|
1083
|
+
const data = await res.json();
|
|
1084
|
+
if (!res.ok) throw new Error(data.error);
|
|
1085
|
+
|
|
1086
|
+
state.llmConfig = data && typeof data === 'object' ? data : {};
|
|
1087
|
+
|
|
1088
|
+
const providersObj = data.providers && typeof data.providers === 'object' ? data.providers : {};
|
|
1089
|
+
const providers = Object.entries(providersObj)
|
|
1090
|
+
.map(([key, val]) => ({
|
|
1091
|
+
key,
|
|
1092
|
+
label: String((val && val.label) || key),
|
|
1093
|
+
defaultModel: String((val && val.defaultModel) || ''),
|
|
1094
|
+
enabled: val ? val.enabled !== false : true,
|
|
1095
|
+
}))
|
|
1096
|
+
.filter((p) => p.enabled);
|
|
1097
|
+
|
|
1098
|
+
state.llmProviders = providers;
|
|
1099
|
+
return providers;
|
|
1100
|
+
} catch (_) {
|
|
1101
|
+
state.llmProviders = [];
|
|
1102
|
+
state.llmConfig = {};
|
|
1103
|
+
return [];
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
async function getSettingValueOrEmpty(key) {
|
|
1108
|
+
const res = await fetch(`${API_BASE}/api/admin/settings/${encodeURIComponent(key)}`, { credentials: 'same-origin' });
|
|
1109
|
+
if (res.status === 404) return '';
|
|
1110
|
+
const data = await res.json();
|
|
1111
|
+
if (!res.ok) throw new Error(data.error);
|
|
1112
|
+
return String(data.value || '').trim();
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
async function upsertStringSetting({ key, value, description }) {
|
|
1116
|
+
const putRes = await fetch(`${API_BASE}/api/admin/settings/${encodeURIComponent(key)}`, {
|
|
1117
|
+
method: 'PUT',
|
|
1118
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1119
|
+
credentials: 'same-origin',
|
|
1120
|
+
body: JSON.stringify({ value: String(value || '') }),
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
if (putRes.status === 404) {
|
|
1124
|
+
const createRes = await fetch(`${API_BASE}/api/admin/settings`, {
|
|
1125
|
+
method: 'POST',
|
|
1126
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1127
|
+
credentials: 'same-origin',
|
|
1128
|
+
body: JSON.stringify({
|
|
1129
|
+
key,
|
|
1130
|
+
value: String(value || ''),
|
|
1131
|
+
type: 'string',
|
|
1132
|
+
description: description || key,
|
|
1133
|
+
public: false,
|
|
1134
|
+
}),
|
|
1135
|
+
});
|
|
1136
|
+
const createData = await createRes.json();
|
|
1137
|
+
if (!createRes.ok) throw new Error(createData.error);
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const putData = await putRes.json().catch(() => ({}));
|
|
1142
|
+
if (!putRes.ok) throw new Error(putData.error || 'Failed to save setting');
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
async function loadBlocksAiSettings() {
|
|
1146
|
+
try {
|
|
1147
|
+
const providers = await loadLlmProvidersForBlocksSettings();
|
|
1148
|
+
const providerEl = document.getElementById('blocks-ai-provider');
|
|
1149
|
+
const modelEl = document.getElementById('blocks-ai-model');
|
|
1150
|
+
const hintEl = document.getElementById('blocks-ai-model-hint');
|
|
1151
|
+
if (!providerEl || !modelEl || !hintEl) return;
|
|
1152
|
+
|
|
1153
|
+
if (window.__llmProviderModelPicker && window.__llmProviderModelPicker.init) {
|
|
1154
|
+
await window.__llmProviderModelPicker.init({
|
|
1155
|
+
apiBase: API_BASE,
|
|
1156
|
+
providerInputId: 'blocks-ai-provider',
|
|
1157
|
+
modelInputId: 'blocks-ai-model',
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const systemDefaults = state.llmConfig && typeof state.llmConfig === 'object' ? state.llmConfig.systemDefaults : null;
|
|
1162
|
+
const blockDefaults = systemDefaults && typeof systemDefaults === 'object' ? systemDefaults['pageBuilder.blocks.generate'] : null;
|
|
1163
|
+
|
|
1164
|
+
const legacyProviderKey = await getSettingValueOrEmpty('pageBuilder.blocks.ai.providerKey');
|
|
1165
|
+
const legacyModel = await getSettingValueOrEmpty('pageBuilder.blocks.ai.model');
|
|
1166
|
+
|
|
1167
|
+
const providerKey = String((blockDefaults && blockDefaults.providerKey) || legacyProviderKey || '').trim();
|
|
1168
|
+
const model = String((blockDefaults && blockDefaults.model) || legacyModel || '').trim();
|
|
1169
|
+
|
|
1170
|
+
providerEl.value = providerKey;
|
|
1171
|
+
modelEl.value = model;
|
|
1172
|
+
|
|
1173
|
+
const selected = providers.find((p) => p.key === providerEl.value);
|
|
1174
|
+
hintEl.textContent = selected && selected.defaultModel
|
|
1175
|
+
? `Provider default model: ${selected.defaultModel}`
|
|
1176
|
+
: '';
|
|
1177
|
+
|
|
1178
|
+
providerEl.onchange = () => {
|
|
1179
|
+
const sel = providers.find((p) => p.key === providerEl.value);
|
|
1180
|
+
hintEl.textContent = sel && sel.defaultModel ? `Provider default model: ${sel.defaultModel}` : '';
|
|
1181
|
+
};
|
|
1182
|
+
} catch (err) {
|
|
1183
|
+
showToast(err.message, 'error');
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
async function saveBlocksAiSettings() {
|
|
1188
|
+
try {
|
|
1189
|
+
const providers = Array.isArray(state.llmProviders) ? state.llmProviders : [];
|
|
1190
|
+
const providerKey = String(document.getElementById('blocks-ai-provider')?.value || '').trim();
|
|
1191
|
+
const model = String(document.getElementById('blocks-ai-model')?.value || '').trim();
|
|
1192
|
+
|
|
1193
|
+
if (!providerKey) {
|
|
1194
|
+
showToast('Provider is required', 'error');
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
if (!providers.find((p) => p.key === providerKey)) {
|
|
1199
|
+
showToast('Provider must be one of the enabled LLM providers', 'error');
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
let systemDefaults = (state.llmConfig && typeof state.llmConfig === 'object' && state.llmConfig.systemDefaults)
|
|
1204
|
+
? state.llmConfig.systemDefaults
|
|
1205
|
+
: {};
|
|
1206
|
+
|
|
1207
|
+
systemDefaults = systemDefaults && typeof systemDefaults === 'object' ? systemDefaults : {};
|
|
1208
|
+
systemDefaults['pageBuilder.blocks.generate'] = {
|
|
1209
|
+
providerKey,
|
|
1210
|
+
model: model || '',
|
|
1211
|
+
};
|
|
1212
|
+
|
|
1213
|
+
await upsertStringSetting({
|
|
1214
|
+
key: 'llm.systemDefaults',
|
|
1215
|
+
value: JSON.stringify(systemDefaults, null, 2),
|
|
1216
|
+
description: 'System-specific default LLM provider/model overrides (map of systemKey => {providerKey, model})',
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
await upsertStringSetting({
|
|
1220
|
+
key: 'pageBuilder.blocks.ai.providerKey',
|
|
1221
|
+
value: providerKey,
|
|
1222
|
+
description: 'Default LLM providerKey for Page Builder Blocks AI assistant',
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
await upsertStringSetting({
|
|
1226
|
+
key: 'pageBuilder.blocks.ai.model',
|
|
1227
|
+
value: model,
|
|
1228
|
+
description: 'Default LLM model for Page Builder Blocks AI assistant',
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
showToast('Blocks AI settings saved');
|
|
1232
|
+
} catch (err) {
|
|
1233
|
+
showToast(err.message, 'error');
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
async function loadCollections() {
|
|
1238
|
+
try {
|
|
1239
|
+
const search = document.getElementById('collections-search')?.value || '';
|
|
1240
|
+
const params = new URLSearchParams({ offset: state.collections.offset, limit: state.collections.limit });
|
|
1241
|
+
if (search) params.set('search', search);
|
|
1242
|
+
|
|
1243
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/collections?${params}`, { credentials: 'same-origin' });
|
|
1244
|
+
const data = await res.json();
|
|
1245
|
+
|
|
1246
|
+
if (!res.ok) throw new Error(data.error);
|
|
1247
|
+
|
|
1248
|
+
state.collections.items = data.collections || [];
|
|
1249
|
+
state.collections.total = data.total || 0;
|
|
1250
|
+
renderCollections();
|
|
1251
|
+
updateCollectionSelect();
|
|
1252
|
+
} catch (e) {
|
|
1253
|
+
showToast(e.message, 'error');
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function renderCollections() {
|
|
1258
|
+
const tbody = document.getElementById('collections-tbody');
|
|
1259
|
+
const empty = document.getElementById('collections-empty');
|
|
1260
|
+
|
|
1261
|
+
if (state.collections.items.length === 0) {
|
|
1262
|
+
tbody.innerHTML = '';
|
|
1263
|
+
empty.classList.remove('hidden');
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
empty.classList.add('hidden');
|
|
1268
|
+
tbody.innerHTML = state.collections.items.map(c => `
|
|
1269
|
+
<tr class="border-b hover:bg-gray-50">
|
|
1270
|
+
<td class="px-4 py-3 font-medium">${escapeHtml(c.name)}</td>
|
|
1271
|
+
<td class="px-4 py-3 text-gray-600">/${escapeHtml(c.slug)}</td>
|
|
1272
|
+
<td class="px-4 py-3">
|
|
1273
|
+
<span class="px-2 py-1 text-xs rounded ${c.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}">${c.status}</span>
|
|
1274
|
+
</td>
|
|
1275
|
+
<td class="px-4 py-3 text-gray-600">${formatDate(c.updatedAt)}</td>
|
|
1276
|
+
<td class="px-4 py-3 text-right">
|
|
1277
|
+
<button onclick="editCollection('${c._id}')" class="text-blue-600 hover:text-blue-800 mr-2"><i class="ti ti-edit"></i></button>
|
|
1278
|
+
<button onclick="deleteCollection('${c._id}', '${escapeHtml(c.name)}')" class="text-red-600 hover:text-red-800"><i class="ti ti-trash"></i></button>
|
|
1279
|
+
</td>
|
|
1280
|
+
</tr>
|
|
1281
|
+
`).join('');
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
async function loadPages() {
|
|
1285
|
+
try {
|
|
1286
|
+
const search = document.getElementById('pages-search')?.value || '';
|
|
1287
|
+
const status = document.getElementById('pages-status')?.value || '';
|
|
1288
|
+
const collectionId = document.getElementById('pages-collection')?.value || '';
|
|
1289
|
+
|
|
1290
|
+
const params = new URLSearchParams({ offset: state.pages.offset, limit: state.pages.limit });
|
|
1291
|
+
if (search) params.set('search', search);
|
|
1292
|
+
if (status) params.set('status', status);
|
|
1293
|
+
if (collectionId) params.set('collectionId', collectionId);
|
|
1294
|
+
|
|
1295
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/pages?${params}`, { credentials: 'same-origin' });
|
|
1296
|
+
const data = await res.json();
|
|
1297
|
+
|
|
1298
|
+
if (!res.ok) throw new Error(data.error);
|
|
1299
|
+
|
|
1300
|
+
state.pages.items = data.pages || [];
|
|
1301
|
+
state.pages.total = data.total || 0;
|
|
1302
|
+
renderPages();
|
|
1303
|
+
} catch (e) {
|
|
1304
|
+
showToast(e.message, 'error');
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function renderPages() {
|
|
1309
|
+
const tbody = document.getElementById('pages-tbody');
|
|
1310
|
+
const empty = document.getElementById('pages-empty');
|
|
1311
|
+
const info = document.getElementById('pages-info');
|
|
1312
|
+
|
|
1313
|
+
if (state.pages.items.length === 0) {
|
|
1314
|
+
tbody.innerHTML = '';
|
|
1315
|
+
empty.classList.remove('hidden');
|
|
1316
|
+
info.textContent = '';
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
empty.classList.add('hidden');
|
|
1321
|
+
info.textContent = `Showing ${state.pages.offset + 1}-${Math.min(state.pages.offset + state.pages.items.length, state.pages.total)} of ${state.pages.total}`;
|
|
1322
|
+
|
|
1323
|
+
document.getElementById('pages-prev').disabled = state.pages.offset === 0;
|
|
1324
|
+
document.getElementById('pages-next').disabled = state.pages.offset + state.pages.limit >= state.pages.total;
|
|
1325
|
+
|
|
1326
|
+
tbody.innerHTML = state.pages.items.map(p => {
|
|
1327
|
+
const collectionSlug = p.collectionId?.slug || '';
|
|
1328
|
+
const fullPath = collectionSlug ? `/${collectionSlug}/${p.slug}` : `/${p.slug}`;
|
|
1329
|
+
return `
|
|
1330
|
+
<tr class="border-b hover:bg-gray-50">
|
|
1331
|
+
<td class="px-4 py-3 font-medium">${escapeHtml(p.title)}</td>
|
|
1332
|
+
<td class="px-4 py-3 text-gray-600">
|
|
1333
|
+
<a href="${fullPath}" target="_blank" class="text-blue-600 hover:underline">${fullPath}</a>
|
|
1334
|
+
</td>
|
|
1335
|
+
<td class="px-4 py-3 text-gray-600">${p.collectionId?.name || '-'}</td>
|
|
1336
|
+
<td class="px-4 py-3">
|
|
1337
|
+
<span class="px-2 py-1 text-xs rounded ${getStatusClass(p.status)}">${p.status}</span>
|
|
1338
|
+
</td>
|
|
1339
|
+
<td class="px-4 py-3 text-gray-600">${formatDate(p.updatedAt)}</td>
|
|
1340
|
+
<td class="px-4 py-3 text-right">
|
|
1341
|
+
<button onclick="previewPage('${p._id}')" class="text-gray-600 hover:text-gray-900 mr-2" title="Preview"><i class="ti ti-eye"></i></button>
|
|
1342
|
+
${p.status !== 'published' ? `<button onclick="publishPage('${p._id}')" class="text-green-600 hover:text-green-800 mr-2" title="Publish"><i class="ti ti-send"></i></button>` : ''}
|
|
1343
|
+
${p.status === 'published' ? `<button onclick="unpublishPage('${p._id}')" class="text-orange-600 hover:text-orange-800 mr-2" title="Unpublish"><i class="ti ti-send-off"></i></button>` : ''}
|
|
1344
|
+
<button onclick="editPage('${p._id}')" class="text-blue-600 hover:text-blue-800 mr-2"><i class="ti ti-edit"></i></button>
|
|
1345
|
+
<button onclick="deletePage('${p._id}', '${escapeHtml(p.title)}')" class="text-red-600 hover:text-red-800"><i class="ti ti-trash"></i></button>
|
|
1346
|
+
</td>
|
|
1347
|
+
</tr>
|
|
1348
|
+
`;
|
|
1349
|
+
}).join('');
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function getStatusClass(status) {
|
|
1353
|
+
switch (status) {
|
|
1354
|
+
case 'published': return 'bg-green-100 text-green-800';
|
|
1355
|
+
case 'draft': return 'bg-yellow-100 text-yellow-800';
|
|
1356
|
+
case 'archived': return 'bg-gray-100 text-gray-800';
|
|
1357
|
+
default: return 'bg-gray-100 text-gray-800';
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function updateCollectionSelect() {
|
|
1362
|
+
const selects = [document.getElementById('pages-collection'), document.getElementById('page-collection')];
|
|
1363
|
+
selects.forEach(select => {
|
|
1364
|
+
if (!select) return;
|
|
1365
|
+
const current = select.value;
|
|
1366
|
+
const isFilter = select.id === 'pages-collection';
|
|
1367
|
+
select.innerHTML = isFilter ? '<option value="">All collections</option>' : '<option value="">No collection (root level)</option>';
|
|
1368
|
+
state.collections.items.filter(c => c.status === 'active').forEach(c => {
|
|
1369
|
+
select.innerHTML += `<option value="${c._id}">${escapeHtml(c.name)} (/${c.slug})</option>`;
|
|
1370
|
+
});
|
|
1371
|
+
select.value = current;
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function pagesPrev() {
|
|
1376
|
+
state.pages.offset = Math.max(0, state.pages.offset - state.pages.limit);
|
|
1377
|
+
loadPages();
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function pagesNext() {
|
|
1381
|
+
state.pages.offset += state.pages.limit;
|
|
1382
|
+
loadPages();
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function openCreatePageModal() {
|
|
1386
|
+
document.getElementById('modal-page-title').textContent = 'Create Page';
|
|
1387
|
+
document.getElementById('page-form').reset();
|
|
1388
|
+
document.getElementById('page-id').value = '';
|
|
1389
|
+
state.currentPage = null;
|
|
1390
|
+
state.currentBlocks = [];
|
|
1391
|
+
renderBlocksEditor();
|
|
1392
|
+
loadActiveContextBlockDefinitionsForPageModal();
|
|
1393
|
+
document.getElementById('modal-page').classList.remove('hidden');
|
|
1394
|
+
document.getElementById('modal-page').classList.add('flex');
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function closePageModal() {
|
|
1398
|
+
document.getElementById('modal-page').classList.add('hidden');
|
|
1399
|
+
document.getElementById('modal-page').classList.remove('flex');
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
async function editPage(id) {
|
|
1403
|
+
try {
|
|
1404
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/pages/${id}`, { credentials: 'same-origin' });
|
|
1405
|
+
const data = await res.json();
|
|
1406
|
+
if (!res.ok) throw new Error(data.error);
|
|
1407
|
+
|
|
1408
|
+
const p = data.page;
|
|
1409
|
+
state.currentPage = p;
|
|
1410
|
+
state.currentBlocks = normalizeBlocks(p.blocks || []);
|
|
1411
|
+
document.getElementById('modal-page-title').textContent = 'Edit Page';
|
|
1412
|
+
document.getElementById('page-id').value = p._id;
|
|
1413
|
+
document.getElementById('page-title').value = p.title;
|
|
1414
|
+
document.getElementById('page-slug').value = p.slug;
|
|
1415
|
+
document.getElementById('page-collection').value = p.collectionId?._id || p.collectionId || '';
|
|
1416
|
+
document.getElementById('page-status').value = p.status;
|
|
1417
|
+
document.getElementById('page-template').value = p.templateKey || 'default';
|
|
1418
|
+
document.getElementById('page-layout').value = p.layoutKey || 'default';
|
|
1419
|
+
document.getElementById('page-seo-title').value = p.seoMeta?.title || '';
|
|
1420
|
+
document.getElementById('page-seo-description').value = p.seoMeta?.description || '';
|
|
1421
|
+
document.getElementById('page-css').value = p.customCss || '';
|
|
1422
|
+
document.getElementById('page-js').value = p.customJs || '';
|
|
1423
|
+
document.getElementById('page-repeat').value = p.repeat ? JSON.stringify(p.repeat, null, 2) : '';
|
|
1424
|
+
|
|
1425
|
+
renderBlocksEditor();
|
|
1426
|
+
|
|
1427
|
+
loadActiveContextBlockDefinitionsForPageModal();
|
|
1428
|
+
|
|
1429
|
+
document.getElementById('modal-page').classList.remove('hidden');
|
|
1430
|
+
document.getElementById('modal-page').classList.add('flex');
|
|
1431
|
+
} catch (e) {
|
|
1432
|
+
showToast(e.message, 'error');
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
async function deletePage(id, title) {
|
|
1437
|
+
if (!confirm(`Delete page "${title}"? This cannot be undone.`)) return;
|
|
1438
|
+
try {
|
|
1439
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/pages/${id}`, { method: 'DELETE', credentials: 'same-origin' });
|
|
1440
|
+
const data = await res.json();
|
|
1441
|
+
if (!res.ok) throw new Error(data.error);
|
|
1442
|
+
showToast('Page deleted');
|
|
1443
|
+
loadPages();
|
|
1444
|
+
} catch (e) {
|
|
1445
|
+
showToast(e.message, 'error');
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
async function publishPage(id) {
|
|
1450
|
+
try {
|
|
1451
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/pages/${id}/publish`, { method: 'POST', credentials: 'same-origin' });
|
|
1452
|
+
const data = await res.json();
|
|
1453
|
+
if (!res.ok) throw new Error(data.error);
|
|
1454
|
+
showToast('Page published');
|
|
1455
|
+
loadPages();
|
|
1456
|
+
} catch (e) {
|
|
1457
|
+
showToast(e.message, 'error');
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
async function unpublishPage(id) {
|
|
1462
|
+
try {
|
|
1463
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/pages/${id}/unpublish`, { method: 'POST', credentials: 'same-origin' });
|
|
1464
|
+
const data = await res.json();
|
|
1465
|
+
if (!res.ok) throw new Error(data.error);
|
|
1466
|
+
showToast('Page unpublished');
|
|
1467
|
+
loadPages();
|
|
1468
|
+
} catch (e) {
|
|
1469
|
+
showToast(e.message, 'error');
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function openCreateCollectionModal() {
|
|
1474
|
+
document.getElementById('modal-collection-title').textContent = 'Create Collection';
|
|
1475
|
+
document.getElementById('collection-form').reset();
|
|
1476
|
+
document.getElementById('collection-id').value = '';
|
|
1477
|
+
document.getElementById('modal-collection').classList.remove('hidden');
|
|
1478
|
+
document.getElementById('modal-collection').classList.add('flex');
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
function closeCollectionModal() {
|
|
1482
|
+
document.getElementById('modal-collection').classList.add('hidden');
|
|
1483
|
+
document.getElementById('modal-collection').classList.remove('flex');
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
async function editCollection(id) {
|
|
1487
|
+
try {
|
|
1488
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/collections/${id}`, { credentials: 'same-origin' });
|
|
1489
|
+
const data = await res.json();
|
|
1490
|
+
if (!res.ok) throw new Error(data.error);
|
|
1491
|
+
|
|
1492
|
+
const c = data.collection;
|
|
1493
|
+
document.getElementById('modal-collection-title').textContent = 'Edit Collection';
|
|
1494
|
+
document.getElementById('collection-id').value = c._id;
|
|
1495
|
+
document.getElementById('collection-name').value = c.name;
|
|
1496
|
+
document.getElementById('collection-slug').value = c.slug;
|
|
1497
|
+
document.getElementById('collection-description').value = c.description || '';
|
|
1498
|
+
document.getElementById('collection-status').value = c.status;
|
|
1499
|
+
|
|
1500
|
+
document.getElementById('modal-collection').classList.remove('hidden');
|
|
1501
|
+
document.getElementById('modal-collection').classList.add('flex');
|
|
1502
|
+
} catch (e) {
|
|
1503
|
+
showToast(e.message, 'error');
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
async function deleteCollection(id, name) {
|
|
1508
|
+
if (!confirm(`Delete collection "${name}"? This cannot be undone.`)) return;
|
|
1509
|
+
try {
|
|
1510
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/collections/${id}`, { method: 'DELETE', credentials: 'same-origin' });
|
|
1511
|
+
const data = await res.json();
|
|
1512
|
+
if (!res.ok) throw new Error(data.error);
|
|
1513
|
+
showToast('Collection deleted');
|
|
1514
|
+
loadCollections();
|
|
1515
|
+
} catch (e) {
|
|
1516
|
+
showToast(e.message, 'error');
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
document.getElementById('page-form')?.addEventListener('submit', async (e) => {
|
|
1521
|
+
e.preventDefault();
|
|
1522
|
+
try {
|
|
1523
|
+
const id = document.getElementById('page-id').value;
|
|
1524
|
+
let repeat = null;
|
|
1525
|
+
const repeatRaw = document.getElementById('page-repeat')?.value || '';
|
|
1526
|
+
if (repeatRaw.trim()) {
|
|
1527
|
+
repeat = JSON.parse(repeatRaw);
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
const payload = {
|
|
1531
|
+
slug: document.getElementById('page-slug').value,
|
|
1532
|
+
collectionId: document.getElementById('page-collection').value || null,
|
|
1533
|
+
title: document.getElementById('page-title').value,
|
|
1534
|
+
templateKey: document.getElementById('page-template').value,
|
|
1535
|
+
layoutKey: document.getElementById('page-layout').value,
|
|
1536
|
+
blocks: state.currentBlocks || [],
|
|
1537
|
+
repeat,
|
|
1538
|
+
customCss: document.getElementById('page-css').value,
|
|
1539
|
+
customJs: document.getElementById('page-js').value,
|
|
1540
|
+
seoMeta: {
|
|
1541
|
+
title: document.getElementById('page-seo-title').value,
|
|
1542
|
+
description: document.getElementById('page-seo-description').value,
|
|
1543
|
+
keywords: document.getElementById('page-seo-keywords').value,
|
|
1544
|
+
},
|
|
1545
|
+
};
|
|
1546
|
+
|
|
1547
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/pages${id ? `/${id}` : ''}`, {
|
|
1548
|
+
method: id ? 'PUT' : 'POST',
|
|
1549
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1550
|
+
credentials: 'same-origin',
|
|
1551
|
+
body: JSON.stringify(payload),
|
|
1552
|
+
});
|
|
1553
|
+
const data = await res.json();
|
|
1554
|
+
if (!res.ok) throw new Error(data.error);
|
|
1555
|
+
showToast(id ? 'Page updated' : 'Page created');
|
|
1556
|
+
closePageModal();
|
|
1557
|
+
loadPages();
|
|
1558
|
+
} catch (e) {
|
|
1559
|
+
showToast(e.message, 'error');
|
|
1560
|
+
}
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1563
|
+
async function testContextPhaseForCurrentPage() {
|
|
1564
|
+
const id = document.getElementById('page-id')?.value;
|
|
1565
|
+
if (!id) {
|
|
1566
|
+
showToast('Save the page first to test context phase', 'error');
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
try {
|
|
1571
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/pages/${id}/test-context`, {
|
|
1572
|
+
method: 'POST',
|
|
1573
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1574
|
+
credentials: 'same-origin',
|
|
1575
|
+
body: JSON.stringify({ routePath: '/_test' }),
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
const data = await res.json();
|
|
1579
|
+
if (!res.ok) throw new Error(data.error);
|
|
1580
|
+
|
|
1581
|
+
const varsStr = JSON.stringify(data.vars || {}, null, 2);
|
|
1582
|
+
showToast(`Context OK (${data.elapsedMs}ms). Vars: ${varsStr.substring(0, 200)}${varsStr.length > 200 ? '...' : ''}`);
|
|
1583
|
+
} catch (e) {
|
|
1584
|
+
showToast(e.message, 'error');
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
document.getElementById('collection-form').addEventListener('submit', async (e) => {
|
|
1589
|
+
e.preventDefault();
|
|
1590
|
+
const id = document.getElementById('collection-id').value;
|
|
1591
|
+
const body = {
|
|
1592
|
+
name: document.getElementById('collection-name').value,
|
|
1593
|
+
slug: document.getElementById('collection-slug').value,
|
|
1594
|
+
description: document.getElementById('collection-description').value,
|
|
1595
|
+
status: document.getElementById('collection-status').value,
|
|
1596
|
+
};
|
|
1597
|
+
|
|
1598
|
+
try {
|
|
1599
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/collections${id ? `/${id}` : ''}`, {
|
|
1600
|
+
method: id ? 'PUT' : 'POST',
|
|
1601
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1602
|
+
credentials: 'same-origin',
|
|
1603
|
+
body: JSON.stringify(body),
|
|
1604
|
+
});
|
|
1605
|
+
const data = await res.json();
|
|
1606
|
+
if (!res.ok) throw new Error(data.error);
|
|
1607
|
+
showToast(id ? 'Collection updated' : 'Collection created');
|
|
1608
|
+
closeCollectionModal();
|
|
1609
|
+
loadCollections();
|
|
1610
|
+
} catch (e) {
|
|
1611
|
+
showToast(e.message, 'error');
|
|
1612
|
+
}
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
function escapeHtml(str) {
|
|
1616
|
+
const div = document.createElement('div');
|
|
1617
|
+
div.textContent = str || '';
|
|
1618
|
+
return div.innerHTML;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
async function loadBlocksSchema() {
|
|
1622
|
+
try {
|
|
1623
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/blocks-schema`, { credentials: 'same-origin' });
|
|
1624
|
+
const data = await res.json();
|
|
1625
|
+
if (!res.ok) throw new Error(data.error);
|
|
1626
|
+
state.blocksSchema = data.schema || null;
|
|
1627
|
+
state.blocksSchemaAlias = data.alias || null;
|
|
1628
|
+
populateBlockTypeSelect();
|
|
1629
|
+
} catch (e) {
|
|
1630
|
+
state.blocksSchema = null;
|
|
1631
|
+
state.blocksSchemaAlias = null;
|
|
1632
|
+
populateBlockTypeSelect();
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
async function loadTemplates() {
|
|
1637
|
+
try {
|
|
1638
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/templates`, { credentials: 'same-origin' });
|
|
1639
|
+
const data = await res.json();
|
|
1640
|
+
if (!res.ok) throw new Error(data.error);
|
|
1641
|
+
state.templates = Array.isArray(data.templates) ? data.templates : [];
|
|
1642
|
+
renderTemplates();
|
|
1643
|
+
updateTemplateSelect();
|
|
1644
|
+
} catch (e) {
|
|
1645
|
+
state.templates = [];
|
|
1646
|
+
renderTemplates();
|
|
1647
|
+
showToast(e.message, 'error');
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
async function loadLayouts() {
|
|
1652
|
+
try {
|
|
1653
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/layouts`, { credentials: 'same-origin' });
|
|
1654
|
+
const data = await res.json();
|
|
1655
|
+
if (!res.ok) throw new Error(data.error);
|
|
1656
|
+
state.layouts = Array.isArray(data.layouts) ? data.layouts : [];
|
|
1657
|
+
renderLayouts();
|
|
1658
|
+
updateLayoutSelect();
|
|
1659
|
+
} catch (e) {
|
|
1660
|
+
state.layouts = [];
|
|
1661
|
+
renderLayouts();
|
|
1662
|
+
showToast(e.message, 'error');
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
function renderTemplates() {
|
|
1667
|
+
const tbody = document.getElementById('templates-tbody');
|
|
1668
|
+
const empty = document.getElementById('templates-empty');
|
|
1669
|
+
const items = state.templates || [];
|
|
1670
|
+
|
|
1671
|
+
if (!items.length) {
|
|
1672
|
+
tbody.innerHTML = '';
|
|
1673
|
+
empty.classList.remove('hidden');
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
empty.classList.add('hidden');
|
|
1678
|
+
tbody.innerHTML = items.map((t) => {
|
|
1679
|
+
const key = String(t.key || '');
|
|
1680
|
+
const name = String(t.name || key);
|
|
1681
|
+
const desc = String(t.description || '');
|
|
1682
|
+
return `
|
|
1683
|
+
<tr class="border-b hover:bg-gray-50">
|
|
1684
|
+
<td class="px-4 py-3 font-mono">${escapeHtml(key)}</td>
|
|
1685
|
+
<td class="px-4 py-3">${escapeHtml(name)}</td>
|
|
1686
|
+
<td class="px-4 py-3 text-gray-600">${escapeHtml(desc)}</td>
|
|
1687
|
+
<td class="px-4 py-3 text-right">
|
|
1688
|
+
<button onclick="openEjsEditorByKey('template','${escapeHtml(key)}')" class="text-blue-600 hover:text-blue-800"><i class="ti ti-edit"></i></button>
|
|
1689
|
+
</td>
|
|
1690
|
+
</tr>
|
|
1691
|
+
`;
|
|
1692
|
+
}).join('');
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
function renderLayouts() {
|
|
1696
|
+
const tbody = document.getElementById('layouts-tbody');
|
|
1697
|
+
const empty = document.getElementById('layouts-empty');
|
|
1698
|
+
const items = state.layouts || [];
|
|
1699
|
+
|
|
1700
|
+
if (!items.length) {
|
|
1701
|
+
tbody.innerHTML = '';
|
|
1702
|
+
empty.classList.remove('hidden');
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
empty.classList.add('hidden');
|
|
1707
|
+
tbody.innerHTML = items.map((l) => {
|
|
1708
|
+
const key = String(l.key || '');
|
|
1709
|
+
const name = String(l.name || key);
|
|
1710
|
+
const desc = String(l.description || '');
|
|
1711
|
+
return `
|
|
1712
|
+
<tr class="border-b hover:bg-gray-50">
|
|
1713
|
+
<td class="px-4 py-3 font-mono">${escapeHtml(key)}</td>
|
|
1714
|
+
<td class="px-4 py-3">${escapeHtml(name)}</td>
|
|
1715
|
+
<td class="px-4 py-3 text-gray-600">${escapeHtml(desc)}</td>
|
|
1716
|
+
<td class="px-4 py-3 text-right">
|
|
1717
|
+
<button onclick="openEjsEditorByKey('layout','${escapeHtml(key)}')" class="text-blue-600 hover:text-blue-800"><i class="ti ti-edit"></i></button>
|
|
1718
|
+
</td>
|
|
1719
|
+
</tr>
|
|
1720
|
+
`;
|
|
1721
|
+
}).join('');
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
function updateTemplateSelect() {
|
|
1725
|
+
const select = document.getElementById('page-template');
|
|
1726
|
+
if (!select) return;
|
|
1727
|
+
const current = select.value;
|
|
1728
|
+
select.innerHTML = '';
|
|
1729
|
+
(state.templates || []).forEach((t) => {
|
|
1730
|
+
const key = String(t.key || '').trim();
|
|
1731
|
+
if (!key) return;
|
|
1732
|
+
const name = String(t.name || key);
|
|
1733
|
+
select.innerHTML += `<option value="${escapeHtml(key)}">${escapeHtml(name)} (${escapeHtml(key)})</option>`;
|
|
1734
|
+
});
|
|
1735
|
+
select.value = current || 'default';
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
function updateLayoutSelect() {
|
|
1739
|
+
const select = document.getElementById('page-layout');
|
|
1740
|
+
if (!select) return;
|
|
1741
|
+
const current = select.value;
|
|
1742
|
+
select.innerHTML = '';
|
|
1743
|
+
(state.layouts || []).forEach((l) => {
|
|
1744
|
+
const key = String(l.key || '').trim();
|
|
1745
|
+
if (!key) return;
|
|
1746
|
+
const name = String(l.name || key);
|
|
1747
|
+
select.innerHTML += `<option value="${escapeHtml(key)}">${escapeHtml(name)} (${escapeHtml(key)})</option>`;
|
|
1748
|
+
});
|
|
1749
|
+
select.value = current || 'default';
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
function openCreateEjsFileModal(kind) {
|
|
1753
|
+
document.getElementById('create-ejs-kind').value = kind;
|
|
1754
|
+
document.getElementById('create-ejs-key').value = '';
|
|
1755
|
+
document.getElementById('modal-create-ejs-title').textContent = kind === 'layout' ? 'Create Layout' : 'Create Template';
|
|
1756
|
+
document.getElementById('modal-create-ejs').classList.remove('hidden');
|
|
1757
|
+
document.getElementById('modal-create-ejs').classList.add('flex');
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
function closeCreateEjsModal() {
|
|
1761
|
+
document.getElementById('modal-create-ejs').classList.add('hidden');
|
|
1762
|
+
document.getElementById('modal-create-ejs').classList.remove('flex');
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
document.getElementById('create-ejs-form').addEventListener('submit', async (e) => {
|
|
1766
|
+
e.preventDefault();
|
|
1767
|
+
const kind = document.getElementById('create-ejs-kind').value;
|
|
1768
|
+
const key = String(document.getElementById('create-ejs-key').value || '').trim();
|
|
1769
|
+
if (!key) return;
|
|
1770
|
+
|
|
1771
|
+
const relPath = kind === 'layout' ? `pages/layouts/${key}.ejs` : `pages/templates/${key}.ejs`;
|
|
1772
|
+
const defaultContent = kind === 'layout'
|
|
1773
|
+
? `<%%- include('../partials/header.ejs', { page, req }) %>\n<main class="container mx-auto px-4 py-8">\n <%%- include(templatePath, { page, blocks, seoMeta, customCss, customJs, layoutPath, templatePath, req }) %>\n</main>\n<%%- include('../partials/footer.ejs', { page, req }) %>\n`
|
|
1774
|
+
: `<div class="page-template">\n <h1 class="text-2xl font-bold mb-4"><%%= page.title %></h1>\n <%% for (const block of (blocks || [])) { %>\n <%%- include('../blocks/' + block.type + '.ejs', { block, page, req }) %>\n <%% } %>\n</div>\n`;
|
|
1775
|
+
|
|
1776
|
+
try {
|
|
1777
|
+
const res = await fetch(`${API_BASE}/api/admin/ejs-virtual/file?path=${encodeURIComponent(relPath)}`, {
|
|
1778
|
+
method: 'PUT',
|
|
1779
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1780
|
+
credentials: 'same-origin',
|
|
1781
|
+
body: JSON.stringify({ content: defaultContent, enabled: true, description: `create ${kind} ${key}` }),
|
|
1782
|
+
});
|
|
1783
|
+
const data = await res.json();
|
|
1784
|
+
if (!res.ok) throw new Error(data.error);
|
|
1785
|
+
closeCreateEjsModal();
|
|
1786
|
+
showToast(`${kind === 'layout' ? 'Layout' : 'Template'} created`);
|
|
1787
|
+
if (kind === 'layout') await loadLayouts();
|
|
1788
|
+
else await loadTemplates();
|
|
1789
|
+
openEjsEditor(relPath);
|
|
1790
|
+
} catch (err) {
|
|
1791
|
+
showToast(err.message, 'error');
|
|
1792
|
+
}
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
function openEjsEditorByKey(kind, key) {
|
|
1796
|
+
const relPath = kind === 'layout' ? `pages/layouts/${key}.ejs` : `pages/templates/${key}.ejs`;
|
|
1797
|
+
openEjsEditor(relPath);
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
async function openEjsEditor(relPath) {
|
|
1801
|
+
try {
|
|
1802
|
+
const res = await fetch(`${API_BASE}/api/admin/ejs-virtual/file?path=${encodeURIComponent(relPath)}`, { credentials: 'same-origin' });
|
|
1803
|
+
const data = await res.json();
|
|
1804
|
+
if (!res.ok) throw new Error(data.error);
|
|
1805
|
+
|
|
1806
|
+
state.currentEjsPath = data.path;
|
|
1807
|
+
state.currentEjsDbEnabled = Boolean(data.db?.enabled);
|
|
1808
|
+
document.getElementById('modal-ejs-title').textContent = 'Edit EJS';
|
|
1809
|
+
document.getElementById('modal-ejs-path').textContent = data.path;
|
|
1810
|
+
document.getElementById('ejs-enabled').checked = Boolean(data.db?.enabled);
|
|
1811
|
+
document.getElementById('ejs-content').value = String((data.db && typeof data.db.content === 'string') ? data.db.content : data.effective?.content || '');
|
|
1812
|
+
document.getElementById('ejs-ai-prompt').value = '';
|
|
1813
|
+
document.getElementById('ejs-history').classList.add('hidden');
|
|
1814
|
+
document.getElementById('ejs-history-items').innerHTML = '';
|
|
1815
|
+
|
|
1816
|
+
document.getElementById('modal-ejs').classList.remove('hidden');
|
|
1817
|
+
document.getElementById('modal-ejs').classList.add('flex');
|
|
1818
|
+
} catch (err) {
|
|
1819
|
+
showToast(err.message, 'error');
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
function closeEjsModal() {
|
|
1824
|
+
document.getElementById('modal-ejs').classList.add('hidden');
|
|
1825
|
+
document.getElementById('modal-ejs').classList.remove('flex');
|
|
1826
|
+
state.currentEjsPath = null;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
async function saveEjsOverride() {
|
|
1830
|
+
const relPath = state.currentEjsPath;
|
|
1831
|
+
if (!relPath) return;
|
|
1832
|
+
try {
|
|
1833
|
+
const res = await fetch(`${API_BASE}/api/admin/ejs-virtual/file?path=${encodeURIComponent(relPath)}`, {
|
|
1834
|
+
method: 'PUT',
|
|
1835
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1836
|
+
credentials: 'same-origin',
|
|
1837
|
+
body: JSON.stringify({
|
|
1838
|
+
content: document.getElementById('ejs-content').value,
|
|
1839
|
+
enabled: document.getElementById('ejs-enabled').checked,
|
|
1840
|
+
description: 'save from page builder',
|
|
1841
|
+
}),
|
|
1842
|
+
});
|
|
1843
|
+
const data = await res.json();
|
|
1844
|
+
if (!res.ok) throw new Error(data.error);
|
|
1845
|
+
showToast('Saved');
|
|
1846
|
+
await loadTemplates();
|
|
1847
|
+
await loadLayouts();
|
|
1848
|
+
} catch (err) {
|
|
1849
|
+
showToast(err.message, 'error');
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
async function revertEjsOverride() {
|
|
1854
|
+
const relPath = state.currentEjsPath;
|
|
1855
|
+
if (!relPath) return;
|
|
1856
|
+
if (!confirm('Revert to default filesystem view?')) return;
|
|
1857
|
+
try {
|
|
1858
|
+
const res = await fetch(`${API_BASE}/api/admin/ejs-virtual/file/revert?path=${encodeURIComponent(relPath)}`, {
|
|
1859
|
+
method: 'POST',
|
|
1860
|
+
credentials: 'same-origin',
|
|
1861
|
+
});
|
|
1862
|
+
const data = await res.json();
|
|
1863
|
+
if (!res.ok) throw new Error(data.error);
|
|
1864
|
+
showToast('Reverted');
|
|
1865
|
+
await openEjsEditor(relPath);
|
|
1866
|
+
await loadTemplates();
|
|
1867
|
+
await loadLayouts();
|
|
1868
|
+
} catch (err) {
|
|
1869
|
+
showToast(err.message, 'error');
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
async function loadEjsHistory() {
|
|
1874
|
+
const relPath = state.currentEjsPath;
|
|
1875
|
+
if (!relPath) return;
|
|
1876
|
+
try {
|
|
1877
|
+
const res = await fetch(`${API_BASE}/api/admin/ejs-virtual/history?path=${encodeURIComponent(relPath)}`, { credentials: 'same-origin' });
|
|
1878
|
+
const data = await res.json();
|
|
1879
|
+
if (!res.ok) throw new Error(data.error);
|
|
1880
|
+
const versions = Array.isArray(data.versions) ? data.versions : [];
|
|
1881
|
+
document.getElementById('ejs-history').classList.remove('hidden');
|
|
1882
|
+
document.getElementById('ejs-history-items').innerHTML = versions.map((v) => {
|
|
1883
|
+
return `
|
|
1884
|
+
<div class="flex items-center justify-between gap-2">
|
|
1885
|
+
<div class="text-xs text-gray-700 font-mono">${escapeHtml(String(v._id || ''))}</div>
|
|
1886
|
+
<button type="button" class="text-sm text-blue-600 hover:underline" onclick="rollbackEjsVersion('${escapeHtml(String(v._id || ''))}')">Rollback</button>
|
|
1887
|
+
</div>
|
|
1888
|
+
`;
|
|
1889
|
+
}).join('') || '<div class="text-sm text-gray-600">No versions</div>';
|
|
1890
|
+
} catch (err) {
|
|
1891
|
+
showToast(err.message, 'error');
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
async function rollbackEjsVersion(versionId) {
|
|
1896
|
+
const relPath = state.currentEjsPath;
|
|
1897
|
+
if (!relPath) return;
|
|
1898
|
+
if (!versionId) return;
|
|
1899
|
+
if (!confirm('Rollback to this version?')) return;
|
|
1900
|
+
try {
|
|
1901
|
+
const res = await fetch(`${API_BASE}/api/admin/ejs-virtual/rollback`, {
|
|
1902
|
+
method: 'POST',
|
|
1903
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1904
|
+
credentials: 'same-origin',
|
|
1905
|
+
body: JSON.stringify({ versionId }),
|
|
1906
|
+
});
|
|
1907
|
+
const data = await res.json();
|
|
1908
|
+
if (!res.ok) throw new Error(data.error);
|
|
1909
|
+
showToast('Rolled back');
|
|
1910
|
+
await openEjsEditor(relPath);
|
|
1911
|
+
await loadTemplates();
|
|
1912
|
+
await loadLayouts();
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
showToast(err.message, 'error');
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
async function runEjsVibe() {
|
|
1919
|
+
const relPath = state.currentEjsPath;
|
|
1920
|
+
if (!relPath) return;
|
|
1921
|
+
const prompt = String(document.getElementById('ejs-ai-prompt').value || '').trim();
|
|
1922
|
+
if (!prompt) return;
|
|
1923
|
+
try {
|
|
1924
|
+
const res = await fetch(`${API_BASE}/api/admin/ejs-virtual/vibe`, {
|
|
1925
|
+
method: 'POST',
|
|
1926
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1927
|
+
credentials: 'same-origin',
|
|
1928
|
+
body: JSON.stringify({ prompt, paths: [relPath] }),
|
|
1929
|
+
});
|
|
1930
|
+
const data = await res.json();
|
|
1931
|
+
if (!res.ok) throw new Error(data.error);
|
|
1932
|
+
showToast('AI applied');
|
|
1933
|
+
await openEjsEditor(relPath);
|
|
1934
|
+
await loadTemplates();
|
|
1935
|
+
await loadLayouts();
|
|
1936
|
+
} catch (err) {
|
|
1937
|
+
showToast(err.message, 'error');
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
async function loadBlockDefinitions() {
|
|
1942
|
+
try {
|
|
1943
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/block-definitions`, { credentials: 'same-origin' });
|
|
1944
|
+
const data = await res.json();
|
|
1945
|
+
if (!res.ok) throw new Error(data.error);
|
|
1946
|
+
state.blockDefinitions = Array.isArray(data.items) ? data.items : [];
|
|
1947
|
+
renderBlockDefinitions();
|
|
1948
|
+
await loadBlocksSchema();
|
|
1949
|
+
} catch (e) {
|
|
1950
|
+
state.blockDefinitions = [];
|
|
1951
|
+
renderBlockDefinitions();
|
|
1952
|
+
showToast(e.message, 'error');
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
function renderBlockDefinitions() {
|
|
1957
|
+
const tbody = document.getElementById('blockdefs-tbody');
|
|
1958
|
+
const empty = document.getElementById('blockdefs-empty');
|
|
1959
|
+
const items = state.blockDefinitions || [];
|
|
1960
|
+
|
|
1961
|
+
if (!items.length) {
|
|
1962
|
+
tbody.innerHTML = '';
|
|
1963
|
+
empty.classList.remove('hidden');
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
empty.classList.add('hidden');
|
|
1968
|
+
tbody.innerHTML = items.map((b) => {
|
|
1969
|
+
const code = String(b.code || '');
|
|
1970
|
+
const label = String(b.label || code);
|
|
1971
|
+
const active = b.isActive === false ? 'inactive' : 'active';
|
|
1972
|
+
return `
|
|
1973
|
+
<tr class="border-b hover:bg-gray-50">
|
|
1974
|
+
<td class="px-4 py-3 font-mono">${escapeHtml(code)}</td>
|
|
1975
|
+
<td class="px-4 py-3">${escapeHtml(label)}</td>
|
|
1976
|
+
<td class="px-4 py-3">
|
|
1977
|
+
<span class="px-2 py-1 text-xs rounded ${active === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}">${escapeHtml(active)}</span>
|
|
1978
|
+
</td>
|
|
1979
|
+
<td class="px-4 py-3 text-gray-600">${formatDate(b.updatedAt)}</td>
|
|
1980
|
+
<td class="px-4 py-3 text-right">
|
|
1981
|
+
<button onclick="openBlockTemplate('${escapeHtml(code)}')" class="text-gray-700 hover:text-gray-900 mr-2" title="Edit block template"><i class="ti ti-template"></i></button>
|
|
1982
|
+
<button onclick="editBlockDefinition('${escapeHtml(code)}')" class="text-blue-600 hover:text-blue-800 mr-2"><i class="ti ti-edit"></i></button>
|
|
1983
|
+
<button onclick="aiEditBlockDefinition('${escapeHtml(code)}')" class="text-purple-600 hover:text-purple-800 mr-2" title="AI propose"><i class="ti ti-sparkles"></i></button>
|
|
1984
|
+
<button onclick="deleteBlockDefinition('${escapeHtml(code)}')" class="text-red-600 hover:text-red-800"><i class="ti ti-trash"></i></button>
|
|
1985
|
+
</td>
|
|
1986
|
+
</tr>
|
|
1987
|
+
`;
|
|
1988
|
+
}).join('');
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
function defaultBlockTemplateContent(blockCode) {
|
|
1992
|
+
const code = String(blockCode || '').trim();
|
|
1993
|
+
return `<section class="py-8">\n <div class="container mx-auto px-4">\n <h2 class="text-2xl font-bold mb-4">${escapeHtml(code || 'Block')}</h2>\n <pre class="text-sm bg-gray-50 border rounded p-4 overflow-x-auto"><%%= JSON.stringify(block.props || {}, null, 2) %></pre>\n </div>\n</section>\n`;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
async function openBlockTemplate(code) {
|
|
1997
|
+
const blockCode = String(code || '').trim().toLowerCase();
|
|
1998
|
+
if (!blockCode) return;
|
|
1999
|
+
const relPath = `pages/blocks/${blockCode}.ejs`;
|
|
2000
|
+
try {
|
|
2001
|
+
const res = await fetch(`${API_BASE}/api/admin/ejs-virtual/file?path=${encodeURIComponent(relPath)}`, { credentials: 'same-origin' });
|
|
2002
|
+
const data = await res.json();
|
|
2003
|
+
if (!res.ok) throw new Error(data.error);
|
|
2004
|
+
|
|
2005
|
+
const hasDb = Boolean(data.db && typeof data.db.content === 'string' && data.db.content.trim() !== '');
|
|
2006
|
+
const hasFs = Boolean(data.fs && typeof data.fs.content === 'string' && data.fs.content.trim() !== '');
|
|
2007
|
+
|
|
2008
|
+
if (!hasDb && !hasFs) {
|
|
2009
|
+
const createRes = await fetch(`${API_BASE}/api/admin/ejs-virtual/file?path=${encodeURIComponent(relPath)}`, {
|
|
2010
|
+
method: 'PUT',
|
|
2011
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2012
|
+
credentials: 'same-origin',
|
|
2013
|
+
body: JSON.stringify({ content: defaultBlockTemplateContent(blockCode), enabled: true, description: `create block template ${blockCode}` }),
|
|
2014
|
+
});
|
|
2015
|
+
const createData = await createRes.json();
|
|
2016
|
+
if (!createRes.ok) throw new Error(createData.error);
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
await openEjsEditor(relPath);
|
|
2020
|
+
} catch (err) {
|
|
2021
|
+
showToast(err.message, 'error');
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
function openCreateBlockDefinitionModal() {
|
|
2026
|
+
state.blockdefMode = 'create';
|
|
2027
|
+
document.getElementById('modal-blockdef-title').textContent = 'Create Block';
|
|
2028
|
+
document.getElementById('blockdef-code').disabled = false;
|
|
2029
|
+
document.getElementById('blockdef-code').value = '';
|
|
2030
|
+
document.getElementById('blockdef-label').value = '';
|
|
2031
|
+
document.getElementById('blockdef-description').value = '';
|
|
2032
|
+
document.getElementById('blockdef-fields').value = JSON.stringify({}, null, 2);
|
|
2033
|
+
document.getElementById('blockdef-ai-prompt').value = '';
|
|
2034
|
+
document.getElementById('modal-blockdef').classList.remove('hidden');
|
|
2035
|
+
document.getElementById('modal-blockdef').classList.add('flex');
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
async function openAiGenerateBlockModal() {
|
|
2039
|
+
openCreateBlockDefinitionModal();
|
|
2040
|
+
document.getElementById('blockdef-ai-prompt').focus();
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
function closeBlockDefinitionModal() {
|
|
2044
|
+
document.getElementById('modal-blockdef').classList.add('hidden');
|
|
2045
|
+
document.getElementById('modal-blockdef').classList.remove('flex');
|
|
2046
|
+
state.blockdefMode = null;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
async function editBlockDefinition(code) {
|
|
2050
|
+
try {
|
|
2051
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/block-definitions/${encodeURIComponent(code)}`, { credentials: 'same-origin' });
|
|
2052
|
+
const data = await res.json();
|
|
2053
|
+
if (!res.ok) throw new Error(data.error);
|
|
2054
|
+
const item = data.item;
|
|
2055
|
+
|
|
2056
|
+
state.blockdefMode = 'edit';
|
|
2057
|
+
document.getElementById('modal-blockdef-title').textContent = `Edit Block: ${item.code}`;
|
|
2058
|
+
document.getElementById('blockdef-code').value = item.code;
|
|
2059
|
+
document.getElementById('blockdef-code').disabled = true;
|
|
2060
|
+
document.getElementById('blockdef-label').value = item.label || '';
|
|
2061
|
+
document.getElementById('blockdef-description').value = item.description || '';
|
|
2062
|
+
document.getElementById('blockdef-fields').value = JSON.stringify(item.fields || {}, null, 2);
|
|
2063
|
+
document.getElementById('blockdef-ai-prompt').value = '';
|
|
2064
|
+
|
|
2065
|
+
document.getElementById('modal-blockdef').classList.remove('hidden');
|
|
2066
|
+
document.getElementById('modal-blockdef').classList.add('flex');
|
|
2067
|
+
} catch (err) {
|
|
2068
|
+
showToast(err.message, 'error');
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
async function aiEditBlockDefinition(code) {
|
|
2073
|
+
await editBlockDefinition(code);
|
|
2074
|
+
document.getElementById('blockdef-ai-prompt').focus();
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
async function runAiForBlockDefinition() {
|
|
2078
|
+
const prompt = String(document.getElementById('blockdef-ai-prompt').value || '').trim();
|
|
2079
|
+
if (!prompt) return;
|
|
2080
|
+
const code = String(document.getElementById('blockdef-code').value || '').trim();
|
|
2081
|
+
const isEdit = state.blockdefMode === 'edit';
|
|
2082
|
+
|
|
2083
|
+
try {
|
|
2084
|
+
const url = isEdit
|
|
2085
|
+
? `${API_BASE}/api/admin/pages/ai/block-definitions/${encodeURIComponent(code)}/propose`
|
|
2086
|
+
: `${API_BASE}/api/admin/pages/ai/block-definitions/generate`;
|
|
2087
|
+
const res = await fetch(url, {
|
|
2088
|
+
method: 'POST',
|
|
2089
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2090
|
+
credentials: 'same-origin',
|
|
2091
|
+
body: JSON.stringify({ prompt }),
|
|
2092
|
+
});
|
|
2093
|
+
const data = await res.json();
|
|
2094
|
+
if (!res.ok) throw new Error(data.error);
|
|
2095
|
+
|
|
2096
|
+
const proposal = data.proposal;
|
|
2097
|
+
document.getElementById('blockdef-code').value = proposal.code;
|
|
2098
|
+
document.getElementById('blockdef-label').value = proposal.label;
|
|
2099
|
+
document.getElementById('blockdef-description').value = proposal.description || '';
|
|
2100
|
+
document.getElementById('blockdef-fields').value = JSON.stringify(proposal.fields || {}, null, 2);
|
|
2101
|
+
showToast('AI proposal loaded');
|
|
2102
|
+
} catch (err) {
|
|
2103
|
+
showToast(err.message, 'error');
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
document.getElementById('blockdef-form').addEventListener('submit', async (e) => {
|
|
2108
|
+
e.preventDefault();
|
|
2109
|
+
const mode = state.blockdefMode;
|
|
2110
|
+
const code = String(document.getElementById('blockdef-code').value || '').trim().toLowerCase();
|
|
2111
|
+
const label = String(document.getElementById('blockdef-label').value || '').trim();
|
|
2112
|
+
const description = String(document.getElementById('blockdef-description').value || '');
|
|
2113
|
+
const fieldsRaw = String(document.getElementById('blockdef-fields').value || '').trim();
|
|
2114
|
+
|
|
2115
|
+
let fields = {};
|
|
2116
|
+
if (fieldsRaw) {
|
|
2117
|
+
try {
|
|
2118
|
+
fields = JSON.parse(fieldsRaw);
|
|
2119
|
+
} catch (err) {
|
|
2120
|
+
showToast('Fields must be valid JSON', 'error');
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
try {
|
|
2126
|
+
const url = mode === 'edit'
|
|
2127
|
+
? `${API_BASE}/api/admin/pages/block-definitions/${encodeURIComponent(code)}`
|
|
2128
|
+
: `${API_BASE}/api/admin/pages/block-definitions`;
|
|
2129
|
+
const method = mode === 'edit' ? 'PUT' : 'POST';
|
|
2130
|
+
const body = { code, label, description, fields, isActive: true };
|
|
2131
|
+
const res = await fetch(url, {
|
|
2132
|
+
method,
|
|
2133
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2134
|
+
credentials: 'same-origin',
|
|
2135
|
+
body: JSON.stringify(body),
|
|
2136
|
+
});
|
|
2137
|
+
const data = await res.json();
|
|
2138
|
+
if (!res.ok) throw new Error(data.error);
|
|
2139
|
+
showToast(mode === 'edit' ? 'Block updated' : 'Block created');
|
|
2140
|
+
closeBlockDefinitionModal();
|
|
2141
|
+
await loadBlockDefinitions();
|
|
2142
|
+
} catch (err) {
|
|
2143
|
+
showToast(err.message, 'error');
|
|
2144
|
+
}
|
|
2145
|
+
});
|
|
2146
|
+
|
|
2147
|
+
async function deleteBlockDefinition(code) {
|
|
2148
|
+
if (!confirm(`Delete block "${code}"?`)) return;
|
|
2149
|
+
try {
|
|
2150
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/block-definitions/${encodeURIComponent(code)}`, {
|
|
2151
|
+
method: 'DELETE',
|
|
2152
|
+
credentials: 'same-origin',
|
|
2153
|
+
});
|
|
2154
|
+
const data = await res.json();
|
|
2155
|
+
if (!res.ok) throw new Error(data.error);
|
|
2156
|
+
showToast('Block deleted');
|
|
2157
|
+
await loadBlockDefinitions();
|
|
2158
|
+
} catch (err) {
|
|
2159
|
+
showToast(err.message, 'error');
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
function populateBlockTypeSelect() {
|
|
2164
|
+
const select = document.getElementById('blocks-add-type');
|
|
2165
|
+
if (!select) return;
|
|
2166
|
+
const current = select.value;
|
|
2167
|
+
select.innerHTML = '<option value="">Select block type</option>';
|
|
2168
|
+
const defs = state.blocksSchema?.blocks || {};
|
|
2169
|
+
Object.keys(defs).forEach((type) => {
|
|
2170
|
+
const label = defs[type]?.label || type;
|
|
2171
|
+
select.innerHTML += `<option value="${type}">${escapeHtml(label)} (${type})</option>`;
|
|
2172
|
+
});
|
|
2173
|
+
select.value = current;
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
function addSelectedBlock() {
|
|
2177
|
+
const type = document.getElementById('blocks-add-type')?.value;
|
|
2178
|
+
if (!type) return;
|
|
2179
|
+
addBlock(type);
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
function addSelectedContextBlockDefinition() {
|
|
2183
|
+
const code = document.getElementById('blocks-add-contextdef')?.value;
|
|
2184
|
+
if (!code) return;
|
|
2185
|
+
const def = (state.pageContextBlockDefs.items || []).find((d) => String(d.code) === String(code));
|
|
2186
|
+
if (!def) {
|
|
2187
|
+
showToast('Context block not found', 'error');
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
2190
|
+
if (!state.currentBlocks) state.currentBlocks = [];
|
|
2191
|
+
state.currentBlocks.push({ id: uuid(), type: def.type, props: def.props || {} });
|
|
2192
|
+
renderBlocksEditor();
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
function uuid() {
|
|
2196
|
+
if (window.crypto && crypto.getRandomValues) {
|
|
2197
|
+
const buf = new Uint8Array(16);
|
|
2198
|
+
crypto.getRandomValues(buf);
|
|
2199
|
+
return Array.from(buf).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
2200
|
+
}
|
|
2201
|
+
return 'b' + Math.random().toString(16).slice(2) + Date.now().toString(16);
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
function normalizeBlocks(blocks) {
|
|
2205
|
+
if (!Array.isArray(blocks)) return [];
|
|
2206
|
+
return blocks
|
|
2207
|
+
.filter(b => b && typeof b === 'object')
|
|
2208
|
+
.map((b) => ({
|
|
2209
|
+
id: String(b.id || '').trim() || uuid(),
|
|
2210
|
+
type: String(b.type || '').trim(),
|
|
2211
|
+
props: (b.props && typeof b.props === 'object' && !Array.isArray(b.props)) ? b.props : {},
|
|
2212
|
+
}))
|
|
2213
|
+
.filter(b => b.type);
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
function addBlock(type) {
|
|
2217
|
+
if (!state.currentBlocks) state.currentBlocks = [];
|
|
2218
|
+
const def = state.blocksSchema?.blocks?.[type] || null;
|
|
2219
|
+
const fields = def?.fields || {};
|
|
2220
|
+
const props = {};
|
|
2221
|
+
Object.keys(fields).forEach((k) => {
|
|
2222
|
+
const ft = String(fields[k]?.type || '').toLowerCase();
|
|
2223
|
+
if (ft === 'boolean') props[k] = false;
|
|
2224
|
+
else props[k] = '';
|
|
2225
|
+
});
|
|
2226
|
+
state.currentBlocks.push({ id: uuid(), type, props });
|
|
2227
|
+
renderBlocksEditor();
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
function removeBlock(blockId) {
|
|
2231
|
+
state.currentBlocks = (state.currentBlocks || []).filter(b => b.id !== blockId);
|
|
2232
|
+
renderBlocksEditor();
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
function moveBlock(fromIdx, toIdx) {
|
|
2236
|
+
const blocks = state.currentBlocks || [];
|
|
2237
|
+
if (fromIdx < 0 || toIdx < 0 || fromIdx >= blocks.length || toIdx >= blocks.length) return;
|
|
2238
|
+
const [item] = blocks.splice(fromIdx, 1);
|
|
2239
|
+
blocks.splice(toIdx, 0, item);
|
|
2240
|
+
state.currentBlocks = blocks;
|
|
2241
|
+
renderBlocksEditor();
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
function onBlockDragStart(e) {
|
|
2245
|
+
const idx = e.currentTarget?.dataset?.idx;
|
|
2246
|
+
if (idx === undefined) return;
|
|
2247
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
2248
|
+
e.dataTransfer.setData('text/plain', String(idx));
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
function onBlockDragOver(e) {
|
|
2252
|
+
e.preventDefault();
|
|
2253
|
+
e.dataTransfer.dropEffect = 'move';
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
function onBlockDrop(e) {
|
|
2257
|
+
e.preventDefault();
|
|
2258
|
+
const fromIdx = parseInt(e.dataTransfer.getData('text/plain'), 10);
|
|
2259
|
+
const toIdx = parseInt(e.currentTarget?.dataset?.idx, 10);
|
|
2260
|
+
if (Number.isNaN(fromIdx) || Number.isNaN(toIdx)) return;
|
|
2261
|
+
moveBlock(fromIdx, toIdx);
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
function updateBlockProp(blockId, key, rawValue) {
|
|
2265
|
+
const blocks = state.currentBlocks || [];
|
|
2266
|
+
const idx = blocks.findIndex(b => b.id === blockId);
|
|
2267
|
+
if (idx === -1) return;
|
|
2268
|
+
const b = blocks[idx];
|
|
2269
|
+
const def = state.blocksSchema?.blocks?.[b.type] || {};
|
|
2270
|
+
const field = def?.fields?.[key] || {};
|
|
2271
|
+
const ft = String(field.type || '').toLowerCase();
|
|
2272
|
+
|
|
2273
|
+
let val = rawValue;
|
|
2274
|
+
if (ft === 'boolean') {
|
|
2275
|
+
val = Boolean(rawValue);
|
|
2276
|
+
} else if (ft === 'number') {
|
|
2277
|
+
const n = Number(rawValue);
|
|
2278
|
+
val = Number.isNaN(n) ? rawValue : n;
|
|
2279
|
+
} else if (ft === 'json') {
|
|
2280
|
+
if (typeof rawValue === 'string' && rawValue.trim() !== '') {
|
|
2281
|
+
try {
|
|
2282
|
+
val = JSON.parse(rawValue);
|
|
2283
|
+
} catch {
|
|
2284
|
+
val = rawValue;
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
blocks[idx] = { ...b, props: { ...(b.props || {}), [key]: val } };
|
|
2290
|
+
state.currentBlocks = blocks;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
function getFullPathForPage(p) {
|
|
2294
|
+
const collectionSlug = p.collectionId?.slug || '';
|
|
2295
|
+
return collectionSlug ? `/${collectionSlug}/${p.slug}` : `/${p.slug}`;
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
async function previewPage(id) {
|
|
2299
|
+
try {
|
|
2300
|
+
const res = await fetch(`${API_BASE}/api/admin/pages/pages/${id}`, { credentials: 'same-origin' });
|
|
2301
|
+
const data = await res.json();
|
|
2302
|
+
if (!res.ok) throw new Error(data.error);
|
|
2303
|
+
const p = data.page;
|
|
2304
|
+
const fullPath = getFullPathForPage(p);
|
|
2305
|
+
const isPublished = p.status === 'published';
|
|
2306
|
+
const url = isPublished ? fullPath : `${fullPath}?draft=1`;
|
|
2307
|
+
window.open(url, '_blank');
|
|
2308
|
+
} catch (e) {
|
|
2309
|
+
showToast(e.message, 'error');
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
function renderBlocksEditor() {
|
|
2314
|
+
const container = document.getElementById('blocks-editor');
|
|
2315
|
+
if (!container) return;
|
|
2316
|
+
const blocks = state.currentBlocks || [];
|
|
2317
|
+
const defs = state.blocksSchema?.blocks || {};
|
|
2318
|
+
|
|
2319
|
+
if (!state.blocksSchema) {
|
|
2320
|
+
container.innerHTML = '<div class="text-sm text-gray-600">Blocks schema not loaded. Create a JSON Config with alias <span class="font-mono">page-builder-blocks-schema</span> or rely on the default schema.</div>';
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
if (blocks.length === 0) {
|
|
2324
|
+
container.innerHTML = '<div class="text-sm text-gray-500">No blocks yet. Add one above.</div>';
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
container.innerHTML = blocks.map((b, idx) => {
|
|
2329
|
+
const def = defs[b.type] || {};
|
|
2330
|
+
const label = def.label || b.type;
|
|
2331
|
+
const fields = def.fields || {};
|
|
2332
|
+
const isSelected = state.selectedBlockId && String(state.selectedBlockId) === String(b.id);
|
|
2333
|
+
const borderClass = isSelected ? 'border-blue-500 ring-2 ring-blue-200' : 'border-gray-200';
|
|
2334
|
+
const fieldsHtml = Object.keys(fields).map((key) => {
|
|
2335
|
+
const field = fields[key] || {};
|
|
2336
|
+
const ft = String(field.type || '').toLowerCase();
|
|
2337
|
+
const fLabel = field.label || key;
|
|
2338
|
+
const propVal = (b.props || {})[key];
|
|
2339
|
+
|
|
2340
|
+
if (ft === 'boolean') {
|
|
2341
|
+
const checked = propVal === true ? 'checked' : '';
|
|
2342
|
+
return `
|
|
2343
|
+
<label class="flex items-center gap-2 text-sm text-gray-700">
|
|
2344
|
+
<input type="checkbox" ${checked} onchange="updateBlockProp('${b.id}', '${escapeHtml(key)}', this.checked)" />
|
|
2345
|
+
<span>${escapeHtml(fLabel)}</span>
|
|
2346
|
+
</label>
|
|
2347
|
+
`;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
if (ft === 'select') {
|
|
2351
|
+
const options = Array.isArray(field.options) ? field.options : [];
|
|
2352
|
+
const current = (typeof propVal === 'string' ? propVal : '');
|
|
2353
|
+
return `
|
|
2354
|
+
<div>
|
|
2355
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">${escapeHtml(fLabel)}</label>
|
|
2356
|
+
<select class="w-full border rounded px-3 py-2 text-sm" onchange="updateBlockProp('${b.id}', '${escapeHtml(key)}', this.value)">
|
|
2357
|
+
${options.map(opt => `<option value="${escapeHtml(String(opt))}" ${String(opt) === current ? 'selected' : ''}>${escapeHtml(String(opt))}</option>`).join('')}
|
|
2358
|
+
</select>
|
|
2359
|
+
</div>
|
|
2360
|
+
`;
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
if (ft === 'json') {
|
|
2364
|
+
const display = typeof propVal === 'string' ? propVal : JSON.stringify(propVal ?? null, null, 2);
|
|
2365
|
+
return `
|
|
2366
|
+
<div>
|
|
2367
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">${escapeHtml(fLabel)}</label>
|
|
2368
|
+
${field.example !== undefined ? `<div class="text-xs text-gray-500 mb-1">Example:</div><pre class="bg-gray-50 border rounded p-2 text-xs overflow-auto">${escapeHtml(JSON.stringify(field.example, null, 2))}</pre>` : ''}
|
|
2369
|
+
<textarea class="w-full border rounded px-3 py-2 font-mono text-xs" rows="6" oninput="updateBlockProp('${b.id}', '${escapeHtml(key)}', this.value)">${escapeHtml(display || '')}</textarea>
|
|
2370
|
+
</div>
|
|
2371
|
+
`;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
const value = typeof propVal === 'string' ? propVal : (propVal ?? '');
|
|
2375
|
+
const isLong = ft === 'html';
|
|
2376
|
+
if (isLong) {
|
|
2377
|
+
return `
|
|
2378
|
+
<div>
|
|
2379
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">${escapeHtml(fLabel)}</label>
|
|
2380
|
+
<textarea class="w-full border rounded px-3 py-2 font-mono text-sm" rows="5" oninput="updateBlockProp('${b.id}', '${escapeHtml(key)}', this.value)">${escapeHtml(String(value || ''))}</textarea>
|
|
2381
|
+
</div>
|
|
2382
|
+
`;
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
return `
|
|
2386
|
+
<div>
|
|
2387
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">${escapeHtml(fLabel)}</label>
|
|
2388
|
+
<input type="text" class="w-full border rounded px-3 py-2 text-sm" value="${escapeHtml(String(value || ''))}" oninput="updateBlockProp('${b.id}', '${escapeHtml(key)}', this.value)" />
|
|
2389
|
+
</div>
|
|
2390
|
+
`;
|
|
2391
|
+
}).join('');
|
|
2392
|
+
|
|
2393
|
+
return `
|
|
2394
|
+
<div class="border rounded-lg bg-white p-3 ${borderClass}" draggable="true" data-idx="${idx}" ondragstart="onBlockDragStart(event)" ondragover="onBlockDragOver(event)" ondrop="onBlockDrop(event)" onclick="setSelectedBlock('${b.id}')">
|
|
2395
|
+
<div class="flex items-center justify-between mb-3">
|
|
2396
|
+
<div class="flex items-center gap-2">
|
|
2397
|
+
<span class="text-gray-400"><i class="ti ti-grip-vertical"></i></span>
|
|
2398
|
+
<div>
|
|
2399
|
+
<div class="text-sm font-semibold text-gray-900">${escapeHtml(label)}</div>
|
|
2400
|
+
<div class="text-xs text-gray-500 font-mono">${escapeHtml(b.type)} • ${escapeHtml(b.id)}</div>
|
|
2401
|
+
</div>
|
|
2402
|
+
</div>
|
|
2403
|
+
<button type="button" class="text-red-600 hover:text-red-800" onclick="removeBlock('${b.id}')" title="Remove"><i class="ti ti-trash"></i></button>
|
|
2404
|
+
</div>
|
|
2405
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
2406
|
+
${fieldsHtml || '<div class="text-sm text-gray-500">No fields defined for this block type.</div>'}
|
|
2407
|
+
</div>
|
|
2408
|
+
</div>
|
|
2409
|
+
`;
|
|
2410
|
+
}).join('');
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
function formatDate(d) {
|
|
2414
|
+
if (!d) return '-';
|
|
2415
|
+
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
loadBlocksSchema().then(() => loadCollections().then(() => loadPages()));
|
|
2419
|
+
loadTemplates();
|
|
2420
|
+
loadLayouts();
|
|
2421
|
+
loadBlockDefinitions();
|
|
2422
|
+
</script>
|
|
2423
|
+
</body>
|
|
2424
|
+
</html>
|