@intranefr/superbackend 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.commiat +4 -0
- package/.env.example +47 -0
- package/README.md +110 -0
- package/index.js +94 -0
- package/package.json +67 -0
- package/public/css/styles.css +139 -0
- package/public/js/animations.js +41 -0
- package/sdk/error-tracking/browser/package.json +16 -0
- package/sdk/error-tracking/browser/src/core.js +270 -0
- package/sdk/error-tracking/browser/src/embed.js +18 -0
- package/sdk/error-tracking/browser/src/index.js +1 -0
- package/server.js +5 -0
- package/src/admin/endpointRegistry.js +300 -0
- package/src/controllers/admin.controller.js +321 -0
- package/src/controllers/adminAssets.controller.js +530 -0
- package/src/controllers/adminAssetsStorage.controller.js +260 -0
- package/src/controllers/adminEjsVirtual.controller.js +354 -0
- package/src/controllers/adminFeatureFlags.controller.js +155 -0
- package/src/controllers/adminHeadless.controller.js +1071 -0
- package/src/controllers/adminI18n.controller.js +604 -0
- package/src/controllers/adminJsonConfigs.controller.js +97 -0
- package/src/controllers/adminLlm.controller.js +273 -0
- package/src/controllers/adminMigration.controller.js +257 -0
- package/src/controllers/adminSeoConfig.controller.js +515 -0
- package/src/controllers/adminStats.controller.js +121 -0
- package/src/controllers/adminUploadNamespaces.controller.js +208 -0
- package/src/controllers/assets.controller.js +248 -0
- package/src/controllers/auth.controller.js +93 -0
- package/src/controllers/billing.controller.js +223 -0
- package/src/controllers/featureFlags.controller.js +35 -0
- package/src/controllers/forms.controller.js +217 -0
- package/src/controllers/globalSettings.controller.js +252 -0
- package/src/controllers/headlessCrud.controller.js +126 -0
- package/src/controllers/i18n.controller.js +12 -0
- package/src/controllers/invite.controller.js +249 -0
- package/src/controllers/jsonConfigs.controller.js +19 -0
- package/src/controllers/metrics.controller.js +149 -0
- package/src/controllers/notificationAdmin.controller.js +264 -0
- package/src/controllers/notifications.controller.js +131 -0
- package/src/controllers/org.controller.js +357 -0
- package/src/controllers/orgAdmin.controller.js +491 -0
- package/src/controllers/stripeAdmin.controller.js +410 -0
- package/src/controllers/user.controller.js +361 -0
- package/src/controllers/userAdmin.controller.js +277 -0
- package/src/controllers/waitingList.controller.js +167 -0
- package/src/controllers/webhook.controller.js +200 -0
- package/src/middleware/auth.js +66 -0
- package/src/middleware/errorCapture.js +170 -0
- package/src/middleware/headlessApiTokenAuth.js +57 -0
- package/src/middleware/org.js +108 -0
- package/src/middleware.js +901 -0
- package/src/models/ActionEvent.js +31 -0
- package/src/models/ActivityLog.js +41 -0
- package/src/models/Asset.js +84 -0
- package/src/models/AuditEvent.js +93 -0
- package/src/models/EmailLog.js +28 -0
- package/src/models/ErrorAggregate.js +72 -0
- package/src/models/FormSubmission.js +41 -0
- package/src/models/GlobalSetting.js +38 -0
- package/src/models/HeadlessApiToken.js +24 -0
- package/src/models/HeadlessModelDefinition.js +41 -0
- package/src/models/I18nEntry.js +77 -0
- package/src/models/I18nLocale.js +33 -0
- package/src/models/Invite.js +70 -0
- package/src/models/JsonConfig.js +46 -0
- package/src/models/Notification.js +60 -0
- package/src/models/Organization.js +57 -0
- package/src/models/OrganizationMember.js +43 -0
- package/src/models/StripeCatalogItem.js +77 -0
- package/src/models/StripeWebhookEvent.js +57 -0
- package/src/models/User.js +89 -0
- package/src/models/VirtualEjsFile.js +60 -0
- package/src/models/VirtualEjsFileVersion.js +43 -0
- package/src/models/VirtualEjsGroupChange.js +32 -0
- package/src/models/WaitingList.js +41 -0
- package/src/models/Webhook.js +63 -0
- package/src/models/Workflow.js +29 -0
- package/src/models/WorkflowExecution.js +12 -0
- package/src/routes/admin.routes.js +26 -0
- package/src/routes/adminAssets.routes.js +28 -0
- package/src/routes/adminAssetsStorage.routes.js +13 -0
- package/src/routes/adminAudit.routes.js +196 -0
- package/src/routes/adminEjsVirtual.routes.js +17 -0
- package/src/routes/adminErrors.routes.js +164 -0
- package/src/routes/adminFeatureFlags.routes.js +12 -0
- package/src/routes/adminHeadless.routes.js +38 -0
- package/src/routes/adminI18n.routes.js +22 -0
- package/src/routes/adminJsonConfigs.routes.js +15 -0
- package/src/routes/adminLlm.routes.js +12 -0
- package/src/routes/adminMigration.routes.js +81 -0
- package/src/routes/adminSeoConfig.routes.js +20 -0
- package/src/routes/adminUploadNamespaces.routes.js +13 -0
- package/src/routes/assets.routes.js +21 -0
- package/src/routes/auth.routes.js +12 -0
- package/src/routes/billing.routes.js +11 -0
- package/src/routes/errorTracking.routes.js +31 -0
- package/src/routes/featureFlags.routes.js +9 -0
- package/src/routes/forms.routes.js +9 -0
- package/src/routes/formsAdmin.routes.js +13 -0
- package/src/routes/globalSettings.routes.js +18 -0
- package/src/routes/headless.routes.js +15 -0
- package/src/routes/i18n.routes.js +8 -0
- package/src/routes/invite.routes.js +9 -0
- package/src/routes/jsonConfigs.routes.js +8 -0
- package/src/routes/log.routes.js +111 -0
- package/src/routes/metrics.routes.js +9 -0
- package/src/routes/notificationAdmin.routes.js +15 -0
- package/src/routes/notifications.routes.js +12 -0
- package/src/routes/org.routes.js +31 -0
- package/src/routes/orgAdmin.routes.js +20 -0
- package/src/routes/publicAssets.routes.js +7 -0
- package/src/routes/stripeAdmin.routes.js +20 -0
- package/src/routes/user.routes.js +22 -0
- package/src/routes/userAdmin.routes.js +15 -0
- package/src/routes/waitingList.routes.js +13 -0
- package/src/routes/waitingListAdmin.routes.js +9 -0
- package/src/routes/webhook.routes.js +32 -0
- package/src/routes/workflowWebhook.routes.js +54 -0
- package/src/routes/workflows.routes.js +110 -0
- package/src/services/assets.service.js +110 -0
- package/src/services/audit.service.js +62 -0
- package/src/services/auditLogger.js +165 -0
- package/src/services/ejsVirtual.service.js +614 -0
- package/src/services/email.service.js +351 -0
- package/src/services/errorLogger.js +221 -0
- package/src/services/featureFlags.service.js +202 -0
- package/src/services/forms.service.js +214 -0
- package/src/services/globalSettings.service.js +49 -0
- package/src/services/headlessApiTokens.service.js +158 -0
- package/src/services/headlessCrypto.service.js +31 -0
- package/src/services/headlessModels.service.js +356 -0
- package/src/services/i18n.service.js +314 -0
- package/src/services/i18nInferredKeys.service.js +337 -0
- package/src/services/jsonConfigs.service.js +392 -0
- package/src/services/llm.service.js +749 -0
- package/src/services/migration.service.js +581 -0
- package/src/services/migrationAssets/fsLocal.js +58 -0
- package/src/services/migrationAssets/index.js +134 -0
- package/src/services/migrationAssets/s3.js +75 -0
- package/src/services/migrationAssets/sftp.js +92 -0
- package/src/services/notification.service.js +212 -0
- package/src/services/objectStorage.service.js +514 -0
- package/src/services/seoConfig.service.js +402 -0
- package/src/services/storage.js +150 -0
- package/src/services/stripe.service.js +185 -0
- package/src/services/stripeHelper.service.js +264 -0
- package/src/services/uploadNamespaces.service.js +326 -0
- package/src/services/webhook.service.js +157 -0
- package/src/services/workflow.service.js +271 -0
- package/src/utils/asyncHandler.js +5 -0
- package/src/utils/encryption.js +80 -0
- package/src/utils/jwt.js +40 -0
- package/src/utils/orgRoles.js +156 -0
- package/src/utils/validation.js +26 -0
- package/src/utils/webhookRetry.js +93 -0
- package/views/admin-assets.ejs +444 -0
- package/views/admin-audit.ejs +283 -0
- package/views/admin-coolify-deploy.ejs +207 -0
- package/views/admin-dashboard-home.ejs +291 -0
- package/views/admin-dashboard.ejs +397 -0
- package/views/admin-ejs-virtual.ejs +280 -0
- package/views/admin-errors.ejs +368 -0
- package/views/admin-feature-flags.ejs +390 -0
- package/views/admin-forms.ejs +526 -0
- package/views/admin-global-settings.ejs +436 -0
- package/views/admin-headless.ejs +2020 -0
- package/views/admin-i18n-locales.ejs +221 -0
- package/views/admin-i18n.ejs +728 -0
- package/views/admin-json-configs.ejs +410 -0
- package/views/admin-llm.ejs +884 -0
- package/views/admin-metrics.ejs +274 -0
- package/views/admin-migration.ejs +814 -0
- package/views/admin-notifications.ejs +430 -0
- package/views/admin-organizations.ejs +984 -0
- package/views/admin-seo-config.ejs +673 -0
- package/views/admin-stripe-pricing.ejs +558 -0
- package/views/admin-test.ejs +342 -0
- package/views/admin-users.ejs +452 -0
- package/views/admin-waiting-list.ejs +547 -0
- package/views/admin-webhooks.ejs +329 -0
- package/views/admin-workflows.ejs +310 -0
- package/views/partials/admin-assets-script.ejs +2022 -0
- package/views/partials/admin-test-sidebar.ejs +14 -0
- package/views/partials/dashboard/nav-items.ejs +66 -0
- package/views/partials/dashboard/palette.ejs +63 -0
- package/views/partials/dashboard/sidebar.ejs +21 -0
- package/views/partials/dashboard/tab-bar.ejs +26 -0
- package/views/partials/footer.ejs +3 -0
|
@@ -0,0 +1,673 @@
|
|
|
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>SEO Config - Admin</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<style>
|
|
9
|
+
.toast { animation: slideIn 0.3s ease-out; }
|
|
10
|
+
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
|
11
|
+
.fade-out { animation: fadeOut 0.3s ease-out forwards; }
|
|
12
|
+
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
|
|
13
|
+
pre { white-space: pre; }
|
|
14
|
+
</style>
|
|
15
|
+
</head>
|
|
16
|
+
<body class="bg-gray-100">
|
|
17
|
+
<div class="min-h-screen">
|
|
18
|
+
<div class="bg-white shadow">
|
|
19
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
20
|
+
<div class="flex justify-between items-center">
|
|
21
|
+
<div>
|
|
22
|
+
<h1 class="text-2xl font-bold text-gray-900">SEO Config</h1>
|
|
23
|
+
<p class="text-sm text-gray-600 mt-1">Manage SEO JSON config and OG image tooling</p>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="flex items-center gap-4">
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
|
32
|
+
|
|
33
|
+
<div class="bg-white rounded-lg shadow">
|
|
34
|
+
<div class="px-4 py-3 border-b">
|
|
35
|
+
<div class="flex justify-between items-center">
|
|
36
|
+
<h2 class="text-lg font-semibold text-gray-900">SEO Config JSON</h2>
|
|
37
|
+
<div class="flex gap-2">
|
|
38
|
+
<button class="px-3 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 text-sm" onclick="loadSeoConfig()">Reload</button>
|
|
39
|
+
<button class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm" onclick="saveSeoConfig()">Save</button>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
<p class="text-sm text-gray-600 mt-1">Fixed slug: <code class="bg-gray-100 px-1 rounded">seo-config</code> (stored in JSON Configs). SVG is stored separately in Global Settings.</p>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="p-4 space-y-4">
|
|
45
|
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
46
|
+
<label class="flex items-center gap-2 text-sm">
|
|
47
|
+
<input id="seo-public-enabled" type="checkbox" class="h-4 w-4" />
|
|
48
|
+
<span>Public enabled (expose via <code>/api/json-configs/seo-config</code>)</span>
|
|
49
|
+
</label>
|
|
50
|
+
<div>
|
|
51
|
+
<label class="block text-sm font-medium">Cache TTL (seconds)</label>
|
|
52
|
+
<input id="seo-cache-ttl" type="number" min="0" class="mt-1 w-full border rounded px-3 py-2 text-sm" placeholder="0" />
|
|
53
|
+
</div>
|
|
54
|
+
<div class="text-sm text-gray-600 flex items-end" id="seo-updated-at"></div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div>
|
|
58
|
+
<label class="block text-sm font-medium mb-1">JSON</label>
|
|
59
|
+
<textarea id="seo-json-raw" class="w-full border rounded px-3 py-2 font-mono text-xs" rows="18" placeholder="{\n ...\n}"></textarea>
|
|
60
|
+
<div class="mt-2 text-xs text-gray-600" id="seo-json-status"></div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div class="bg-white rounded-lg shadow">
|
|
66
|
+
<div class="px-4 py-3 border-b">
|
|
67
|
+
<div class="flex justify-between items-center">
|
|
68
|
+
<h2 class="text-lg font-semibold text-gray-900">Developer helper</h2>
|
|
69
|
+
<button class="px-3 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 text-sm" onclick="copySnippet()">Copy</button>
|
|
70
|
+
</div>
|
|
71
|
+
<p class="text-sm text-gray-600 mt-1">Fetch SEO config via internal JSON Config service.</p>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="p-4">
|
|
74
|
+
<pre id="dev-snippet" class="bg-gray-50 rounded p-3 text-xs overflow-auto"></pre>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<div class="bg-white rounded-lg shadow">
|
|
79
|
+
<div class="px-4 py-3 border-b">
|
|
80
|
+
<div class="flex justify-between items-center">
|
|
81
|
+
<h2 class="text-lg font-semibold text-gray-900">AI helpers (SEO JSON)</h2>
|
|
82
|
+
<div class="flex gap-2">
|
|
83
|
+
<button class="px-3 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 text-sm" onclick="seoAiLoadViews()">Infer .ejs views</button>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
<p class="text-sm text-gray-600 mt-1">Generate or improve <code>pages</code> entries with AI, then apply them into the <code>seo-config</code> JSON.</p>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div class="p-4 space-y-6">
|
|
90
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
91
|
+
<div>
|
|
92
|
+
<h3 class="text-sm font-semibold text-gray-900">Add new entry from EJS view</h3>
|
|
93
|
+
<div class="mt-2 space-y-3">
|
|
94
|
+
<div>
|
|
95
|
+
<label class="block text-sm font-medium">View (.ejs)</label>
|
|
96
|
+
<select id="seo-ai-view" class="mt-1 w-full border rounded px-3 py-2 text-sm">
|
|
97
|
+
<option value="">(load views)</option>
|
|
98
|
+
</select>
|
|
99
|
+
<div class="mt-1 text-xs text-gray-600" id="seo-ai-views-status"></div>
|
|
100
|
+
</div>
|
|
101
|
+
<div>
|
|
102
|
+
<label class="block text-sm font-medium">Route path</label>
|
|
103
|
+
<input id="seo-ai-route" class="mt-1 w-full border rounded px-3 py-2 text-sm" placeholder="/marketplace" />
|
|
104
|
+
</div>
|
|
105
|
+
<div class="flex gap-2">
|
|
106
|
+
<button class="px-3 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 text-sm" onclick="seoAiGenerateFromView()">Generate entry</button>
|
|
107
|
+
<button class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm" onclick="seoAiApplyProposed()">Apply</button>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="text-xs text-gray-600" id="seo-ai-generate-status"></div>
|
|
110
|
+
<pre id="seo-ai-proposed" class="bg-gray-50 rounded p-3 text-xs overflow-auto" style="min-height: 96px;"></pre>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div>
|
|
115
|
+
<h3 class="text-sm font-semibold text-gray-900">Improve existing entry</h3>
|
|
116
|
+
<div class="mt-2 space-y-3">
|
|
117
|
+
<div>
|
|
118
|
+
<label class="block text-sm font-medium">Select existing route</label>
|
|
119
|
+
<select id="seo-ai-existing-route" class="mt-1 w-full border rounded px-3 py-2 text-sm">
|
|
120
|
+
<option value="">(load from JSON)</option>
|
|
121
|
+
</select>
|
|
122
|
+
</div>
|
|
123
|
+
<div>
|
|
124
|
+
<label class="block text-sm font-medium">Instruction</label>
|
|
125
|
+
<input id="seo-ai-instruction" class="mt-1 w-full border rounded px-3 py-2 text-sm" placeholder="Improve description including desktop apps" />
|
|
126
|
+
</div>
|
|
127
|
+
<div class="flex gap-2">
|
|
128
|
+
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
|
|
129
|
+
<input id="seo-ai-robots-noindex" type="checkbox" class="rounded border-gray-300" />
|
|
130
|
+
Set <code>robots: 'noindex,follow'</code>
|
|
131
|
+
</label>
|
|
132
|
+
</div>
|
|
133
|
+
<div class="flex gap-2">
|
|
134
|
+
<button class="px-3 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 text-sm" onclick="seoAiImproveEntry()">Generate improved entry</button>
|
|
135
|
+
<button class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm" onclick="seoAiApplyProposed()">Apply</button>
|
|
136
|
+
</div>
|
|
137
|
+
<div class="text-xs text-gray-600" id="seo-ai-improve-status"></div>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div class="text-xs text-gray-600">
|
|
143
|
+
Endpoints used: <code>/api/admin/seo-config/ai/*</code> and <code>/api/admin/seo-config/pages/apply-entry</code>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<div class="bg-white rounded-lg shadow">
|
|
149
|
+
<div class="px-4 py-3 border-b">
|
|
150
|
+
<div class="flex justify-between items-center">
|
|
151
|
+
<h2 class="text-lg font-semibold text-gray-900">OG Share Image</h2>
|
|
152
|
+
<div class="flex gap-2">
|
|
153
|
+
<button class="px-3 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 text-sm" onclick="reloadOgPng()">Reload PNG</button>
|
|
154
|
+
<button class="px-3 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 text-sm" onclick="loadSeoConfig()">Reload data</button>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
<p class="text-sm text-gray-600 mt-1">Edit SVG, preview live, and generate a 1200×630 PNG.</p>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<div class="p-4 space-y-5">
|
|
161
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
162
|
+
<div>
|
|
163
|
+
<div class="flex justify-between items-center mb-2">
|
|
164
|
+
<label class="block text-sm font-medium">SVG (Global Setting: <code>seoconfig.og.svg</code>)</label>
|
|
165
|
+
<div class="flex gap-2">
|
|
166
|
+
<button id="ai-edit-btn" class="px-3 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 text-sm" onclick="openAiModal()">AI edit</button>
|
|
167
|
+
<button class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm" onclick="saveOgSvg()">Save SVG</button>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
<textarea id="og-svg-raw" class="w-full border rounded px-3 py-2 font-mono text-xs" rows="16" placeholder="<svg ...>"></textarea>
|
|
171
|
+
<div class="mt-2 text-xs text-gray-600" id="og-svg-status"></div>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<div>
|
|
175
|
+
<div class="mb-2 flex justify-between items-center">
|
|
176
|
+
<label class="block text-sm font-medium">SVG preview (sandboxed)</label>
|
|
177
|
+
<div class="text-xs text-gray-600">Updates on change</div>
|
|
178
|
+
</div>
|
|
179
|
+
<iframe id="og-svg-preview" class="w-full border rounded bg-white" style="height: 360px;" sandbox></iframe>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
184
|
+
<div>
|
|
185
|
+
<label class="block text-sm font-medium mb-1">PNG output path (must be under <code>public/</code>)</label>
|
|
186
|
+
<div class="flex gap-2">
|
|
187
|
+
<input id="og-png-output" class="flex-1 border rounded px-3 py-2 text-sm" value="public/og/og-default.png" />
|
|
188
|
+
<button class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm" onclick="generatePng()">Generate PNG</button>
|
|
189
|
+
</div>
|
|
190
|
+
<div class="mt-2 text-xs text-gray-600" id="og-png-status"></div>
|
|
191
|
+
</div>
|
|
192
|
+
<div>
|
|
193
|
+
<label class="block text-sm font-medium mb-1">PNG preview</label>
|
|
194
|
+
<div id="png-preview-wrapper" class="border rounded bg-white p-3" style="min-height: 140px;">
|
|
195
|
+
<div id="png-preview-empty" class="text-sm text-gray-600">Not available</div>
|
|
196
|
+
<img id="png-preview" class="hidden max-w-full" alt="og png preview" />
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<div id="toast-container" class="fixed top-4 right-4 space-y-2 z-50"></div>
|
|
208
|
+
|
|
209
|
+
<div id="ai-modal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
|
210
|
+
<div class="relative top-20 mx-auto p-5 border w-full max-w-2xl shadow-lg rounded-md bg-white">
|
|
211
|
+
<div class="flex justify-between items-center mb-4">
|
|
212
|
+
<h3 class="text-xl font-bold">AI edit SVG</h3>
|
|
213
|
+
<button onclick="closeAiModal()" class="text-gray-400 hover:text-gray-600">✕</button>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<div class="mb-3">
|
|
217
|
+
<label class="block text-sm font-medium mb-1">Instruction</label>
|
|
218
|
+
<input id="ai-instruction" class="w-full border rounded px-3 py-2 text-sm" placeholder="e.g. Make background darker and replace title with MicroExits" />
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<div class="mb-3">
|
|
222
|
+
<label class="block text-sm font-medium mb-1">Model (optional override)</label>
|
|
223
|
+
<input id="ai-model" class="w-full border rounded px-3 py-2 text-sm" placeholder="google/gemini-2.5-flash-lite" />
|
|
224
|
+
<div class="mt-1 text-xs text-gray-600">Uses <code>seoconfig.ai.openrouter.*</code> settings with fallback to <code>ai.openrouter.*</code>. Disabled if no API key.</div>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<div class="flex justify-end gap-2">
|
|
228
|
+
<button type="button" onclick="closeAiModal()" class="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400">Cancel</button>
|
|
229
|
+
<button type="button" id="ai-run-btn" onclick="runAiEdit()" class="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700">Run</button>
|
|
230
|
+
</div>
|
|
231
|
+
<div class="mt-3 text-xs text-gray-600" id="ai-status"></div>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<script>
|
|
236
|
+
const API_BASE = window.location.origin + "<%= baseUrl %>";
|
|
237
|
+
|
|
238
|
+
function showToast(message, type = 'success') {
|
|
239
|
+
const container = document.getElementById('toast-container');
|
|
240
|
+
const toast = document.createElement('div');
|
|
241
|
+
toast.className = `toast px-6 py-4 rounded-lg shadow-lg text-white ${type === 'success' ? 'bg-green-500' : 'bg-red-500'}`;
|
|
242
|
+
toast.textContent = message;
|
|
243
|
+
container.appendChild(toast);
|
|
244
|
+
setTimeout(() => {
|
|
245
|
+
toast.classList.add('fade-out');
|
|
246
|
+
setTimeout(() => toast.remove(), 300);
|
|
247
|
+
}, 3000);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function buildSnippet() {
|
|
251
|
+
const snippet = `const { getJsonConfigValueBySlug } = require('saasbackend').services.jsonConfigs;\n\nasync function loadSeoConfig() {\n const seo = await getJsonConfigValueBySlug('seo-config');\n return seo;\n}`;
|
|
252
|
+
document.getElementById('dev-snippet').textContent = snippet;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function setSvgPreview(svgRaw) {
|
|
256
|
+
const iframe = document.getElementById('og-svg-preview');
|
|
257
|
+
<script>
|
|
258
|
+
window.addEventListener("keydown", (e) => {
|
|
259
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
window.parent.postMessage({ type: "keydown", ctrlK: true }, "*");
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
</script>
|
|
265
|
+
const html = `<!doctype html><html><head><meta charset="utf-8" />\n<style>html,body{margin:0;padding:0;background:#fff;}svg{width:100%;height:auto;display:block;}</style></head><body>${svgRaw || ''}</body></html>`;
|
|
266
|
+
iframe.srcdoc = html;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function debounce(fn, wait) {
|
|
270
|
+
let t;
|
|
271
|
+
return (...args) => {
|
|
272
|
+
clearTimeout(t);
|
|
273
|
+
t = setTimeout(() => fn(...args), wait);
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const debouncedPreview = debounce(() => {
|
|
278
|
+
const svgRaw = document.getElementById('og-svg-raw').value;
|
|
279
|
+
setSvgPreview(svgRaw);
|
|
280
|
+
}, 200);
|
|
281
|
+
|
|
282
|
+
async function loadSeoConfig() {
|
|
283
|
+
const status = document.getElementById('seo-json-status');
|
|
284
|
+
status.textContent = 'Loading...';
|
|
285
|
+
try {
|
|
286
|
+
const res = await fetch(`${API_BASE}/api/admin/seo-config`);
|
|
287
|
+
const data = await res.json().catch(() => ({}));
|
|
288
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to load');
|
|
289
|
+
|
|
290
|
+
document.getElementById('seo-public-enabled').checked = Boolean(data?.config?.publicEnabled);
|
|
291
|
+
document.getElementById('seo-cache-ttl').value = String(data?.config?.cacheTtlSeconds ?? 0);
|
|
292
|
+
document.getElementById('seo-json-raw').value = String(data?.config?.jsonRaw || '');
|
|
293
|
+
document.getElementById('seo-updated-at').textContent = data?.config?.updatedAt ? `Updated: ${new Date(data.config.updatedAt).toLocaleString()}` : '';
|
|
294
|
+
|
|
295
|
+
document.getElementById('og-svg-raw').value = String(data?.og?.svgRaw || '');
|
|
296
|
+
document.getElementById('og-png-output').value = String(data?.og?.defaultPngOutputPath || 'public/og/og-default.png');
|
|
297
|
+
setSvgPreview(String(data?.og?.svgRaw || ''));
|
|
298
|
+
|
|
299
|
+
status.textContent = 'Loaded';
|
|
300
|
+
document.getElementById('og-svg-status').textContent = '';
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
seoAiRefreshRoutesFromJson(document.getElementById('seo-json-raw').value);
|
|
304
|
+
} catch {}
|
|
305
|
+
|
|
306
|
+
await reloadOgPng();
|
|
307
|
+
|
|
308
|
+
return data;
|
|
309
|
+
} catch (e) {
|
|
310
|
+
status.textContent = e.message || 'Failed to load';
|
|
311
|
+
showToast(status.textContent, 'error');
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function seoAiRefreshRoutesFromJson(jsonRaw) {
|
|
316
|
+
let parsed;
|
|
317
|
+
parsed = JSON.parse(String(jsonRaw || ''));
|
|
318
|
+
const pages = parsed && typeof parsed === 'object' ? parsed.pages : null;
|
|
319
|
+
const routes = pages && typeof pages === 'object' ? Object.keys(pages) : [];
|
|
320
|
+
routes.sort();
|
|
321
|
+
|
|
322
|
+
const select = document.getElementById('seo-ai-existing-route');
|
|
323
|
+
const current = select.value;
|
|
324
|
+
select.innerHTML = '<option value="">(select)</option>';
|
|
325
|
+
for (const r of routes) {
|
|
326
|
+
const opt = document.createElement('option');
|
|
327
|
+
opt.value = r;
|
|
328
|
+
opt.textContent = r;
|
|
329
|
+
select.appendChild(opt);
|
|
330
|
+
}
|
|
331
|
+
if (routes.includes(current)) select.value = current;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function seoAiLoadViews() {
|
|
335
|
+
const status = document.getElementById('seo-ai-views-status');
|
|
336
|
+
status.textContent = 'Loading views...';
|
|
337
|
+
try {
|
|
338
|
+
const res = await fetch(`${API_BASE}/api/admin/seo-config/ai/views`);
|
|
339
|
+
const data = await res.json().catch(() => ({}));
|
|
340
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to load views');
|
|
341
|
+
|
|
342
|
+
const views = Array.isArray(data?.views) ? data.views : [];
|
|
343
|
+
const select = document.getElementById('seo-ai-view');
|
|
344
|
+
select.innerHTML = '<option value="">(select a view)</option>';
|
|
345
|
+
for (const v of views) {
|
|
346
|
+
const opt = document.createElement('option');
|
|
347
|
+
opt.value = v;
|
|
348
|
+
opt.textContent = v;
|
|
349
|
+
select.appendChild(opt);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
status.textContent = `Loaded ${views.length} view(s)`;
|
|
353
|
+
} catch (e) {
|
|
354
|
+
status.textContent = e.message || 'Failed to load views';
|
|
355
|
+
showToast(status.textContent, 'error');
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let seoAiProposed = null;
|
|
360
|
+
|
|
361
|
+
function seoAiNormalizeRobotsForImprove(entry) {
|
|
362
|
+
const robots = document.getElementById('seo-ai-robots-noindex');
|
|
363
|
+
if (!entry || typeof entry !== 'object') return entry;
|
|
364
|
+
if (!robots) return entry;
|
|
365
|
+
|
|
366
|
+
if (robots.checked) {
|
|
367
|
+
entry.robots = 'noindex,follow';
|
|
368
|
+
} else {
|
|
369
|
+
delete entry.robots;
|
|
370
|
+
}
|
|
371
|
+
return entry;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function seoAiTryPatchJsonTextareaRobots(routePath) {
|
|
375
|
+
const jsonEl = document.getElementById('seo-json-raw');
|
|
376
|
+
const robots = document.getElementById('seo-ai-robots-noindex');
|
|
377
|
+
if (!jsonEl || !robots) return;
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
const parsed = JSON.parse(String(jsonEl.value || ''));
|
|
381
|
+
if (!parsed || typeof parsed !== 'object') return;
|
|
382
|
+
if (!parsed.pages || typeof parsed.pages !== 'object') return;
|
|
383
|
+
if (!routePath || typeof routePath !== 'string') return;
|
|
384
|
+
if (!parsed.pages[routePath] || typeof parsed.pages[routePath] !== 'object') return;
|
|
385
|
+
|
|
386
|
+
const entry = { ...(parsed.pages[routePath] || {}) };
|
|
387
|
+
if (robots.checked) {
|
|
388
|
+
entry.robots = 'noindex,follow';
|
|
389
|
+
} else {
|
|
390
|
+
delete entry.robots;
|
|
391
|
+
}
|
|
392
|
+
parsed.pages[routePath] = entry;
|
|
393
|
+
|
|
394
|
+
jsonEl.value = JSON.stringify(parsed, null, 2);
|
|
395
|
+
} catch {
|
|
396
|
+
// ignore parse errors; user may be editing invalid JSON
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function seoAiSetProposed({ routePath, entry, model }) {
|
|
401
|
+
seoAiProposed = { routePath, entry, model };
|
|
402
|
+
const pre = document.getElementById('seo-ai-proposed');
|
|
403
|
+
pre.textContent = JSON.stringify({ routePath, entry, model }, null, 2);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function seoAiGenerateFromView() {
|
|
407
|
+
const status = document.getElementById('seo-ai-generate-status');
|
|
408
|
+
status.textContent = 'Generating...';
|
|
409
|
+
try {
|
|
410
|
+
const viewPath = document.getElementById('seo-ai-view').value;
|
|
411
|
+
const routePath = document.getElementById('seo-ai-route').value;
|
|
412
|
+
const res = await fetch(`${API_BASE}/api/admin/seo-config/ai/generate-entry`, {
|
|
413
|
+
method: 'POST',
|
|
414
|
+
headers: { 'Content-Type': 'application/json' },
|
|
415
|
+
body: JSON.stringify({ viewPath, routePath }),
|
|
416
|
+
});
|
|
417
|
+
const data = await res.json().catch(() => ({}));
|
|
418
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to generate entry');
|
|
419
|
+
|
|
420
|
+
seoAiSetProposed({ routePath: data.routePath, entry: data.entry, model: data.model });
|
|
421
|
+
status.textContent = `Generated (model: ${data.model || 'unknown'})`;
|
|
422
|
+
showToast('Generated SEO entry');
|
|
423
|
+
} catch (e) {
|
|
424
|
+
status.textContent = e.message || 'Failed to generate entry';
|
|
425
|
+
showToast(status.textContent, 'error');
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function seoAiImproveEntry() {
|
|
430
|
+
const status = document.getElementById('seo-ai-improve-status');
|
|
431
|
+
status.textContent = 'Generating...';
|
|
432
|
+
try {
|
|
433
|
+
const routePath = document.getElementById('seo-ai-existing-route').value;
|
|
434
|
+
const instruction = document.getElementById('seo-ai-instruction').value;
|
|
435
|
+
const res = await fetch(`${API_BASE}/api/admin/seo-config/ai/improve-entry`, {
|
|
436
|
+
method: 'POST',
|
|
437
|
+
headers: { 'Content-Type': 'application/json' },
|
|
438
|
+
body: JSON.stringify({ routePath, instruction }),
|
|
439
|
+
});
|
|
440
|
+
const data = await res.json().catch(() => ({}));
|
|
441
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to improve entry');
|
|
442
|
+
|
|
443
|
+
const nextEntry = seoAiNormalizeRobotsForImprove({ ...(data.entry || {}) });
|
|
444
|
+
seoAiProposed = { routePath: data.routePath, entry: nextEntry, model: data.model, source: 'improve' };
|
|
445
|
+
const pre = document.getElementById('seo-ai-proposed');
|
|
446
|
+
pre.textContent = JSON.stringify({ routePath: seoAiProposed.routePath, entry: seoAiProposed.entry, model: seoAiProposed.model }, null, 2);
|
|
447
|
+
status.textContent = `Generated (model: ${data.model || 'unknown'})`;
|
|
448
|
+
showToast('Generated improved SEO entry');
|
|
449
|
+
} catch (e) {
|
|
450
|
+
status.textContent = e.message || 'Failed';
|
|
451
|
+
showToast(status.textContent, 'error');
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function seoAiApplyProposed() {
|
|
456
|
+
const status = document.getElementById('seo-json-status');
|
|
457
|
+
if (!seoAiProposed?.routePath || !seoAiProposed?.entry) {
|
|
458
|
+
showToast('No proposed entry to apply', 'error');
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (seoAiProposed.source === 'improve') {
|
|
463
|
+
seoAiProposed.entry = seoAiNormalizeRobotsForImprove({ ...(seoAiProposed.entry || {}) });
|
|
464
|
+
const pre = document.getElementById('seo-ai-proposed');
|
|
465
|
+
pre.textContent = JSON.stringify({ routePath: seoAiProposed.routePath, entry: seoAiProposed.entry, model: seoAiProposed.model }, null, 2);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
status.textContent = 'Applying...';
|
|
469
|
+
try {
|
|
470
|
+
const res = await fetch(`${API_BASE}/api/admin/seo-config/pages/apply-entry`, {
|
|
471
|
+
method: 'POST',
|
|
472
|
+
headers: { 'Content-Type': 'application/json' },
|
|
473
|
+
body: JSON.stringify({ routePath: seoAiProposed.routePath, entry: seoAiProposed.entry }),
|
|
474
|
+
});
|
|
475
|
+
const data = await res.json().catch(() => ({}));
|
|
476
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to apply entry');
|
|
477
|
+
|
|
478
|
+
const jsonRaw = data?.result?.jsonRaw;
|
|
479
|
+
if (typeof jsonRaw === 'string') {
|
|
480
|
+
document.getElementById('seo-json-raw').value = jsonRaw;
|
|
481
|
+
seoAiRefreshRoutesFromJson(jsonRaw);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
status.textContent = 'Applied';
|
|
485
|
+
showToast('Applied entry to SEO JSON');
|
|
486
|
+
} catch (e) {
|
|
487
|
+
status.textContent = e.message || 'Failed to apply';
|
|
488
|
+
showToast(status.textContent, 'error');
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function saveSeoConfig() {
|
|
493
|
+
const status = document.getElementById('seo-json-status');
|
|
494
|
+
status.textContent = 'Saving...';
|
|
495
|
+
try {
|
|
496
|
+
const jsonRaw = document.getElementById('seo-json-raw').value;
|
|
497
|
+
const publicEnabled = document.getElementById('seo-public-enabled').checked;
|
|
498
|
+
const cacheTtlSeconds = Number(document.getElementById('seo-cache-ttl').value || 0);
|
|
499
|
+
|
|
500
|
+
const res = await fetch(`${API_BASE}/api/admin/seo-config`, {
|
|
501
|
+
method: 'PUT',
|
|
502
|
+
headers: { 'Content-Type': 'application/json' },
|
|
503
|
+
body: JSON.stringify({ jsonRaw, publicEnabled, cacheTtlSeconds }),
|
|
504
|
+
});
|
|
505
|
+
const data = await res.json().catch(() => ({}));
|
|
506
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to save');
|
|
507
|
+
|
|
508
|
+
status.textContent = 'Saved';
|
|
509
|
+
showToast('Saved SEO config');
|
|
510
|
+
document.getElementById('seo-updated-at').textContent = data?.config?.updatedAt ? `Updated: ${new Date(data.config.updatedAt).toLocaleString()}` : '';
|
|
511
|
+
} catch (e) {
|
|
512
|
+
status.textContent = e.message || 'Failed to save';
|
|
513
|
+
showToast(status.textContent, 'error');
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
(() => {
|
|
518
|
+
const robots = document.getElementById('seo-ai-robots-noindex');
|
|
519
|
+
const existingRoute = document.getElementById('seo-ai-existing-route');
|
|
520
|
+
if (!robots) return;
|
|
521
|
+
|
|
522
|
+
robots.addEventListener('change', () => {
|
|
523
|
+
const routePath = String(existingRoute?.value || seoAiProposed?.routePath || '').trim();
|
|
524
|
+
|
|
525
|
+
if (seoAiProposed?.source === 'improve' && seoAiProposed?.routePath === routePath && seoAiProposed?.entry) {
|
|
526
|
+
seoAiProposed.entry = seoAiNormalizeRobotsForImprove({ ...(seoAiProposed.entry || {}) });
|
|
527
|
+
const pre = document.getElementById('seo-ai-proposed');
|
|
528
|
+
pre.textContent = JSON.stringify({ routePath: seoAiProposed.routePath, entry: seoAiProposed.entry, model: seoAiProposed.model }, null, 2);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
seoAiTryPatchJsonTextareaRobots(routePath);
|
|
532
|
+
});
|
|
533
|
+
})();
|
|
534
|
+
|
|
535
|
+
async function saveOgSvg() {
|
|
536
|
+
const status = document.getElementById('og-svg-status');
|
|
537
|
+
status.textContent = 'Saving...';
|
|
538
|
+
try {
|
|
539
|
+
const svgRaw = document.getElementById('og-svg-raw').value;
|
|
540
|
+
const res = await fetch(`${API_BASE}/api/admin/seo-config/og/svg`, {
|
|
541
|
+
method: 'PUT',
|
|
542
|
+
headers: { 'Content-Type': 'application/json' },
|
|
543
|
+
body: JSON.stringify({ svgRaw }),
|
|
544
|
+
});
|
|
545
|
+
const data = await res.json().catch(() => ({}));
|
|
546
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to save');
|
|
547
|
+
status.textContent = 'Saved';
|
|
548
|
+
showToast('Saved OG SVG');
|
|
549
|
+
} catch (e) {
|
|
550
|
+
status.textContent = e.message || 'Failed to save';
|
|
551
|
+
showToast(status.textContent, 'error');
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function generatePng() {
|
|
556
|
+
const status = document.getElementById('og-png-status');
|
|
557
|
+
status.textContent = 'Generating...';
|
|
558
|
+
try {
|
|
559
|
+
const svgRaw = document.getElementById('og-svg-raw').value;
|
|
560
|
+
const outputPath = document.getElementById('og-png-output').value;
|
|
561
|
+
|
|
562
|
+
const res = await fetch(`${API_BASE}/api/admin/seo-config/og/generate-png`, {
|
|
563
|
+
method: 'POST',
|
|
564
|
+
headers: { 'Content-Type': 'application/json' },
|
|
565
|
+
body: JSON.stringify({ svgRaw, outputPath }),
|
|
566
|
+
});
|
|
567
|
+
const data = await res.json().catch(() => ({}));
|
|
568
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to generate');
|
|
569
|
+
|
|
570
|
+
const tool = data?.result?.tool ? ` (${data.result.tool})` : '';
|
|
571
|
+
status.textContent = `Generated${tool}`;
|
|
572
|
+
showToast('Generated PNG');
|
|
573
|
+
|
|
574
|
+
await reloadOgPng(data?.result?.publicUrlPath);
|
|
575
|
+
} catch (e) {
|
|
576
|
+
status.textContent = e.message || 'Failed to generate';
|
|
577
|
+
showToast(status.textContent, 'error');
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function reloadOgPng(forcedPublicUrlPath) {
|
|
582
|
+
const outputPath = document.getElementById('og-png-output').value;
|
|
583
|
+
const publicPath = forcedPublicUrlPath || ('/' + String(outputPath || '').replace(/^public\//, ''));
|
|
584
|
+
|
|
585
|
+
const img = document.getElementById('png-preview');
|
|
586
|
+
const empty = document.getElementById('png-preview-empty');
|
|
587
|
+
|
|
588
|
+
const url = publicPath.includes('?')
|
|
589
|
+
? publicPath + '&t=' + Date.now()
|
|
590
|
+
: publicPath + '?t=' + Date.now();
|
|
591
|
+
|
|
592
|
+
img.onload = () => {
|
|
593
|
+
img.classList.remove('hidden');
|
|
594
|
+
empty.classList.add('hidden');
|
|
595
|
+
};
|
|
596
|
+
img.onerror = () => {
|
|
597
|
+
img.classList.add('hidden');
|
|
598
|
+
empty.classList.remove('hidden');
|
|
599
|
+
empty.textContent = 'Not available';
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
img.src = url;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function copySnippet() {
|
|
606
|
+
try {
|
|
607
|
+
const text = document.getElementById('dev-snippet').textContent;
|
|
608
|
+
await navigator.clipboard.writeText(text);
|
|
609
|
+
showToast('Copied');
|
|
610
|
+
} catch (e) {
|
|
611
|
+
showToast('Failed to copy', 'error');
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function openAiModal() {
|
|
616
|
+
document.getElementById('ai-status').textContent = '';
|
|
617
|
+
document.getElementById('ai-instruction').value = '';
|
|
618
|
+
document.getElementById('ai-model').value = '';
|
|
619
|
+
document.getElementById('ai-modal').classList.remove('hidden');
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function closeAiModal() {
|
|
623
|
+
document.getElementById('ai-modal').classList.add('hidden');
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function runAiEdit() {
|
|
627
|
+
const status = document.getElementById('ai-status');
|
|
628
|
+
const btn = document.getElementById('ai-run-btn');
|
|
629
|
+
status.textContent = 'Running...';
|
|
630
|
+
btn.disabled = true;
|
|
631
|
+
btn.classList.add('opacity-50');
|
|
632
|
+
|
|
633
|
+
try {
|
|
634
|
+
const svgRaw = document.getElementById('og-svg-raw').value;
|
|
635
|
+
const instruction = document.getElementById('ai-instruction').value;
|
|
636
|
+
const model = document.getElementById('ai-model').value;
|
|
637
|
+
|
|
638
|
+
const res = await fetch(`${API_BASE}/api/admin/seo-config/ai/edit-svg`, {
|
|
639
|
+
method: 'POST',
|
|
640
|
+
headers: { 'Content-Type': 'application/json' },
|
|
641
|
+
body: JSON.stringify({ svgRaw, instruction, model: model || undefined }),
|
|
642
|
+
});
|
|
643
|
+
const data = await res.json().catch(() => ({}));
|
|
644
|
+
if (!res.ok) throw new Error(data?.error || 'AI failed');
|
|
645
|
+
|
|
646
|
+
document.getElementById('og-svg-raw').value = String(data?.svgRaw || '');
|
|
647
|
+
setSvgPreview(String(data?.svgRaw || ''));
|
|
648
|
+
status.textContent = `Done (model: ${data?.model || 'unknown'})`;
|
|
649
|
+
showToast('AI updated SVG');
|
|
650
|
+
} catch (e) {
|
|
651
|
+
status.textContent = e.message || 'AI failed';
|
|
652
|
+
showToast(status.textContent, 'error');
|
|
653
|
+
} finally {
|
|
654
|
+
btn.disabled = false;
|
|
655
|
+
btn.classList.remove('opacity-50');
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
document.getElementById('og-svg-raw').addEventListener('input', debouncedPreview);
|
|
660
|
+
|
|
661
|
+
buildSnippet();
|
|
662
|
+
loadSeoConfig();
|
|
663
|
+
</script>
|
|
664
|
+
<script>
|
|
665
|
+
window.addEventListener("keydown", (e) => {
|
|
666
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
|
667
|
+
e.preventDefault();
|
|
668
|
+
window.parent.postMessage({ type: "keydown", ctrlK: true }, "*");
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
</script>
|
|
672
|
+
</body>
|
|
673
|
+
</html>
|