@acms-subir/core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/cli/common.mjs +35 -0
  3. package/cli/legacy.mjs +317 -0
  4. package/cli/main.mjs +66 -0
  5. package/cli/palette.mjs +4 -0
  6. package/cli/preview.mjs +50 -0
  7. package/cli/remote.mjs +55 -0
  8. package/cli/themes.mjs +127 -0
  9. package/cli.mjs +2 -369
  10. package/deploy/Caddyfile +8 -0
  11. package/deploy/Dockerfile +34 -0
  12. package/deploy/compose.https.yml +21 -0
  13. package/deploy/compose.yml +46 -0
  14. package/docs/theme-authoring.md +103 -0
  15. package/email-runner.mjs +32 -0
  16. package/integration.mjs +182 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +27 -5
  19. package/src/actions/index.ts +575 -47
  20. package/src/components/AiCleaner.tsx +291 -0
  21. package/src/components/AiSettings.tsx +575 -0
  22. package/src/components/CommandPalette.tsx +200 -0
  23. package/src/components/ContentList.astro +246 -0
  24. package/src/components/DesignAccess.tsx +182 -0
  25. package/src/components/DesignManager.tsx +418 -46
  26. package/src/components/EmailSettings.tsx +521 -0
  27. package/src/components/EventPage.astro +178 -0
  28. package/src/components/LegalPage.astro +27 -0
  29. package/src/components/LinksManager.tsx +114 -57
  30. package/src/components/NavManager.tsx +42 -43
  31. package/src/components/ThemeScript.astro +50 -0
  32. package/src/components/ThemeSections.astro +57 -0
  33. package/src/components/ThemeSectionsEditor.tsx +513 -0
  34. package/src/components/booking/BookingFlow.astro +150 -0
  35. package/src/components/booking/HoursEditor.astro +63 -0
  36. package/src/components/builder/PageBuilder.tsx +10 -2
  37. package/src/components/editor/EventPanel.tsx +401 -0
  38. package/src/components/editor/FormatDock.tsx +81 -0
  39. package/src/components/editor/LivePane.tsx +122 -0
  40. package/src/components/editor/MobileToolbar.tsx +93 -0
  41. package/src/components/editor/PostEditor.tsx +605 -223
  42. package/src/components/editor/PreviewOverlay.tsx +242 -43
  43. package/src/components/editor/extensions/calendar.tsx +51 -7
  44. package/src/components/editor/extensions/events.tsx +153 -0
  45. package/src/components/editor/extensions/slash.tsx +7 -1
  46. package/src/components/site/Pager.astro +24 -0
  47. package/src/components/site/PostCard.astro +64 -0
  48. package/src/components/site/TagLabel.astro +11 -0
  49. package/src/layouts/Admin.astro +289 -49
  50. package/src/layouts/Site.astro +324 -59
  51. package/src/lib/agent-api.ts +13 -0
  52. package/src/lib/agent-discovery.ts +40 -0
  53. package/src/lib/agent.ts +114 -0
  54. package/src/lib/ai-config.ts +58 -0
  55. package/src/lib/ai-schema.ts +22 -0
  56. package/src/lib/ai.ts +90 -0
  57. package/src/lib/api-keys.ts +65 -0
  58. package/src/lib/api.ts +52 -0
  59. package/src/lib/auth.ts +25 -5
  60. package/src/lib/backup/content.ts +36 -0
  61. package/src/lib/backup/unzip.ts +12 -1
  62. package/src/lib/backup/zip.ts +1 -1
  63. package/src/lib/booking/agent.ts +108 -0
  64. package/src/lib/booking/availability.ts +221 -0
  65. package/src/lib/booking/emails.ts +115 -0
  66. package/src/lib/booking/forms.ts +77 -0
  67. package/src/lib/booking/service.ts +277 -0
  68. package/src/lib/booking/view.ts +54 -0
  69. package/src/lib/calendar/agent-schema.ts +48 -0
  70. package/src/lib/calendar/agent.ts +136 -0
  71. package/src/lib/calendar/api.ts +81 -0
  72. package/src/lib/calendar/cards.ts +102 -0
  73. package/src/lib/calendar/config.ts +52 -0
  74. package/src/lib/calendar/data.ts +130 -0
  75. package/src/lib/calendar/events.ts +261 -0
  76. package/src/lib/calendar/feeds.ts +43 -0
  77. package/src/lib/calendar/hooks.ts +38 -0
  78. package/src/lib/calendar/http.ts +30 -0
  79. package/src/lib/calendar/ics-write.ts +120 -0
  80. package/src/lib/calendar/ics.ts +15 -5
  81. package/src/lib/calendar/import.ts +203 -0
  82. package/src/lib/calendar/render.ts +120 -37
  83. package/src/lib/calendar/rrule.ts +149 -0
  84. package/src/lib/calendar/seo.ts +48 -0
  85. package/src/lib/design-service.ts +58 -0
  86. package/src/lib/designDiff.ts +145 -0
  87. package/src/lib/designPreview.ts +110 -12
  88. package/src/lib/documents/cleanup.ts +31 -0
  89. package/src/lib/documents/extract.ts +154 -0
  90. package/src/lib/documents/vendor.d.ts +4 -0
  91. package/src/lib/drafts.ts +60 -6
  92. package/src/lib/dynamicBlocks.ts +40 -8
  93. package/src/lib/email/accounts.ts +55 -0
  94. package/src/lib/email/config.ts +86 -0
  95. package/src/lib/email/migration.ts +24 -0
  96. package/src/lib/email/providers.ts +89 -0
  97. package/src/lib/email/service.ts +78 -0
  98. package/src/lib/email/store.ts +167 -0
  99. package/src/lib/email/templates.ts +18 -0
  100. package/src/lib/email/types.ts +32 -0
  101. package/src/lib/email/webhooks.ts +87 -0
  102. package/src/lib/email.ts +15 -0
  103. package/src/lib/env.ts +1 -1
  104. package/src/lib/legal/index.ts +25 -0
  105. package/src/lib/mobile/bookings.ts +73 -0
  106. package/src/lib/mobile/calendar.ts +85 -0
  107. package/src/lib/mobile/content.ts +85 -0
  108. package/src/lib/mobile/http.ts +63 -0
  109. package/src/lib/mobile/route.ts +20 -0
  110. package/src/lib/ops.ts +8 -0
  111. package/src/lib/preview.ts +21 -1
  112. package/src/lib/repo/migrations.ts +217 -0
  113. package/src/lib/repo/sql.ts +853 -60
  114. package/src/lib/repo/types.ts +122 -5
  115. package/src/lib/scheduler.ts +25 -0
  116. package/src/lib/schema.ts +309 -0
  117. package/src/lib/search-i18n.ts +3 -0
  118. package/src/lib/socialIcons.ts +32 -0
  119. package/src/lib/tagColor.ts +11 -0
  120. package/src/lib/theme.ts +59 -40
  121. package/src/lib/themeTokens.ts +174 -0
  122. package/src/lib/umami.ts +108 -0
  123. package/src/lib/webhooks.ts +139 -0
  124. package/src/middleware.ts +36 -3
  125. package/src/pages/404.astro +23 -0
  126. package/src/pages/[...slug].astro +8 -5
  127. package/src/pages/account/[action].astro +53 -0
  128. package/src/pages/admin/ai.astro +12 -0
  129. package/src/pages/admin/c/[collection]/[id].astro +4 -1
  130. package/src/pages/admin/c/[collection]/index.astro +127 -92
  131. package/src/pages/admin/calendar/bookings.astro +400 -0
  132. package/src/pages/admin/calendar/calendars.astro +265 -0
  133. package/src/pages/admin/calendar/events/[id].astro +56 -0
  134. package/src/pages/admin/calendar/events/index.astro +408 -0
  135. package/src/pages/admin/calendar/index.ts +4 -0
  136. package/src/pages/admin/calendar/integrations.astro +221 -0
  137. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  138. package/src/pages/admin/calendar/resources/index.astro +106 -0
  139. package/src/pages/admin/calendar/resources/link.astro +196 -0
  140. package/src/pages/admin/design.astro +10 -3
  141. package/src/pages/admin/email.astro +13 -0
  142. package/src/pages/admin/index.astro +232 -85
  143. package/src/pages/admin/links.astro +3 -3
  144. package/src/pages/admin/login.astro +14 -10
  145. package/src/pages/admin/navigation.astro +8 -2
  146. package/src/pages/admin/pages/[id].astro +4 -1
  147. package/src/pages/admin/pages/index.astro +41 -48
  148. package/src/pages/admin/settings.astro +418 -367
  149. package/src/pages/admin/tags.astro +4 -4
  150. package/src/pages/admin/users.astro +38 -28
  151. package/src/pages/api/agent/docs.ts +33 -0
  152. package/src/pages/api/agent/index.ts +21 -0
  153. package/src/pages/api/ai/clean.ts +13 -0
  154. package/src/pages/api/ai/draft.ts +14 -0
  155. package/src/pages/api/ai/settings.ts +53 -0
  156. package/src/pages/api/ai/test.ts +12 -0
  157. package/src/pages/api/backup.ts +8 -21
  158. package/src/pages/api/calendar/availability.ts +35 -0
  159. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  160. package/src/pages/api/calendar/bookings/index.ts +73 -0
  161. package/src/pages/api/calendar/calendars.ts +35 -0
  162. package/src/pages/api/calendar/docs.ts +64 -0
  163. package/src/pages/api/calendar/events/[id].ts +63 -0
  164. package/src/pages/api/calendar/events/index.ts +112 -0
  165. package/src/pages/api/calendar/resources.ts +34 -0
  166. package/src/pages/api/design/capabilities.ts +10 -0
  167. package/src/pages/api/design/index.ts +58 -0
  168. package/src/pages/api/design/keys.ts +24 -0
  169. package/src/pages/api/design/media.ts +32 -0
  170. package/src/pages/api/email/run.ts +11 -0
  171. package/src/pages/api/email/settings.ts +61 -0
  172. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  173. package/src/pages/api/mcp.ts +59 -0
  174. package/src/pages/api/media/upload.ts +2 -2
  175. package/src/pages/api/ops/status.ts +39 -0
  176. package/src/pages/api/restore.ts +27 -37
  177. package/src/pages/api/search.ts +18 -4
  178. package/src/pages/api/v1/availability.ts +7 -0
  179. package/src/pages/api/v1/bookings.ts +17 -0
  180. package/src/pages/api/v1/calendars.ts +7 -0
  181. package/src/pages/api/v1/content/[id].ts +7 -0
  182. package/src/pages/api/v1/content/index.ts +7 -0
  183. package/src/pages/api/v1/events.ts +7 -0
  184. package/src/pages/api/v1/resources.ts +7 -0
  185. package/src/pages/author/[slug].astro +43 -35
  186. package/src/pages/blog/[slug].astro +137 -66
  187. package/src/pages/blog/index.astro +95 -30
  188. package/src/pages/blog/tag/[slug].astro +45 -40
  189. package/src/pages/book/[resource]/[link].astro +70 -0
  190. package/src/pages/book/[resource]/index.astro +63 -0
  191. package/src/pages/book/[resource].ics.ts +38 -0
  192. package/src/pages/book/index.astro +47 -0
  193. package/src/pages/book/manage/[token].astro +108 -0
  194. package/src/pages/calendar/[slug].ics.ts +27 -0
  195. package/src/pages/calendar/feed.ics.ts +15 -0
  196. package/src/pages/calendar/index.astro +191 -0
  197. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  198. package/src/pages/embed/calendar/[slug].astro +43 -0
  199. package/src/pages/events/[slug].astro +74 -0
  200. package/src/pages/events/[slug].ics.ts +25 -0
  201. package/src/pages/index.astro +6 -3
  202. package/src/pages/legal/privacy.astro +4 -0
  203. package/src/pages/legal/terms.astro +4 -0
  204. package/src/pages/links.astro +23 -3
  205. package/src/pages/llms.txt.ts +4 -0
  206. package/src/pages/preview/[type]/[id].astro +27 -6
  207. package/src/pages/search.astro +40 -20
  208. package/src/pages/sitemap.xml.ts +16 -1
  209. package/src/styles/admin.css +924 -105
  210. package/src/styles/cards.css +92 -74
  211. package/src/styles/editor.css +492 -114
  212. package/src/styles/email-settings.css +117 -0
  213. package/src/styles/sections.css +12 -0
  214. package/src/styles/site.css +938 -281
  215. package/src/styles/theme-editor.css +135 -0
  216. package/src/theme-sdk/contract.d.mts +619 -0
  217. package/src/theme-sdk/contract.mjs +80 -0
  218. package/src/theme-sdk/index.ts +15 -0
  219. package/src/theme-sdk/tokens.d.mts +148 -0
  220. package/src/theme-sdk/tokens.mjs +52 -0
  221. package/src/themes/editorial/templates/home.astro +4 -4
  222. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  223. package/src/themes/editorial/theme.css +22 -37
  224. package/src/themes/editorial/theme.json +23 -3
  225. package/src/themes/field/theme.json +26 -9
  226. package/src/themes/ink/theme.json +30 -12
  227. package/src/themes/night/theme.css +4 -7
  228. package/src/themes/night/theme.json +27 -10
  229. package/src/virtual.d.ts +14 -0
  230. package/src/worker.ts +5 -1
@@ -1,14 +1,15 @@
1
1
  ---
2
2
  import Admin from '../../layouts/Admin.astro';
3
+ import AiSettings from '../../components/AiSettings';
3
4
  import { getRepo } from '../../lib/repo';
4
- import { verifySessionToken, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
5
+ import { validateSession, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
5
6
  import { invalidateRedirectCache } from '../../middleware';
6
7
  import { CONTENT } from '../../lib/content';
7
8
  import { UMAMI_ID_RE, META_PIXEL_ID_RE, TIKTOK_PIXEL_ID_RE, normalizeScriptUrl } from '../../lib/analytics';
8
9
 
9
10
  const repo = await getRepo(Astro.locals);
10
11
 
11
- const sessionUserId = verifySessionToken(Astro.cookies.get(SESSION_COOKIE)?.value);
12
+ const sessionUserId = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
12
13
  const sessionUser = sessionUserId && sessionUserId !== OWNER_ENV ? await repo.getUser(sessionUserId) : null;
13
14
  const isOwner = sessionUserId === OWNER_ENV || sessionUser?.role === 'owner';
14
15
 
@@ -32,9 +33,11 @@ function normalizeOrigin(value: string): string | null {
32
33
 
33
34
  let flash = '';
34
35
  let flashError = '';
36
+ let postedAction = '';
35
37
  if (Astro.request.method === 'POST') {
36
38
  const form = await Astro.request.formData();
37
39
  const action = String(form.get('action') || 'site');
40
+ postedAction = action;
38
41
  if (action === 'site') {
39
42
  // site URL: normalize to a bare origin; empty is fine (falls back to the
40
43
  // request origin until configured)
@@ -206,410 +209,456 @@ const ORIGIN_PRESETS: { label: string; origin: string }[] = [
206
209
  { label: 'Any origin (*)', origin: '*' },
207
210
  ];
208
211
  const remainingPresets = ORIGIN_PRESETS.filter((p) => !settings.draftsAllowedOrigins.includes(p.origin));
212
+
213
+ // the card whose save failed reopens in edit mode with the flash above it
214
+ const openCard = flashError ? postedAction : '';
215
+ const groups = [
216
+ { label: 'General', items: [
217
+ { id: 'site', label: 'Title & description', terms: 'site title tagline url footer seo' },
218
+ { id: 'localization', label: 'Timezone & language', terms: 'timezone locale language date' },
219
+ { id: 'announcement', label: 'Announcement bar', terms: 'announcement banner bar' },
220
+ ] },
221
+ { label: 'Publishing', items: [
222
+ ...(isOwner ? [{ id: 'drafts-api', label: 'External drafts API', terms: 'api key drafts cors origins' }] : []),
223
+ { id: 'redirects', label: 'Redirects', terms: 'redirects 301 302 urls' },
224
+ ] },
225
+ ...(isOwner ? [{ label: 'Integrations', items: [
226
+ { id: 'email', label: 'Email', terms: 'email transactional resend mailgun amazon ses aws cloudflare invitation invite password reset booking calendar notifications reminders' },
227
+ { id: 'ai-agents', label: 'AI & agents', terms: 'ai agents openai custom kimi deepseek cli proxy cpamc tailscale mcp' },
228
+ { id: 'analytics', label: 'Analytics', terms: 'analytics umami replay meta pixel tiktok tracking' },
229
+ { id: 'code', label: 'Code injection', terms: 'code injection head footer scripts' },
230
+ ] }] : []),
231
+ { label: 'Advanced', items: [
232
+ { id: 'backup', label: 'Backup', terms: 'backup restore export zip' },
233
+ { id: 'backend', label: 'Backend', terms: 'backend database d1 sqlite' },
234
+ ] },
235
+ ];
209
236
  ---
210
237
 
211
238
  <Admin title="Settings" section="settings">
212
- <div class="page-head"><h1>Settings</h1></div>
213
-
214
- {flash && <div class="flash">{flash}</div>}
215
- {flashError && <div class="flash error">{flashError}</div>}
216
-
217
- <div class="card card-pad">
218
- <p class="card-title">Site</p>
219
- <form method="post">
220
- <input type="hidden" name="action" value="site" />
221
- <div class="field-row">
222
- <label class="field"><span>Site title</span><input type="text" name="siteTitle" value={settings.siteTitle} /></label>
223
- <label class="field"><span>Tagline</span><input type="text" name="tagline" value={settings.tagline} /></label>
239
+ <div class="settings">
240
+ <nav class="set-index" aria-label="Settings sections">
241
+ <div class="set-search">
242
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" /></svg>
243
+ <input type="search" placeholder="Search settings" id="set-q" aria-label="Search settings" />
224
244
  </div>
225
- <label class="field">
226
- <span>Site URL</span>
227
- <input type="text" name="siteUrl" value={settings.siteUrl} placeholder="https://example.com" />
228
- </label>
229
- <p class="hint">
230
- The public address of this site. Used to build canonical URLs, the sitemap, the RSS feed,
231
- and social-share previews. Leave empty to use whatever address the site is served from.
232
- </p>
233
- <label class="field">
234
- <span>Footer</span>
235
- <textarea name="footerText" rows="3" placeholder={`${settings.siteTitle}${settings.tagline ? ` — ${settings.tagline}` : ''}`}>{settings.footerText}</textarea>
236
- </label>
237
- <p class="hint">
238
- Shown at the bottom of every public page. Markdown works — e.g.
239
- <code>© 2026 Your Site · [Twitter](https://twitter.com/…)</code>. Leave empty to fall back to
240
- “title — tagline”.
241
- </p>
242
- <div class="form-actions"><button class="btn primary" type="submit">Save</button></div>
243
- </form>
244
- </div>
245
+ {groups.map((g) => (
246
+ <div class="set-group">
247
+ <div class="set-group-title">{g.label}</div>
248
+ {g.items.map((it) => (
249
+ <a href={`#${it.id}`} data-spy={it.id} data-terms={it.terms}>
250
+ <span>{it.label}</span>
251
+ <svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6" /></svg>
252
+ </a>
253
+ ))}
254
+ </div>
255
+ ))}
256
+ </nav>
245
257
 
246
- {isOwner && (
247
- <div class="card card-pad">
248
- <p class="card-title">External drafts API</p>
249
- <p style="margin:0">
250
- Lets outside apps push drafts to <code>POST /api/drafts</code> — they land in Posts as drafts
251
- for you to refine and publish. The key can only create drafts, never publish, edit, or delete.
252
- </p>
258
+ <div class="set-cards">
259
+ <div class="page-head set-head"><div><h1>Settings</h1><p class="sub">Site-wide configuration. Each card saves on its own.</p></div></div>
260
+ {flash && <div class="flash">{flash}</div>}
261
+ {flashError && <div class="flash error">{flashError}</div>}
253
262
 
254
- {apiKey ? (
255
- <div class="da-key-row">
256
- <code class="da-key" title="API key">{maskedKey}</code>
257
- <button class="btn" type="button" id="da-copy" data-key={apiKey}>Copy</button>
258
- <form method="post">
259
- <input type="hidden" name="action" value="generateKey" />
260
- <button class="btn" type="submit">Regenerate</button>
261
- </form>
262
- <form method="post">
263
- <input type="hidden" name="action" value="revokeKey" />
264
- <button class="btn danger" type="submit">Revoke</button>
265
- </form>
266
- </div>
267
- ) : (
268
- <div class="da-empty">
269
- <span class="hint">No API key yet — external apps can’t reach <code>/api/drafts</code>.</span>
270
- <form method="post" style="margin:0">
271
- <input type="hidden" name="action" value="generateKey" />
272
- <button class="btn primary" type="submit">Generate key</button>
273
- </form>
274
- </div>
275
- )}
276
- {apiKey && (
277
- <p class="hint" style="margin-top:8px">
278
- Send it as <code>Authorization: Bearer &lt;key&gt;</code>. Regenerating or revoking takes
279
- effect immediately.
280
- </p>
281
- )}
263
+ <div class="set-group-title">General</div>
282
264
 
283
- <div class="da-origins">
284
- <p class="da-subtitle">Allowed origins <span class="hint">(CORS)</span></p>
285
- <p class="hint" style="margin:0 0 10px">
286
- Browser apps on these URLs can call the API. Tools like curl or scripts are never blocked
287
- by this list. Empty list = no browser app gets through.
288
- </p>
289
- {settings.draftsAllowedOrigins.length > 0 && (
290
- <div class="da-chips">
291
- {settings.draftsAllowedOrigins.map((origin) => (
292
- <form method="post" class="da-chip">
293
- <input type="hidden" name="action" value="removeOrigin" />
294
- <input type="hidden" name="origin" value={origin} />
295
- <code>{origin}</code>
296
- <button class="da-chip-x" type="submit" title={`Remove ${origin}`}>✕</button>
297
- </form>
298
- ))}
265
+ <section class={`scard ${openCard === 'site' ? 'editing' : ''}`} id="site" data-editable>
266
+ <header class="scard-head">
267
+ <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
268
+ <div class="grow"><h2>Title &amp; description</h2><p>How your publication appears around the web.</p></div>
269
+ <div class="scard-actions">
270
+ <button class="btn" type="button" data-edit>Edit</button>
271
+ <button class="btn ghost" type="button" data-cancel>Cancel</button>
272
+ <button class="btn primary" type="submit" form="form-site">Save</button>
299
273
  </div>
300
- )}
301
- <form method="post" class="da-add-row">
302
- <input type="hidden" name="action" value="addOrigin" />
303
- <input type="text" name="origin" placeholder="https://another-app.example.com" />
304
- <button class="btn" type="submit">Add</button>
305
- </form>
306
- {remainingPresets.length > 0 && (
307
- <div class="da-presets">
308
- <span class="hint">Quick add:</span>
309
- {remainingPresets.map((p) => (
310
- <form method="post" style="margin:0">
311
- <input type="hidden" name="action" value="addOrigin" />
312
- <input type="hidden" name="origin" value={p.origin} />
313
- <button class="da-preset" type="submit">+ {p.label}</button>
314
- </form>
315
- ))}
274
+ </header>
275
+ <dl class="scard-view">
276
+ <div><dt>Site title</dt><dd>{settings.siteTitle || '—'}</dd></div>
277
+ <div><dt>Tagline</dt><dd>{settings.tagline || '—'}</dd></div>
278
+ <div><dt>Site URL</dt><dd>{settings.siteUrl || <span class="hint">Not set — uses the serving address</span>}</dd></div>
279
+ <div><dt>Footer</dt><dd>{settings.footerText || <span class="hint">Title — tagline</span>}</dd></div>
280
+ </dl>
281
+ <form class="scard-form" id="form-site" method="post" action="/admin/settings#site">
282
+ <input type="hidden" name="action" value="site" />
283
+ <div class="field-row">
284
+ <label class="field"><span>Site title</span><input type="text" name="siteTitle" value={settings.siteTitle} /></label>
285
+ <label class="field"><span>Tagline</span><input type="text" name="tagline" value={settings.tagline} /></label>
316
286
  </div>
317
- )}
318
- </div>
319
- </div>
320
- )}
321
-
322
- <div class="card card-pad">
323
- <p class="card-title">Timezone &amp; language</p>
324
- <form method="post">
325
- <input type="hidden" name="action" value="localization" />
326
- <div class="field-row">
327
- <label class="field">
328
- <span>Publication timezone</span>
329
- <input type="text" name="timezone" value={settings.timezone} placeholder="America/New_York" list="tz-suggestions" />
330
- </label>
331
- <label class="field">
332
- <span>Locale</span>
333
- <input type="text" name="locale" value={settings.locale} placeholder="en-US" />
334
- </label>
335
- </div>
336
- <datalist id="tz-suggestions">
337
- {['UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/Guatemala', 'America/Mexico_City', 'Europe/London', 'Europe/Madrid', 'Asia/Tokyo'].map((tz) => <option value={tz} />)}
338
- </datalist>
339
- <p class="hint">
340
- Dates on the public site render in this timezone and locale (e.g. <code>es-GT</code> for Spanish).
341
- Empty = server timezone and English.
342
- </p>
343
- <div class="form-actions"><button class="btn primary" type="submit">Save</button></div>
344
- </form>
345
- </div>
346
-
347
- {isOwner && (
348
- <div class="card card-pad">
349
- <p class="card-title">Code injection</p>
350
- <p style="margin:0">
351
- Raw HTML added to every public page — analytics snippets, verification tags, custom fonts.
352
- Scripts here run with full access to your pages, so paste only code you trust.
353
- </p>
354
- <form method="post">
355
- <input type="hidden" name="action" value="codeInjection" />
356
- <label class="field" style="margin-top:12px">
357
- <span>Site header <span class="hint">(inside <code>&lt;head&gt;</code>)</span></span>
358
- <textarea name="headCode" rows="4" placeholder={'<script defer data-domain="example.com" src="…"></script>'} style="font-family:monospace;font-size:12.5px">{settings.headCode}</textarea>
359
- </label>
360
- <label class="field">
361
- <span>Site footer <span class="hint">(before <code>&lt;/body&gt;</code>)</span></span>
362
- <textarea name="footerCode" rows="4" style="font-family:monospace;font-size:12.5px">{settings.footerCode}</textarea>
363
- </label>
364
- <div class="form-actions"><button class="btn primary" type="submit">Save</button></div>
365
- </form>
366
- </div>
367
- )}
368
-
369
- {isOwner && (
370
- <div class="card card-pad">
371
- <p class="card-title">Analytics</p>
372
- <p style="margin:0">
373
- Traffic and ad tracking on every public page. Only the ID is stored — the tracking code itself
374
- is rendered by the CMS. The admin is never tracked. Mind visitor-consent rules (GDPR) if you
375
- enable the ad pixels.
376
- </p>
377
- <form method="post">
378
- <input type="hidden" name="action" value="analytics" />
379
-
380
- <div style="border-top:1px solid var(--line);margin-top:14px;padding-top:12px">
381
- <label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
382
- <input type="checkbox" name="umamiEnabled" checked={settings.analytics.umami.enabled} />
383
- <span style="font-weight:600">Umami</span>
287
+ <label class="field">
288
+ <span>Site URL</span>
289
+ <input type="text" name="siteUrl" value={settings.siteUrl} placeholder="https://example.com" inputmode="url" />
290
+ <small class="hint">Builds canonical URLs, the sitemap, RSS and social previews. Empty = whatever address the site is served from.</small>
291
+ </label>
292
+ <label class="field">
293
+ <span>Footer</span>
294
+ <textarea name="footerText" rows="3" placeholder={`${settings.siteTitle}${settings.tagline ? ` — ${settings.tagline}` : ''}`}>{settings.footerText}</textarea>
295
+ <small class="hint">Markdown works — e.g. <code>© 2026 Your Site · [Twitter](https://twitter.com/…)</code>. Empty = “title — tagline”.</small>
384
296
  </label>
297
+ <div class="serp-preview" aria-hidden="true">
298
+ <small>Search preview</small>
299
+ <span class="serp-url">{(settings.siteUrl || 'example.com').replace(/^https?:\/\//, '')}</span>
300
+ <span class="serp-title">{settings.siteTitle || 'My Site'}</span>
301
+ <span class="serp-desc">{settings.tagline || 'Your tagline appears here.'}</span>
302
+ </div>
303
+ </form>
304
+ </section>
305
+
306
+ <section class={`scard ${openCard === 'localization' ? 'editing' : ''}`} id="localization" data-editable>
307
+ <header class="scard-head">
308
+ <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
309
+ <div class="grow"><h2>Timezone &amp; language</h2><p>Used for scheduling and how dates render on the site.</p></div>
310
+ <div class="scard-actions">
311
+ <button class="btn" type="button" data-edit>Edit</button>
312
+ <button class="btn ghost" type="button" data-cancel>Cancel</button>
313
+ <button class="btn primary" type="submit" form="form-localization">Save</button>
314
+ </div>
315
+ </header>
316
+ <dl class="scard-view">
317
+ <div><dt>Timezone</dt><dd>{settings.timezone || <span class="hint">Server timezone</span>}</dd></div>
318
+ <div><dt>Locale</dt><dd>{settings.locale || <span class="hint">English (en-US)</span>}</dd></div>
319
+ </dl>
320
+ <form class="scard-form" id="form-localization" method="post" action="/admin/settings#localization">
321
+ <input type="hidden" name="action" value="localization" />
385
322
  <div class="field-row">
386
323
  <label class="field">
387
- <span>Website ID</span>
388
- <input type="text" name="umamiWebsiteId" value={settings.analytics.umami.websiteId} placeholder="94db1cb1-74f4-4a40-ad6c-962362670409" style="font-family:monospace;font-size:12.5px" />
324
+ <span>Publication timezone</span>
325
+ <input type="text" name="timezone" value={settings.timezone} placeholder="America/New_York" list="tz-suggestions" />
389
326
  </label>
390
327
  <label class="field">
391
- <span>Script URL</span>
392
- <input type="text" name="umamiScriptUrl" value={settings.analytics.umami.scriptUrl} placeholder="https://cloud.umami.is/script.js" style="font-family:monospace;font-size:12.5px" />
328
+ <span>Locale</span>
329
+ <input type="text" name="locale" value={settings.locale} placeholder="en-US" />
393
330
  </label>
394
331
  </div>
395
- <p class="hint" style="margin:0">
396
- Privacy-friendly stats: pageviews, referrers, devices, countries, visit duration.
397
- Umami Cloud by default — point Script URL at your own instance if self-hosting.
398
- </p>
399
- <div style="margin:10px 0 0 24px;padding:12px 14px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px">
400
- <label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
401
- <input type="checkbox" name="umamiReplayEnabled" checked={settings.analytics.umami.replay.enabled} />
402
- <span style="font-weight:600">Session replay</span>
403
- </label>
404
- <div class="field-row">
405
- <label class="field">
406
- <span>Sample rate (%)</span>
407
- <input type="number" name="umamiReplaySampleRate" min="1" max="100" step="1" value={Math.round(settings.analytics.umami.replay.sampleRate * 100)} />
408
- </label>
409
- <label class="field">
410
- <span>Mask level</span>
411
- <select name="umamiReplayMaskLevel">
412
- <option value="moderate" selected={settings.analytics.umami.replay.maskLevel === 'moderate'}>moderate</option>
413
- <option value="strict" selected={settings.analytics.umami.replay.maskLevel === 'strict'}>strict</option>
414
- </select>
415
- </label>
416
- <label class="field">
417
- <span>Max duration</span>
418
- <select name="umamiReplayMaxDuration">
419
- <option value="60000" selected={settings.analytics.umami.replay.maxDuration === 60000}>1 minute</option>
420
- <option value="300000" selected={settings.analytics.umami.replay.maxDuration === 300000}>5 minutes</option>
421
- <option value="600000" selected={settings.analytics.umami.replay.maxDuration === 600000}>10 minutes</option>
422
- <option value="1800000" selected={settings.analytics.umami.replay.maxDuration === 1800000}>30 minutes</option>
423
- </select>
424
- </label>
332
+ <datalist id="tz-suggestions">
333
+ {['UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/Guatemala', 'America/Mexico_City', 'Europe/London', 'Europe/Madrid', 'Asia/Tokyo'].map((tz) => <option value={tz} />)}
334
+ </datalist>
335
+ <p class="hint">Dates on the public site render in this timezone and locale (e.g. <code>es-GT</code> for Spanish). Empty = server timezone and English.</p>
336
+ </form>
337
+ </section>
338
+
339
+ <section class={`scard ${openCard === 'announcement' ? 'editing' : ''}`} id="announcement" data-editable>
340
+ <header class="scard-head">
341
+ <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
342
+ <div class="grow"><h2>Announcement bar</h2><p>A bar above the site header for news worth shouting about.</p></div>
343
+ <div class="scard-actions">
344
+ <button class="btn" type="button" data-edit>Edit</button>
345
+ <button class="btn ghost" type="button" data-cancel>Cancel</button>
346
+ <button class="btn primary" type="submit" form="form-announcement">Save</button>
347
+ </div>
348
+ </header>
349
+ <dl class="scard-view">
350
+ <div><dt>Status</dt><dd>{settings.announcement.enabled ? <span class="status published">● Live</span> : <span class="status archived">Off</span>}</dd></div>
351
+ <div><dt>Text</dt><dd>{settings.announcement.text || '—'}</dd></div>
352
+ {settings.announcement.link && <div><dt>Link</dt><dd>{settings.announcement.link}</dd></div>}
353
+ <div><dt>Dismissible</dt><dd>{settings.announcement.dismissible ? 'Yes' : 'No'}</dd></div>
354
+ </dl>
355
+ <form class="scard-form" id="form-announcement" method="post" action="/admin/settings#announcement">
356
+ <input type="hidden" name="action" value="announcement" />
357
+ <label class="check-row"><input type="checkbox" name="annEnabled" checked={settings.announcement.enabled} /><span>Show a bar above the site header</span></label>
358
+ <div class="field-row">
359
+ <label class="field"><span>Text</span><input type="text" name="annText" value={settings.announcement.text} placeholder="🎉 Something worth announcing" /></label>
360
+ <label class="field"><span>Link (optional)</span><input type="text" name="annLink" value={settings.announcement.link} placeholder={`${CONTENT.posts.base}/announcement`} /></label>
361
+ </div>
362
+ <label class="check-row"><input type="checkbox" name="annDismissible" checked={settings.announcement.dismissible} /><span>Visitors can dismiss it <span class="hint">(remembered per browser until the text changes)</span></span></label>
363
+ </form>
364
+ </section>
365
+
366
+ <div class="set-group-title">Publishing</div>
367
+
368
+ {isOwner && (
369
+ <section class="scard" id="drafts-api">
370
+ <header class="scard-head">
371
+ <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
372
+ <div class="grow"><h2>External drafts API</h2><p>Outside apps push drafts to <code>POST /api/drafts</code>. The key can only create drafts — never publish, edit, or delete.</p></div>
373
+ </header>
374
+ {apiKey ? (
375
+ <div class="da-key-row">
376
+ <code class="da-key" title="API key">{maskedKey}</code>
377
+ <button class="btn" type="button" id="da-copy" data-key={apiKey}>Copy</button>
378
+ <form method="post" action="/admin/settings#drafts-api">
379
+ <input type="hidden" name="action" value="generateKey" />
380
+ <button class="btn" type="submit">Regenerate</button>
381
+ </form>
382
+ <form method="post" action="/admin/settings#drafts-api">
383
+ <input type="hidden" name="action" value="revokeKey" />
384
+ <button class="btn danger" type="submit">Revoke</button>
385
+ </form>
386
+ </div>
387
+ ) : (
388
+ <div class="da-empty">
389
+ <span class="hint">No API key yet — external apps can’t reach <code>/api/drafts</code>.</span>
390
+ <form method="post" action="/admin/settings#drafts-api">
391
+ <input type="hidden" name="action" value="generateKey" />
392
+ <button class="btn primary" type="submit">Generate key</button>
393
+ </form>
425
394
  </div>
426
- <p class="hint" style="margin:0">
427
- Records a sample of visitor sessions for playback in Umami (v3.1+, Behavior → Replays).
428
- Umami keeps replays for 30 days. Inputs are masked — mind visitor-consent rules (GDPR) before enabling.
429
- </p>
395
+ )}
396
+ {apiKey && <p class="hint">Send it as <code>Authorization: Bearer &lt;key&gt;</code>. Regenerating or revoking takes effect immediately.</p>}
397
+ <div class="scard-sub">
398
+ <h3>Allowed origins <span class="hint">(CORS)</span></h3>
399
+ <p class="hint">Browser apps on these URLs can call the API. curl and scripts are never blocked by this list. Empty = no browser app gets through.</p>
400
+ {settings.draftsAllowedOrigins.length > 0 && (
401
+ <div class="da-chips">
402
+ {settings.draftsAllowedOrigins.map((origin) => (
403
+ <form method="post" class="da-chip" action="/admin/settings#drafts-api">
404
+ <input type="hidden" name="action" value="removeOrigin" />
405
+ <input type="hidden" name="origin" value={origin} />
406
+ <code>{origin}</code>
407
+ <button class="da-chip-x" type="submit" title={`Remove ${origin}`} aria-label={`Remove ${origin}`}>✕</button>
408
+ </form>
409
+ ))}
410
+ </div>
411
+ )}
412
+ <form method="post" class="inline-add" action="/admin/settings#drafts-api">
413
+ <input type="hidden" name="action" value="addOrigin" />
414
+ <input type="text" name="origin" placeholder="https://another-app.example.com" aria-label="Origin" />
415
+ <button class="btn" type="submit">Add</button>
416
+ </form>
417
+ {remainingPresets.length > 0 && (
418
+ <div class="da-presets">
419
+ <span class="hint">Quick add:</span>
420
+ {remainingPresets.map((p) => (
421
+ <form method="post" action="/admin/settings#drafts-api">
422
+ <input type="hidden" name="action" value="addOrigin" />
423
+ <input type="hidden" name="origin" value={p.origin} />
424
+ <button class="da-preset" type="submit">+ {p.label}</button>
425
+ </form>
426
+ ))}
427
+ </div>
428
+ )}
430
429
  </div>
431
- </div>
430
+ </section>
431
+ )}
432
432
 
433
- <div style="border-top:1px solid var(--line);margin-top:14px;padding-top:12px">
434
- <label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
435
- <input type="checkbox" name="metaEnabled" checked={settings.analytics.metaPixel.enabled} />
436
- <span style="font-weight:600">Meta Pixel</span>
437
- </label>
438
- <label class="field">
439
- <span>Pixel ID</span>
440
- <input type="text" name="metaPixelId" value={settings.analytics.metaPixel.pixelId} placeholder="724159876543210" style="font-family:monospace;font-size:12.5px;max-width:320px" />
441
- </label>
442
- <p class="hint" style="margin:0">Tracks page views for Facebook/Instagram ad attribution and retargeting audiences.</p>
443
- </div>
433
+ <section class="scard" id="redirects">
434
+ <header class="scard-head">
435
+ <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
436
+ <div class="grow"><h2>Redirects</h2><p>Forward old URLs to new ones. They only fire when a URL would otherwise 404, and renaming a published post adds one automatically.</p></div>
437
+ </header>
438
+ {redirects.length > 0 && (
439
+ <div class="rd-list">
440
+ {redirects.map((r) => (
441
+ <div class="rd-row">
442
+ <code class="rd-path">{r.fromPath}</code>
443
+ <span class="rd-arrow">→</span>
444
+ <code class="rd-path">{r.toPath}</code>
445
+ <span class="rd-code">{r.code}</span>
446
+ <form method="post" action="/admin/settings#redirects">
447
+ <input type="hidden" name="action" value="removeRedirect" />
448
+ <input type="hidden" name="id" value={r.id} />
449
+ <button class="da-chip-x" type="submit" title={`Remove redirect from ${r.fromPath}`} aria-label={`Remove redirect from ${r.fromPath}`}>✕</button>
450
+ </form>
451
+ </div>
452
+ ))}
453
+ </div>
454
+ )}
455
+ <form method="post" class="rd-add" action="/admin/settings#redirects">
456
+ <input type="hidden" name="action" value="addRedirect" />
457
+ <input type="text" name="fromPath" placeholder="/old-path" required aria-label="From path" />
458
+ <span class="rd-arrow">→</span>
459
+ <input type="text" name="toPath" placeholder="/new-path or https://elsewhere.com" required aria-label="To path" />
460
+ <select name="code" aria-label="Redirect type">
461
+ <option value="301">301 permanent</option>
462
+ <option value="302">302 temporary</option>
463
+ </select>
464
+ <button class="btn" type="submit">Add</button>
465
+ </form>
466
+ </section>
444
467
 
445
- <div style="border-top:1px solid var(--line);margin-top:14px;padding-top:12px">
446
- <label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
447
- <input type="checkbox" name="tiktokEnabled" checked={settings.analytics.tiktokPixel.enabled} />
448
- <span style="font-weight:600">TikTok Pixel <span style="display:inline-block;font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;padding:2px 8px;border-radius:999px;background:var(--amber-soft);color:var(--amber);vertical-align:1px">Experimental</span></span>
449
- </label>
450
- <label class="field">
451
- <span>Pixel ID</span>
452
- <input type="text" name="tiktokPixelId" value={settings.analytics.tiktokPixel.pixelId} placeholder="CABC123DEF456GHI789" style="font-family:monospace;font-size:12.5px;max-width:320px" />
453
- </label>
454
- <p class="hint" style="margin:0">
455
- Untested integration — the snippet follows TikTok’s published base code, but verify events
456
- in TikTok Events Manager before relying on it.
457
- </p>
458
- </div>
468
+ {isOwner && <div class="set-group-title">Integrations</div>}
459
469
 
460
- <div class="form-actions"><button class="btn primary" type="submit">Save</button></div>
461
- </form>
462
- </div>
463
- )}
470
+ {isOwner && (
471
+ <section class="scard" id="email">
472
+ <header class="scard-head">
473
+ <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
474
+ <div class="grow"><h2>Email</h2><p>Invitations, password resets, booking updates and reminders.</p></div>
475
+ <div class="scard-actions always"><a class="btn" href="/admin/email">Configure email</a></div>
476
+ </header>
477
+ <p class="hint">Choose Resend, Mailgun, Amazon SES or Cloudflare. Manage your sender, notifications and delivery activity.</p>
478
+ </section>
479
+ )}
464
480
 
465
- <div class="card card-pad">
466
- <p class="card-title">Announcement bar</p>
467
- <form method="post">
468
- <input type="hidden" name="action" value="announcement" />
469
- <label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
470
- <input type="checkbox" name="annEnabled" checked={settings.announcement.enabled} />
471
- <span>Show a bar above the site header</span>
472
- </label>
473
- <div class="field-row">
474
- <label class="field"><span>Text</span><input type="text" name="annText" value={settings.announcement.text} placeholder="🎉 Something worth announcing" /></label>
475
- <label class="field"><span>Link (optional)</span><input type="text" name="annLink" value={settings.announcement.link} placeholder={`${CONTENT.posts.base}/announcement`} /></label>
476
- </div>
477
- <label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
478
- <input type="checkbox" name="annDismissible" checked={settings.announcement.dismissible} />
479
- <span>Visitors can dismiss it <span class="hint">(remembered per browser until the text changes)</span></span>
480
- </label>
481
- <div class="form-actions"><button class="btn primary" type="submit">Save</button></div>
482
- </form>
483
- </div>
481
+ {isOwner && (
482
+ <section class={`scard ${openCard === 'analytics' ? 'editing' : ''}`} id="analytics" data-editable>
483
+ <header class="scard-head">
484
+ <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
485
+ <div class="grow"><h2>Analytics</h2><p>Traffic and ad tracking on public pages. The admin is never tracked. Mind consent rules (GDPR) for the ad pixels.</p></div>
486
+ <div class="scard-actions">
487
+ <button class="btn" type="button" data-edit>Edit</button>
488
+ <button class="btn ghost" type="button" data-cancel>Cancel</button>
489
+ <button class="btn primary" type="submit" form="form-analytics">Save</button>
490
+ </div>
491
+ </header>
492
+ <dl class="scard-view">
493
+ <div><dt>Umami</dt><dd>{settings.analytics.umami.enabled ? (normalizeScriptUrl(settings.analytics.umami.scriptUrl) ? new URL(settings.analytics.umami.scriptUrl).host : 'On') : <span class="hint">Off</span>}</dd></div>
494
+ <div><dt>Session replay</dt><dd>{settings.analytics.umami.replay.enabled ? `${Math.round(settings.analytics.umami.replay.sampleRate * 100)}% · ${settings.analytics.umami.replay.maskLevel}` : <span class="hint">Off</span>}</dd></div>
495
+ <div><dt>Meta Pixel</dt><dd>{settings.analytics.metaPixel.enabled ? settings.analytics.metaPixel.pixelId : <span class="hint">Off</span>}</dd></div>
496
+ <div><dt>TikTok Pixel</dt><dd>{settings.analytics.tiktokPixel.enabled ? settings.analytics.tiktokPixel.pixelId : <span class="hint">Off</span>}</dd></div>
497
+ </dl>
498
+ <form class="scard-form" id="form-analytics" method="post" action="/admin/settings#analytics">
499
+ <input type="hidden" name="action" value="analytics" />
500
+ <div class="scard-sub first">
501
+ <label class="check-row"><input type="checkbox" name="umamiEnabled" checked={settings.analytics.umami.enabled} /><b>Umami</b></label>
502
+ <div class="field-row">
503
+ <label class="field"><span>Website ID</span><input class="mono" type="text" name="umamiWebsiteId" value={settings.analytics.umami.websiteId} placeholder="94db1cb1-74f4-4a40-ad6c-962362670409" /></label>
504
+ <label class="field"><span>Script URL</span><input class="mono" type="text" name="umamiScriptUrl" value={settings.analytics.umami.scriptUrl} placeholder="https://cloud.umami.is/script.js" /></label>
505
+ </div>
506
+ <p class="hint">Privacy-friendly stats. Umami Cloud by default — point Script URL at your own instance if self-hosting. Set <code>UMAMI_API_TOKEN</code> (self-hosted) or <code>UMAMI_API_KEY</code> (Cloud) to show traffic in the admin.</p>
507
+ <div class="subpanel">
508
+ <label class="check-row"><input type="checkbox" name="umamiReplayEnabled" checked={settings.analytics.umami.replay.enabled} /><b>Session replay</b></label>
509
+ <div class="field-row three">
510
+ <label class="field"><span>Sample rate (%)</span><input type="number" name="umamiReplaySampleRate" min="1" max="100" step="1" value={Math.round(settings.analytics.umami.replay.sampleRate * 100)} /></label>
511
+ <label class="field"><span>Mask level</span>
512
+ <select name="umamiReplayMaskLevel">
513
+ <option value="moderate" selected={settings.analytics.umami.replay.maskLevel === 'moderate'}>moderate</option>
514
+ <option value="strict" selected={settings.analytics.umami.replay.maskLevel === 'strict'}>strict</option>
515
+ </select>
516
+ </label>
517
+ <label class="field"><span>Max duration</span>
518
+ <select name="umamiReplayMaxDuration">
519
+ <option value="60000" selected={settings.analytics.umami.replay.maxDuration === 60000}>1 minute</option>
520
+ <option value="300000" selected={settings.analytics.umami.replay.maxDuration === 300000}>5 minutes</option>
521
+ <option value="600000" selected={settings.analytics.umami.replay.maxDuration === 600000}>10 minutes</option>
522
+ <option value="1800000" selected={settings.analytics.umami.replay.maxDuration === 1800000}>30 minutes</option>
523
+ </select>
524
+ </label>
525
+ </div>
526
+ <p class="hint">Records a sample of sessions for playback in Umami (v3.1+, Behavior → Replays), kept 30 days. Inputs are masked.</p>
527
+ </div>
528
+ </div>
529
+ <div class="scard-sub">
530
+ <label class="check-row"><input type="checkbox" name="metaEnabled" checked={settings.analytics.metaPixel.enabled} /><b>Meta Pixel</b></label>
531
+ <label class="field narrow"><span>Pixel ID</span><input class="mono" type="text" name="metaPixelId" value={settings.analytics.metaPixel.pixelId} placeholder="724159876543210" /></label>
532
+ <p class="hint">Page views for Facebook/Instagram ad attribution and retargeting.</p>
533
+ </div>
534
+ <div class="scard-sub">
535
+ <label class="check-row"><input type="checkbox" name="tiktokEnabled" checked={settings.analytics.tiktokPixel.enabled} /><b>TikTok Pixel</b><span class="pill edited">Experimental</span></label>
536
+ <label class="field narrow"><span>Pixel ID</span><input class="mono" type="text" name="tiktokPixelId" value={settings.analytics.tiktokPixel.pixelId} placeholder="CABC123DEF456GHI789" /></label>
537
+ <p class="hint">Untested integration — verify events in TikTok Events Manager before relying on it.</p>
538
+ </div>
539
+ </form>
540
+ </section>
541
+ )}
484
542
 
485
- <div class="card card-pad">
486
- <p class="card-title">Redirects</p>
487
- <p style="margin:0">
488
- Forward old URLs to new ones. Redirects only fire when a URL would otherwise 404, so they can
489
- never shadow real pages. Renaming a published post or page adds one here automatically.
490
- </p>
491
- {redirects.length > 0 && (
492
- <div class="rd-list">
493
- {redirects.map((r) => (
494
- <div class="rd-row">
495
- <code class="rd-path">{r.fromPath}</code>
496
- <span class="rd-arrow">→</span>
497
- <code class="rd-path">{r.toPath}</code>
498
- <span class="rd-code">{r.code}</span>
499
- <form method="post" style="margin:0">
500
- <input type="hidden" name="action" value="removeRedirect" />
501
- <input type="hidden" name="id" value={r.id} />
502
- <button class="da-chip-x" type="submit" title={`Remove redirect from ${r.fromPath}`}>✕</button>
503
- </form>
504
- </div>
505
- ))}
506
- </div>
507
- )}
508
- <form method="post" class="rd-add">
509
- <input type="hidden" name="action" value="addRedirect" />
510
- <input type="text" name="fromPath" placeholder="/old-path" required />
511
- <span class="rd-arrow">→</span>
512
- <input type="text" name="toPath" placeholder="/new-path or https://elsewhere.com" required />
513
- <select name="code">
514
- <option value="301">301 permanent</option>
515
- <option value="302">302 temporary</option>
516
- </select>
517
- <button class="btn" type="submit">Add</button>
518
- </form>
519
- </div>
543
+ {isOwner && (
544
+ <section class={`scard ${openCard === 'codeInjection' ? 'editing' : ''}`} id="code" data-editable>
545
+ <header class="scard-head">
546
+ <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
547
+ <div class="grow"><h2>Code injection</h2><p>Raw HTML on every public page. Scripts run with full access — paste only code you trust.</p></div>
548
+ <div class="scard-actions">
549
+ <button class="btn" type="button" data-edit>Edit</button>
550
+ <button class="btn ghost" type="button" data-cancel>Cancel</button>
551
+ <button class="btn primary" type="submit" form="form-code">Save</button>
552
+ </div>
553
+ </header>
554
+ <dl class="scard-view">
555
+ <div><dt>Site header</dt><dd>{settings.headCode ? `${settings.headCode.split('\n').length} lines` : <span class="hint">Empty</span>}</dd></div>
556
+ <div><dt>Site footer</dt><dd>{settings.footerCode ? `${settings.footerCode.split('\n').length} lines` : <span class="hint">Empty</span>}</dd></div>
557
+ </dl>
558
+ <form class="scard-form" id="form-code" method="post" action="/admin/settings#code">
559
+ <input type="hidden" name="action" value="codeInjection" />
560
+ <label class="field"><span>Site header <span class="hint">(inside <code>&lt;head&gt;</code>)</span></span>
561
+ <textarea class="code" name="headCode" rows="5" placeholder={'<script defer data-domain="example.com" src="…"></script>'}>{settings.headCode}</textarea>
562
+ </label>
563
+ <label class="field"><span>Site footer <span class="hint">(before <code>&lt;/body&gt;</code>)</span></span>
564
+ <textarea class="code" name="footerCode" rows="5">{settings.footerCode}</textarea>
565
+ </label>
566
+ </form>
567
+ </section>
568
+ )}
520
569
 
521
- <div class="card card-pad">
522
- <p class="card-title">Backup</p>
523
- <p style="margin:0">
524
- Download everything as one ZIP — pages, posts, tags, links, navigation, users, settings
525
- (<code>content.json</code>) plus every uploaded image.
526
- </p>
527
- <p class="hint" style="margin-top:8px">
528
- Works the same on every backend. Passwords are never exported — after restoring to a fresh
529
- install, sign in with the owner password from <code>.env</code> and reset accounts.
530
- </p>
531
- <div class="form-actions"><a class="btn primary" href="/api/backup" download>Download backup</a></div>
570
+ {isOwner && <section class="scard" id="ai-agents"><header class="scard-head"><a class="scard-back" href="#" aria-label="All settings">←</a><div class="grow"><h2>AI &amp; agents</h2></div><a class="btn" href="/admin/ai">Import document</a></header><AiSettings client:load /></section>}
532
571
 
533
- {isOwner && (
534
- <div class="rs-restore">
535
- <p class="da-subtitle">Restore from backup</p>
536
- <p class="hint" style="margin:0 0 10px">
537
- Pick a backup ZIP to see what's inside, then restore. <strong>Restoring replaces all
538
- current content</strong> — posts, pages, media, settings, everything.
539
- </p>
540
- <div class="rs-row">
541
- <input type="file" id="rs-file" accept=".zip,application/zip" />
542
- <button class="btn" type="button" id="rs-analyze">Analyze</button>
543
- <button class="btn danger" type="button" id="rs-go" hidden>Restore now</button>
544
- </div>
545
- <div class="hint" id="rs-report" style="margin-top:10px"></div>
546
- </div>
547
- )}
548
- </div>
572
+ <div class="set-group-title">Advanced</div>
573
+
574
+ <section class="scard" id="backup">
575
+ <header class="scard-head">
576
+ <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
577
+ <div class="grow"><h2>Backup</h2><p>Portable ZIP with content, bookings, booking retry records and uploaded media. Passwords, provider credentials and queued email are not exported. Keep this private: it includes guest details and management tokens.</p></div>
578
+ <div class="scard-actions always"><a class="btn" href="/api/backup" download>Download backup</a></div>
579
+ </header>
580
+ {isOwner && (
581
+ <div class="scard-sub first">
582
+ <h3>Restore from backup</h3>
583
+ <p class="hint">Pick a backup ZIP to see what's inside, then restore. <strong>Restoring replaces all current content and booking retry records. Use a fresh v5 backup for migration.</strong></p>
584
+ <div class="rs-row">
585
+ <input type="file" id="rs-file" accept=".zip,application/zip" aria-label="Backup ZIP" />
586
+ <button class="btn" type="button" id="rs-analyze">Analyze</button>
587
+ <button class="btn danger" type="button" id="rs-go" hidden>Restore now</button>
588
+ </div>
589
+ <div class="hint" id="rs-report"></div>
590
+ </div>
591
+ )}
592
+ </section>
549
593
 
550
- <div class="card card-pad">
551
- <p class="card-title">Backend</p>
552
- <p style="margin:0">
553
- Content backend: <span class="pill home">{backend}</span>
554
- </p>
555
- <p class="hint" style="margin-top:8px">
556
- Picked automatically: <code>d1</code> when the Cloudflare <code>DB</code> binding is present
557
- (Workers deploys and wrangler dev), otherwise <code>sqlite</code> (local file at
558
- <code>SQLITE_URL</code>, zero setup). Both use the same content shapes.
559
- </p>
594
+ <section class="scard" id="backend">
595
+ <header class="scard-head">
596
+ <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
597
+ <div class="grow"><h2>Backend</h2><p>Picked automatically: <code>d1</code> when the Cloudflare <code>DB</code> binding is present, otherwise <code>sqlite</code> (<code>SQLITE_URL</code>).</p></div>
598
+ <div class="scard-actions always"><span class="pill home">{backend}</span></div>
599
+ </header>
600
+ </section>
601
+ </div>
560
602
  </div>
561
603
  </Admin>
562
604
 
563
605
  <style>
564
- .rs-restore { border-top: 1px solid var(--border); margin-top: 16px; padding-top: 14px; }
565
606
  .rs-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
566
- .rd-list { margin-top: 14px; border: 1px solid var(--border); border-radius: 9px; overflow: hidden; }
567
- .rd-row {
568
- display: flex; align-items: center; gap: 10px; padding: 8px 12px;
569
- border-bottom: 1px solid var(--border); font-size: 13px;
570
- }
607
+ #rs-report:not(:empty) { margin-top: 10px; }
608
+ .rd-list { margin-top: 4px; border: 1px solid var(--line); border-radius: var(--r-md); overflow: hidden; }
609
+ .rd-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--line); font-size: 13px; }
571
610
  .rd-row:last-child { border-bottom: none; }
572
- .rd-path { background: var(--bg); border-radius: 6px; padding: 3px 8px; font-size: 12.5px; }
611
+ .rd-row form { margin: 0; }
612
+ .rd-path { background: var(--panel-2); border-radius: 6px; padding: 3px 8px; font-size: 12.5px; min-width: 0; overflow-wrap: anywhere; }
573
613
  .rd-arrow { color: var(--muted); }
574
614
  .rd-code { margin-left: auto; font-size: 11.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
575
- .rd-add { display: flex; gap: 8px; align-items: center; margin-top: 14px; flex-wrap: wrap; }
615
+ .rd-add { display: flex; gap: 8px; align-items: center; margin-top: 12px; flex-wrap: wrap; }
576
616
  .rd-add input[type='text'] { flex: 1; min-width: 140px; }
577
- .da-key-row { display: flex; gap: 8px; align-items: center; margin-top: 14px; flex-wrap: wrap; }
578
- .da-key {
579
- flex: 1; min-width: 220px; background: var(--bg); border: 1px solid var(--border);
580
- border-radius: 7px; padding: 7px 10px; font-size: 12.5px; color: var(--muted);
581
- }
582
- .da-key-row form { margin: 0; }
583
- .da-empty {
584
- display: flex; align-items: center; justify-content: space-between; gap: 12px;
585
- border: 1px dashed var(--border); border-radius: 9px; padding: 12px 14px; margin-top: 14px;
586
- }
587
- .da-origins { border-top: 1px solid var(--border); margin-top: 16px; padding-top: 14px; }
588
- .da-subtitle { font-size: 13px; font-weight: 600; margin: 0 0 4px; }
617
+ .rd-add select { width: auto; }
618
+ .da-key-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
619
+ .da-key-row form, .da-empty form, .da-presets form { margin: 0; }
620
+ .da-key { flex: 1; min-width: 220px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 7px; padding: 7px 10px; font-size: 12.5px; color: var(--muted); }
621
+ .da-empty { display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px dashed var(--line-strong); border-radius: var(--r-md); padding: 12px 14px; flex-wrap: wrap; }
589
622
  .da-chips { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
590
- .da-chip {
591
- display: inline-flex; align-items: center; gap: 7px; margin: 0;
592
- background: var(--accent-soft); border: 1px solid rgb(110 123 255 / 30%);
593
- border-radius: 99px; padding: 3px 6px 3px 12px;
594
- }
623
+ .da-chip { display: inline-flex; align-items: center; gap: 7px; margin: 0; background: var(--sel); border-radius: 99px; padding: 3px 6px 3px 12px; }
595
624
  .da-chip code { font-size: 12px; background: none; border: none; padding: 0; }
596
- .da-chip-x {
597
- background: none; border: none; color: var(--muted); cursor: pointer;
598
- font-size: 12px; padding: 2px 5px; border-radius: 99px; line-height: 1;
599
- }
600
- .da-chip-x:hover { color: var(--red); background: rgb(248 113 113 / 12%); }
601
- .da-add-row { display: flex; gap: 8px; margin: 0 0 12px; }
602
- .da-add-row input[type='text'] { flex: 1; }
625
+ .da-chip-x { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 12px; padding: 2px 5px; border-radius: 99px; line-height: 1; }
626
+ .da-chip-x:hover { color: var(--red); background: var(--red-soft); }
627
+ .inline-add { display: flex; gap: 8px; margin: 0 0 12px; }
628
+ .inline-add input[type='text'] { flex: 1; }
603
629
  .da-presets { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
604
- .da-presets form { margin: 0; }
605
- .da-preset {
606
- background: none; border: 1px dashed var(--border); border-radius: 99px;
607
- padding: 3px 12px; font-size: 12px; color: var(--muted); cursor: pointer; font-family: inherit;
608
- }
609
- .da-preset:hover { color: var(--accent); border-color: var(--accent); }
630
+ .da-preset { background: none; border: 1px dashed var(--line-strong); border-radius: 99px; padding: 3px 12px; font-size: 12px; color: var(--muted); cursor: pointer; font-family: inherit; }
631
+ .da-preset:hover { color: var(--text); border-color: var(--muted); }
610
632
  </style>
611
633
 
612
634
  <script>
635
+ // read-mode cards: Edit swaps the summary for the form; Cancel resets it
636
+ document.querySelectorAll<HTMLElement>('.scard[data-editable]').forEach((card) => {
637
+ const form = card.querySelector<HTMLFormElement>('form.scard-form');
638
+ card.querySelector('[data-edit]')?.addEventListener('click', () => {
639
+ card.classList.add('editing');
640
+ form?.querySelector<HTMLElement>('input:not([type=hidden]), textarea')?.focus();
641
+ });
642
+ card.querySelector('[data-cancel]')?.addEventListener('click', () => { form?.reset(); card.classList.remove('editing'); });
643
+ });
644
+
645
+ // index: live search + scroll-spy inside the panel
646
+ const q = document.getElementById('set-q') as HTMLInputElement | null;
647
+ q?.addEventListener('input', () => {
648
+ const v = q.value.trim().toLowerCase();
649
+ document.querySelectorAll<HTMLAnchorElement>('.set-index a').forEach((a) => {
650
+ a.hidden = !!v && !(a.textContent!.toLowerCase().includes(v) || (a.dataset.terms ?? '').includes(v));
651
+ });
652
+ });
653
+ const links = new Map([...document.querySelectorAll<HTMLAnchorElement>('.set-index a[data-spy]')].map((a) => [a.dataset.spy!, a]));
654
+ const spy = new IntersectionObserver((entries) => {
655
+ for (const e of entries) if (e.isIntersecting) {
656
+ links.forEach((a) => a.classList.remove('on'));
657
+ links.get(e.target.id)?.classList.add('on');
658
+ }
659
+ }, { root: document.getElementById('acms-panel'), rootMargin: '-15% 0px -70% 0px' });
660
+ document.querySelectorAll('.scard').forEach((c) => spy.observe(c));
661
+
613
662
  // backup restore: analyze (dry run) → confirm → restore
614
663
  const rsFile = document.getElementById('rs-file') as HTMLInputElement | null;
615
664
  const rsAnalyze = document.getElementById('rs-analyze');
@@ -636,9 +685,11 @@ const remainingPresets = ORIGIN_PRESETS.filter((p) => !settings.draftsAllowedOri
636
685
  rsReport.innerHTML =
637
686
  `Backup from <b>${r.exportedAt ? new Date(r.exportedAt).toLocaleString() : 'unknown date'}</b>: ` +
638
687
  `${r.posts} posts, ${r.pages} pages, ${r.media} media files, ${r.tags} tags, ` +
639
- `${r.links} links, ${r.navItems} nav items, ${r.users} users.` +
688
+ `${r.links} links, ${r.navItems} nav items, ${r.users} users, ${r.bookings} bookings, ${r.bookingRequests} retry records.` +
689
+ (!r.retryRecordsIncluded ? ' <b>This older backup lacks booking retry records; use a new v5 backup for migration.</b>' : '') +
640
690
  (r.missingBinaries ? ` <b>⚠ ${r.missingBinaries} media binaries missing from the archive.</b>` : '');
641
- rsGo?.removeAttribute('hidden');
691
+ if (r.missingBinaries) rsGo?.setAttribute('hidden', '');
692
+ else rsGo?.removeAttribute('hidden');
642
693
  });
643
694
  rsGo?.addEventListener('click', async () => {
644
695
  if (!confirm('Replace ALL current content with this backup? This cannot be undone.')) return;