@acms-subir/core 0.1.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 +106 -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 -315
- 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 +193 -2
- package/ops-check.mjs +27 -0
- package/package.json +35 -12
- package/src/actions/index.ts +744 -38
- 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 +455 -47
- 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 +216 -97
- package/src/components/NavManager.tsx +83 -38
- 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 +826 -271
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/cards.tsx +17 -1
- package/src/components/editor/extensions/codeBlock.tsx +1 -1
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/infoCards.tsx +16 -1
- package/src/components/editor/extensions/mediaCards.tsx +36 -2
- package/src/components/editor/extensions/slash.tsx +77 -9
- package/src/components/editor/extensions/toc.tsx +89 -0
- package/src/components/editor/extensions/toggle.tsx +70 -0
- 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 +331 -57
- 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/analytics.ts +121 -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 +112 -0
- 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 +131 -0
- package/src/lib/dynamicBlocks.ts +79 -7
- 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 +254 -0
- package/src/lib/repo/sql.ts +956 -46
- package/src/lib/repo/types.ts +140 -3
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +362 -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 +10 -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 +14 -4
- package/src/pages/admin/c/[collection]/index.astro +127 -91
- 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 +16 -4
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +237 -87
- 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 +14 -3
- package/src/pages/admin/pages/index.astro +41 -47
- package/src/pages/admin/settings.astro +475 -276
- 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 +140 -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 +9 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +33 -7
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +34 -8
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +928 -105
- package/src/styles/cards.css +101 -60
- package/src/styles/editor.css +532 -102
- package/src/styles/email-settings.css +117 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -278
- 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,13 +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';
|
|
8
|
+
import { UMAMI_ID_RE, META_PIXEL_ID_RE, TIKTOK_PIXEL_ID_RE, normalizeScriptUrl } from '../../lib/analytics';
|
|
7
9
|
|
|
8
10
|
const repo = await getRepo(Astro.locals);
|
|
9
11
|
|
|
10
|
-
const sessionUserId =
|
|
12
|
+
const sessionUserId = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
|
|
11
13
|
const sessionUser = sessionUserId && sessionUserId !== OWNER_ENV ? await repo.getUser(sessionUserId) : null;
|
|
12
14
|
const isOwner = sessionUserId === OWNER_ENV || sessionUser?.role === 'owner';
|
|
13
15
|
|
|
@@ -31,9 +33,11 @@ function normalizeOrigin(value: string): string | null {
|
|
|
31
33
|
|
|
32
34
|
let flash = '';
|
|
33
35
|
let flashError = '';
|
|
36
|
+
let postedAction = '';
|
|
34
37
|
if (Astro.request.method === 'POST') {
|
|
35
38
|
const form = await Astro.request.formData();
|
|
36
39
|
const action = String(form.get('action') || 'site');
|
|
40
|
+
postedAction = action;
|
|
37
41
|
if (action === 'site') {
|
|
38
42
|
// site URL: normalize to a bare origin; empty is fine (falls back to the
|
|
39
43
|
// request origin until configured)
|
|
@@ -77,6 +81,57 @@ if (Astro.request.method === 'POST') {
|
|
|
77
81
|
});
|
|
78
82
|
flash = 'Code injection saved.';
|
|
79
83
|
}
|
|
84
|
+
} else if (action === 'analytics') {
|
|
85
|
+
if (!isOwner) {
|
|
86
|
+
flashError = 'Only owners can edit analytics.';
|
|
87
|
+
} else {
|
|
88
|
+
// replay sample rate is edited as a percent, stored as a fraction
|
|
89
|
+
const replayPct = Number(String(form.get('umamiReplaySampleRate') ?? '15').trim());
|
|
90
|
+
const replayDuration = Number(String(form.get('umamiReplayMaxDuration') ?? '300000').trim());
|
|
91
|
+
const umami = {
|
|
92
|
+
enabled: form.get('umamiEnabled') === 'on',
|
|
93
|
+
websiteId: String(form.get('umamiWebsiteId') ?? '').trim(),
|
|
94
|
+
scriptUrl: String(form.get('umamiScriptUrl') ?? '').trim() || 'https://cloud.umami.is/script.js',
|
|
95
|
+
replay: {
|
|
96
|
+
enabled: form.get('umamiReplayEnabled') === 'on',
|
|
97
|
+
sampleRate: Math.round(replayPct) / 100,
|
|
98
|
+
maskLevel: (form.get('umamiReplayMaskLevel') === 'strict' ? 'strict' : 'moderate') as 'strict' | 'moderate',
|
|
99
|
+
maxDuration: replayDuration,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
const metaPixel = {
|
|
103
|
+
enabled: form.get('metaEnabled') === 'on',
|
|
104
|
+
pixelId: String(form.get('metaPixelId') ?? '').trim(),
|
|
105
|
+
};
|
|
106
|
+
const tiktokPixel = {
|
|
107
|
+
enabled: form.get('tiktokEnabled') === 'on',
|
|
108
|
+
pixelId: String(form.get('tiktokPixelId') ?? '').trim(),
|
|
109
|
+
};
|
|
110
|
+
// reject bad values even while disabled — a stored ID must always be
|
|
111
|
+
// renderable, since these end up in set:html on every public page
|
|
112
|
+
if (umami.websiteId && !UMAMI_ID_RE.test(umami.websiteId)) {
|
|
113
|
+
flashError = 'Umami website ID should be a UUID — copy it from your Umami dashboard (Settings → Websites → Edit).';
|
|
114
|
+
} else if (!normalizeScriptUrl(umami.scriptUrl)) {
|
|
115
|
+
flashError = 'Umami script URL must be an https:// URL, e.g. https://cloud.umami.is/script.js';
|
|
116
|
+
} else if (!Number.isFinite(replayPct) || replayPct < 1 || replayPct > 100) {
|
|
117
|
+
flashError = 'Replay sample rate must be between 1 and 100 percent.';
|
|
118
|
+
} else if (!Number.isInteger(replayDuration) || replayDuration <= 0) {
|
|
119
|
+
flashError = 'Replay max duration is invalid.';
|
|
120
|
+
} else if (metaPixel.pixelId && !META_PIXEL_ID_RE.test(metaPixel.pixelId)) {
|
|
121
|
+
flashError = 'Meta Pixel ID should be numbers only — find it in Meta Events Manager.';
|
|
122
|
+
} else if (tiktokPixel.pixelId && !TIKTOK_PIXEL_ID_RE.test(tiktokPixel.pixelId)) {
|
|
123
|
+
flashError = 'TikTok Pixel ID should be letters and numbers only — find it in TikTok Events Manager.';
|
|
124
|
+
} else if (umami.enabled && !umami.websiteId) {
|
|
125
|
+
flashError = 'Enter the Umami website ID to enable it.';
|
|
126
|
+
} else if (metaPixel.enabled && !metaPixel.pixelId) {
|
|
127
|
+
flashError = 'Enter the Meta Pixel ID to enable it.';
|
|
128
|
+
} else if (tiktokPixel.enabled && !tiktokPixel.pixelId) {
|
|
129
|
+
flashError = 'Enter the TikTok Pixel ID to enable it.';
|
|
130
|
+
} else {
|
|
131
|
+
await repo.updateSettings({ analytics: { umami, metaPixel, tiktokPixel } });
|
|
132
|
+
flash = 'Analytics saved.';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
80
135
|
} else if (action === 'announcement') {
|
|
81
136
|
// link must be a relative path or http(s) URL — never javascript: etc.
|
|
82
137
|
const rawLink = String(form.get('annLink') ?? '').trim();
|
|
@@ -154,314 +209,456 @@ const ORIGIN_PRESETS: { label: string; origin: string }[] = [
|
|
|
154
209
|
{ label: 'Any origin (*)', origin: '*' },
|
|
155
210
|
];
|
|
156
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
|
+
];
|
|
157
236
|
---
|
|
158
237
|
|
|
159
238
|
<Admin title="Settings" section="settings">
|
|
160
|
-
<div class="
|
|
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" />
|
|
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>
|
|
161
257
|
|
|
162
|
-
|
|
163
|
-
|
|
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>}
|
|
164
262
|
|
|
165
|
-
|
|
166
|
-
<p class="card-title">Site</p>
|
|
167
|
-
<form method="post">
|
|
168
|
-
<input type="hidden" name="action" value="site" />
|
|
169
|
-
<div class="field-row">
|
|
170
|
-
<label class="field"><span>Site title</span><input type="text" name="siteTitle" value={settings.siteTitle} /></label>
|
|
171
|
-
<label class="field"><span>Tagline</span><input type="text" name="tagline" value={settings.tagline} /></label>
|
|
172
|
-
</div>
|
|
173
|
-
<label class="field">
|
|
174
|
-
<span>Site URL</span>
|
|
175
|
-
<input type="text" name="siteUrl" value={settings.siteUrl} placeholder="https://example.com" />
|
|
176
|
-
</label>
|
|
177
|
-
<p class="hint">
|
|
178
|
-
The public address of this site. Used to build canonical URLs, the sitemap, the RSS feed,
|
|
179
|
-
and social-share previews. Leave empty to use whatever address the site is served from.
|
|
180
|
-
</p>
|
|
181
|
-
<label class="field">
|
|
182
|
-
<span>Footer</span>
|
|
183
|
-
<textarea name="footerText" rows="3" placeholder={`${settings.siteTitle}${settings.tagline ? ` — ${settings.tagline}` : ''}`}>{settings.footerText}</textarea>
|
|
184
|
-
</label>
|
|
185
|
-
<p class="hint">
|
|
186
|
-
Shown at the bottom of every public page. Markdown works — e.g.
|
|
187
|
-
<code>© 2026 Your Site · [Twitter](https://twitter.com/…)</code>. Leave empty to fall back to
|
|
188
|
-
“title — tagline”.
|
|
189
|
-
</p>
|
|
190
|
-
<div class="form-actions"><button class="btn primary" type="submit">Save</button></div>
|
|
191
|
-
</form>
|
|
192
|
-
</div>
|
|
263
|
+
<div class="set-group-title">General</div>
|
|
193
264
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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>
|
|
273
|
+
</div>
|
|
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>
|
|
286
|
+
</div>
|
|
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>
|
|
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>
|
|
201
305
|
|
|
202
|
-
{
|
|
203
|
-
<
|
|
204
|
-
<
|
|
205
|
-
<
|
|
206
|
-
<
|
|
207
|
-
<
|
|
208
|
-
<button class="btn" type="
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
</
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
<
|
|
217
|
-
<
|
|
218
|
-
<
|
|
219
|
-
<
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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" />
|
|
322
|
+
<div class="field-row">
|
|
323
|
+
<label class="field">
|
|
324
|
+
<span>Publication timezone</span>
|
|
325
|
+
<input type="text" name="timezone" value={settings.timezone} placeholder="America/New_York" list="tz-suggestions" />
|
|
326
|
+
</label>
|
|
327
|
+
<label class="field">
|
|
328
|
+
<span>Locale</span>
|
|
329
|
+
<input type="text" name="locale" value={settings.locale} placeholder="en-US" />
|
|
330
|
+
</label>
|
|
331
|
+
</div>
|
|
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>
|
|
230
338
|
|
|
231
|
-
<
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
{settings.draftsAllowedOrigins.map((origin) => (
|
|
240
|
-
<form method="post" class="da-chip">
|
|
241
|
-
<input type="hidden" name="action" value="removeOrigin" />
|
|
242
|
-
<input type="hidden" name="origin" value={origin} />
|
|
243
|
-
<code>{origin}</code>
|
|
244
|
-
<button class="da-chip-x" type="submit" title={`Remove ${origin}`}>✕</button>
|
|
245
|
-
</form>
|
|
246
|
-
))}
|
|
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>
|
|
247
347
|
</div>
|
|
248
|
-
|
|
249
|
-
<
|
|
250
|
-
<
|
|
251
|
-
<
|
|
252
|
-
|
|
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>
|
|
253
363
|
</form>
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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>
|
|
262
393
|
</form>
|
|
394
|
+
</div>
|
|
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
|
+
)}
|
|
429
|
+
</div>
|
|
430
|
+
</section>
|
|
431
|
+
)}
|
|
432
|
+
|
|
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>
|
|
263
452
|
))}
|
|
264
453
|
</div>
|
|
265
454
|
)}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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>
|
|
269
467
|
|
|
270
|
-
|
|
271
|
-
<p class="card-title">Timezone & language</p>
|
|
272
|
-
<form method="post">
|
|
273
|
-
<input type="hidden" name="action" value="localization" />
|
|
274
|
-
<div class="field-row">
|
|
275
|
-
<label class="field">
|
|
276
|
-
<span>Publication timezone</span>
|
|
277
|
-
<input type="text" name="timezone" value={settings.timezone} placeholder="America/New_York" list="tz-suggestions" />
|
|
278
|
-
</label>
|
|
279
|
-
<label class="field">
|
|
280
|
-
<span>Locale</span>
|
|
281
|
-
<input type="text" name="locale" value={settings.locale} placeholder="en-US" />
|
|
282
|
-
</label>
|
|
283
|
-
</div>
|
|
284
|
-
<datalist id="tz-suggestions">
|
|
285
|
-
{['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} />)}
|
|
286
|
-
</datalist>
|
|
287
|
-
<p class="hint">
|
|
288
|
-
Dates on the public site render in this timezone and locale (e.g. <code>es-GT</code> for Spanish).
|
|
289
|
-
Empty = server timezone and English.
|
|
290
|
-
</p>
|
|
291
|
-
<div class="form-actions"><button class="btn primary" type="submit">Save</button></div>
|
|
292
|
-
</form>
|
|
293
|
-
</div>
|
|
468
|
+
{isOwner && <div class="set-group-title">Integrations</div>}
|
|
294
469
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
<span>Site header <span class="hint">(inside <code><head></code>)</span></span>
|
|
306
|
-
<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>
|
|
307
|
-
</label>
|
|
308
|
-
<label class="field">
|
|
309
|
-
<span>Site footer <span class="hint">(before <code></body></code>)</span></span>
|
|
310
|
-
<textarea name="footerCode" rows="4" style="font-family:monospace;font-size:12.5px">{settings.footerCode}</textarea>
|
|
311
|
-
</label>
|
|
312
|
-
<div class="form-actions"><button class="btn primary" type="submit">Save</button></div>
|
|
313
|
-
</form>
|
|
314
|
-
</div>
|
|
315
|
-
)}
|
|
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
|
+
)}
|
|
316
480
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
+
)}
|
|
336
542
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
<
|
|
350
|
-
<span class="
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
<span class="rd-arrow">→</span>
|
|
364
|
-
<input type="text" name="toPath" placeholder="/new-path or https://elsewhere.com" required />
|
|
365
|
-
<select name="code">
|
|
366
|
-
<option value="301">301 permanent</option>
|
|
367
|
-
<option value="302">302 temporary</option>
|
|
368
|
-
</select>
|
|
369
|
-
<button class="btn" type="submit">Add</button>
|
|
370
|
-
</form>
|
|
371
|
-
</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
|
+
)}
|
|
372
569
|
|
|
373
|
-
|
|
374
|
-
<p class="card-title">Backup</p>
|
|
375
|
-
<p style="margin:0">
|
|
376
|
-
Download everything as one ZIP — pages, posts, tags, links, navigation, users, settings
|
|
377
|
-
(<code>content.json</code>) plus every uploaded image.
|
|
378
|
-
</p>
|
|
379
|
-
<p class="hint" style="margin-top:8px">
|
|
380
|
-
Works the same on every backend. Passwords are never exported — after restoring to a fresh
|
|
381
|
-
install, sign in with the owner password from <code>.env</code> and reset accounts.
|
|
382
|
-
</p>
|
|
383
|
-
<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>}
|
|
384
571
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
<
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
<
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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>
|
|
401
593
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
<code>SQLITE_URL</code>, zero setup). Both use the same content shapes.
|
|
411
|
-
</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>
|
|
412
602
|
</div>
|
|
413
603
|
</Admin>
|
|
414
604
|
|
|
415
605
|
<style>
|
|
416
|
-
.rs-restore { border-top: 1px solid var(--border); margin-top: 16px; padding-top: 14px; }
|
|
417
606
|
.rs-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
|
418
|
-
|
|
419
|
-
.rd-
|
|
420
|
-
|
|
421
|
-
border-bottom: 1px solid var(--border); font-size: 13px;
|
|
422
|
-
}
|
|
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; }
|
|
423
610
|
.rd-row:last-child { border-bottom: none; }
|
|
424
|
-
.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; }
|
|
425
613
|
.rd-arrow { color: var(--muted); }
|
|
426
614
|
.rd-code { margin-left: auto; font-size: 11.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
427
|
-
.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; }
|
|
428
616
|
.rd-add input[type='text'] { flex: 1; min-width: 140px; }
|
|
429
|
-
.
|
|
430
|
-
.da-key {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
434
|
-
.da-key-row form { margin: 0; }
|
|
435
|
-
.da-empty {
|
|
436
|
-
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
|
437
|
-
border: 1px dashed var(--border); border-radius: 9px; padding: 12px 14px; margin-top: 14px;
|
|
438
|
-
}
|
|
439
|
-
.da-origins { border-top: 1px solid var(--border); margin-top: 16px; padding-top: 14px; }
|
|
440
|
-
.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; }
|
|
441
622
|
.da-chips { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
442
|
-
.da-chip {
|
|
443
|
-
display: inline-flex; align-items: center; gap: 7px; margin: 0;
|
|
444
|
-
background: var(--accent-soft); border: 1px solid rgb(110 123 255 / 30%);
|
|
445
|
-
border-radius: 99px; padding: 3px 6px 3px 12px;
|
|
446
|
-
}
|
|
623
|
+
.da-chip { display: inline-flex; align-items: center; gap: 7px; margin: 0; background: var(--sel); border-radius: 99px; padding: 3px 6px 3px 12px; }
|
|
447
624
|
.da-chip code { font-size: 12px; background: none; border: none; padding: 0; }
|
|
448
|
-
.da-chip-x {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}
|
|
452
|
-
.da-chip-x:hover { color: var(--red); background: rgb(248 113 113 / 12%); }
|
|
453
|
-
.da-add-row { display: flex; gap: 8px; margin: 0 0 12px; }
|
|
454
|
-
.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; }
|
|
455
629
|
.da-presets { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
456
|
-
.da-
|
|
457
|
-
.da-preset {
|
|
458
|
-
background: none; border: 1px dashed var(--border); border-radius: 99px;
|
|
459
|
-
padding: 3px 12px; font-size: 12px; color: var(--muted); cursor: pointer; font-family: inherit;
|
|
460
|
-
}
|
|
461
|
-
.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); }
|
|
462
632
|
</style>
|
|
463
633
|
|
|
464
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
|
+
|
|
465
662
|
// backup restore: analyze (dry run) → confirm → restore
|
|
466
663
|
const rsFile = document.getElementById('rs-file') as HTMLInputElement | null;
|
|
467
664
|
const rsAnalyze = document.getElementById('rs-analyze');
|
|
@@ -488,9 +685,11 @@ const remainingPresets = ORIGIN_PRESETS.filter((p) => !settings.draftsAllowedOri
|
|
|
488
685
|
rsReport.innerHTML =
|
|
489
686
|
`Backup from <b>${r.exportedAt ? new Date(r.exportedAt).toLocaleString() : 'unknown date'}</b>: ` +
|
|
490
687
|
`${r.posts} posts, ${r.pages} pages, ${r.media} media files, ${r.tags} tags, ` +
|
|
491
|
-
`${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>' : '') +
|
|
492
690
|
(r.missingBinaries ? ` <b>⚠ ${r.missingBinaries} media binaries missing from the archive.</b>` : '');
|
|
493
|
-
rsGo?.
|
|
691
|
+
if (r.missingBinaries) rsGo?.setAttribute('hidden', '');
|
|
692
|
+
else rsGo?.removeAttribute('hidden');
|
|
494
693
|
});
|
|
495
694
|
rsGo?.addEventListener('click', async () => {
|
|
496
695
|
if (!confirm('Replace ALL current content with this backup? This cannot be undone.')) return;
|