@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.
- package/CHANGELOG.md +78 -0
- package/cli/common.mjs +35 -0
- package/cli/legacy.mjs +317 -0
- package/cli/main.mjs +66 -0
- package/cli/palette.mjs +4 -0
- package/cli/preview.mjs +50 -0
- package/cli/remote.mjs +55 -0
- package/cli/themes.mjs +127 -0
- package/cli.mjs +2 -369
- package/deploy/Caddyfile +8 -0
- package/deploy/Dockerfile +34 -0
- package/deploy/compose.https.yml +21 -0
- package/deploy/compose.yml +46 -0
- package/docs/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +182 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +575 -47
- package/src/components/AiCleaner.tsx +291 -0
- package/src/components/AiSettings.tsx +575 -0
- package/src/components/CommandPalette.tsx +200 -0
- package/src/components/ContentList.astro +246 -0
- package/src/components/DesignAccess.tsx +182 -0
- package/src/components/DesignManager.tsx +418 -46
- package/src/components/EmailSettings.tsx +521 -0
- package/src/components/EventPage.astro +178 -0
- package/src/components/LegalPage.astro +27 -0
- package/src/components/LinksManager.tsx +114 -57
- package/src/components/NavManager.tsx +42 -43
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +57 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -0
- package/src/components/booking/BookingFlow.astro +150 -0
- package/src/components/booking/HoursEditor.astro +63 -0
- package/src/components/builder/PageBuilder.tsx +10 -2
- package/src/components/editor/EventPanel.tsx +401 -0
- package/src/components/editor/FormatDock.tsx +81 -0
- package/src/components/editor/LivePane.tsx +122 -0
- package/src/components/editor/MobileToolbar.tsx +93 -0
- package/src/components/editor/PostEditor.tsx +605 -223
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/slash.tsx +7 -1
- package/src/components/site/Pager.astro +24 -0
- package/src/components/site/PostCard.astro +64 -0
- package/src/components/site/TagLabel.astro +11 -0
- package/src/layouts/Admin.astro +289 -49
- package/src/layouts/Site.astro +324 -59
- package/src/lib/agent-api.ts +13 -0
- package/src/lib/agent-discovery.ts +40 -0
- package/src/lib/agent.ts +114 -0
- package/src/lib/ai-config.ts +58 -0
- package/src/lib/ai-schema.ts +22 -0
- package/src/lib/ai.ts +90 -0
- package/src/lib/api-keys.ts +65 -0
- package/src/lib/api.ts +52 -0
- package/src/lib/auth.ts +25 -5
- package/src/lib/backup/content.ts +36 -0
- package/src/lib/backup/unzip.ts +12 -1
- package/src/lib/backup/zip.ts +1 -1
- package/src/lib/booking/agent.ts +108 -0
- package/src/lib/booking/availability.ts +221 -0
- package/src/lib/booking/emails.ts +115 -0
- package/src/lib/booking/forms.ts +77 -0
- package/src/lib/booking/service.ts +277 -0
- package/src/lib/booking/view.ts +54 -0
- package/src/lib/calendar/agent-schema.ts +48 -0
- package/src/lib/calendar/agent.ts +136 -0
- package/src/lib/calendar/api.ts +81 -0
- package/src/lib/calendar/cards.ts +102 -0
- package/src/lib/calendar/config.ts +52 -0
- package/src/lib/calendar/data.ts +130 -0
- package/src/lib/calendar/events.ts +261 -0
- package/src/lib/calendar/feeds.ts +43 -0
- package/src/lib/calendar/hooks.ts +38 -0
- package/src/lib/calendar/http.ts +30 -0
- package/src/lib/calendar/ics-write.ts +120 -0
- package/src/lib/calendar/ics.ts +15 -5
- package/src/lib/calendar/import.ts +203 -0
- package/src/lib/calendar/render.ts +120 -37
- package/src/lib/calendar/rrule.ts +149 -0
- package/src/lib/calendar/seo.ts +48 -0
- package/src/lib/design-service.ts +58 -0
- package/src/lib/designDiff.ts +145 -0
- package/src/lib/designPreview.ts +110 -12
- package/src/lib/documents/cleanup.ts +31 -0
- package/src/lib/documents/extract.ts +154 -0
- package/src/lib/documents/vendor.d.ts +4 -0
- package/src/lib/drafts.ts +60 -6
- package/src/lib/dynamicBlocks.ts +40 -8
- package/src/lib/email/accounts.ts +55 -0
- package/src/lib/email/config.ts +86 -0
- package/src/lib/email/migration.ts +24 -0
- package/src/lib/email/providers.ts +89 -0
- package/src/lib/email/service.ts +78 -0
- package/src/lib/email/store.ts +167 -0
- package/src/lib/email/templates.ts +18 -0
- package/src/lib/email/types.ts +32 -0
- package/src/lib/email/webhooks.ts +87 -0
- package/src/lib/email.ts +15 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/legal/index.ts +25 -0
- package/src/lib/mobile/bookings.ts +73 -0
- package/src/lib/mobile/calendar.ts +85 -0
- package/src/lib/mobile/content.ts +85 -0
- package/src/lib/mobile/http.ts +63 -0
- package/src/lib/mobile/route.ts +20 -0
- package/src/lib/ops.ts +8 -0
- package/src/lib/preview.ts +21 -1
- package/src/lib/repo/migrations.ts +217 -0
- package/src/lib/repo/sql.ts +853 -60
- package/src/lib/repo/types.ts +122 -5
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +309 -0
- package/src/lib/search-i18n.ts +3 -0
- package/src/lib/socialIcons.ts +32 -0
- package/src/lib/tagColor.ts +11 -0
- package/src/lib/theme.ts +59 -40
- package/src/lib/themeTokens.ts +174 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhooks.ts +139 -0
- package/src/middleware.ts +36 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +8 -5
- package/src/pages/account/[action].astro +53 -0
- package/src/pages/admin/ai.astro +12 -0
- package/src/pages/admin/c/[collection]/[id].astro +4 -1
- package/src/pages/admin/c/[collection]/index.astro +127 -92
- package/src/pages/admin/calendar/bookings.astro +400 -0
- package/src/pages/admin/calendar/calendars.astro +265 -0
- package/src/pages/admin/calendar/events/[id].astro +56 -0
- package/src/pages/admin/calendar/events/index.astro +408 -0
- package/src/pages/admin/calendar/index.ts +4 -0
- package/src/pages/admin/calendar/integrations.astro +221 -0
- package/src/pages/admin/calendar/resources/[id].astro +180 -0
- package/src/pages/admin/calendar/resources/index.astro +106 -0
- package/src/pages/admin/calendar/resources/link.astro +196 -0
- package/src/pages/admin/design.astro +10 -3
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +232 -85
- package/src/pages/admin/links.astro +3 -3
- package/src/pages/admin/login.astro +14 -10
- package/src/pages/admin/navigation.astro +8 -2
- package/src/pages/admin/pages/[id].astro +4 -1
- package/src/pages/admin/pages/index.astro +41 -48
- package/src/pages/admin/settings.astro +418 -367
- package/src/pages/admin/tags.astro +4 -4
- package/src/pages/admin/users.astro +38 -28
- package/src/pages/api/agent/docs.ts +33 -0
- package/src/pages/api/agent/index.ts +21 -0
- package/src/pages/api/ai/clean.ts +13 -0
- package/src/pages/api/ai/draft.ts +14 -0
- package/src/pages/api/ai/settings.ts +53 -0
- package/src/pages/api/ai/test.ts +12 -0
- package/src/pages/api/backup.ts +8 -21
- package/src/pages/api/calendar/availability.ts +35 -0
- package/src/pages/api/calendar/bookings/[id].ts +49 -0
- package/src/pages/api/calendar/bookings/index.ts +73 -0
- package/src/pages/api/calendar/calendars.ts +35 -0
- package/src/pages/api/calendar/docs.ts +64 -0
- package/src/pages/api/calendar/events/[id].ts +63 -0
- package/src/pages/api/calendar/events/index.ts +112 -0
- package/src/pages/api/calendar/resources.ts +34 -0
- package/src/pages/api/design/capabilities.ts +10 -0
- package/src/pages/api/design/index.ts +58 -0
- package/src/pages/api/design/keys.ts +24 -0
- package/src/pages/api/design/media.ts +32 -0
- package/src/pages/api/email/run.ts +11 -0
- package/src/pages/api/email/settings.ts +61 -0
- package/src/pages/api/email/webhooks/[provider].ts +4 -0
- package/src/pages/api/mcp.ts +59 -0
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/ops/status.ts +39 -0
- package/src/pages/api/restore.ts +27 -37
- package/src/pages/api/search.ts +18 -4
- package/src/pages/api/v1/availability.ts +7 -0
- package/src/pages/api/v1/bookings.ts +17 -0
- package/src/pages/api/v1/calendars.ts +7 -0
- package/src/pages/api/v1/content/[id].ts +7 -0
- package/src/pages/api/v1/content/index.ts +7 -0
- package/src/pages/api/v1/events.ts +7 -0
- package/src/pages/api/v1/resources.ts +7 -0
- package/src/pages/author/[slug].astro +43 -35
- package/src/pages/blog/[slug].astro +137 -66
- package/src/pages/blog/index.astro +95 -30
- package/src/pages/blog/tag/[slug].astro +45 -40
- package/src/pages/book/[resource]/[link].astro +70 -0
- package/src/pages/book/[resource]/index.astro +63 -0
- package/src/pages/book/[resource].ics.ts +38 -0
- package/src/pages/book/index.astro +47 -0
- package/src/pages/book/manage/[token].astro +108 -0
- package/src/pages/calendar/[slug].ics.ts +27 -0
- package/src/pages/calendar/feed.ics.ts +15 -0
- package/src/pages/calendar/index.astro +191 -0
- package/src/pages/embed/book/[resource]/[link].astro +33 -0
- package/src/pages/embed/calendar/[slug].astro +43 -0
- package/src/pages/events/[slug].astro +74 -0
- package/src/pages/events/[slug].ics.ts +25 -0
- package/src/pages/index.astro +6 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +23 -3
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +27 -6
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +924 -105
- package/src/styles/cards.css +92 -74
- package/src/styles/editor.css +492 -114
- package/src/styles/email-settings.css +117 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -281
- package/src/styles/theme-editor.css +135 -0
- package/src/theme-sdk/contract.d.mts +619 -0
- package/src/theme-sdk/contract.mjs +80 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +52 -0
- package/src/themes/editorial/templates/home.astro +4 -4
- package/src/themes/editorial/templates/post-minimal.astro +2 -2
- package/src/themes/editorial/theme.css +22 -37
- package/src/themes/editorial/theme.json +23 -3
- package/src/themes/field/theme.json +26 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +27 -10
- package/src/virtual.d.ts +14 -0
- 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 {
|
|
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 =
|
|
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="
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
<
|
|
249
|
-
<
|
|
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
|
-
|
|
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 <key></code>. Regenerating or revoking takes
|
|
279
|
-
effect immediately.
|
|
280
|
-
</p>
|
|
281
|
-
)}
|
|
263
|
+
<div class="set-group-title">General</div>
|
|
282
264
|
|
|
283
|
-
<
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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 & 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
|
-
<
|
|
302
|
-
<
|
|
303
|
-
<
|
|
304
|
-
<
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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><head></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></body></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 & 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>
|
|
388
|
-
<input type="text" name="
|
|
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>
|
|
392
|
-
<input type="text" name="
|
|
328
|
+
<span>Locale</span>
|
|
329
|
+
<input type="text" name="locale" value={settings.locale} placeholder="en-US" />
|
|
393
330
|
</label>
|
|
394
331
|
</div>
|
|
395
|
-
<
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
</p>
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
</
|
|
395
|
+
)}
|
|
396
|
+
{apiKey && <p class="hint">Send it as <code>Authorization: Bearer <key></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
|
-
</
|
|
430
|
+
</section>
|
|
431
|
+
)}
|
|
432
432
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
<
|
|
498
|
-
<span class="
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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><head></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></body></code>)</span></span>
|
|
564
|
+
<textarea class="code" name="footerCode" rows="5">{settings.footerCode}</textarea>
|
|
565
|
+
</label>
|
|
566
|
+
</form>
|
|
567
|
+
</section>
|
|
568
|
+
)}
|
|
520
569
|
|
|
521
|
-
|
|
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 & agents</h2></div><a class="btn" href="/admin/ai">Import document</a></header><AiSettings client:load /></section>}
|
|
532
571
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
<
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
<
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
567
|
-
.rd-
|
|
568
|
-
|
|
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-
|
|
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:
|
|
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
|
-
.
|
|
578
|
-
.da-key {
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
598
|
-
|
|
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-
|
|
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?.
|
|
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;
|