@tdhftw/emdash-plugin-umami 0.0.0-stage → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,76 @@
1
1
  {
2
2
  "name": "@tdhftw/emdash-plugin-umami",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.3",
4
+ "description": "Umami analytics inside the EmDash CMS admin: an Analytics page, a dashboard widget and optional tracker injection, for self-hosted Umami",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./admin": "./src/admin/index.tsx"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "src/admin",
18
+ "src/shared",
19
+ "CHANGELOG.md"
20
+ ],
21
+ "sideEffects": false,
22
+ "scripts": {
23
+ "build": "tsdown",
24
+ "dev": "tsdown --watch",
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "vitest run",
27
+ "test:watch": "vitest",
28
+ "pack:check": "npm pack --dry-run",
29
+ "prepublishOnly": "npm run typecheck && npm test && npm run build",
30
+ "prepare": "tsdown"
31
+ },
32
+ "peerDependencies": {
33
+ "@cloudflare/kumo": "^2.0.0",
34
+ "@emdash-cms/admin": "^1.0.0",
35
+ "@tanstack/react-query": "^5.0.0",
36
+ "emdash": "^1.0.0",
37
+ "react": "^18.0.0 || ^19.0.0",
38
+ "astro": ">=7.0.0"
39
+ },
40
+ "dependencies": {
41
+ "echarts": "^6.1.0"
42
+ },
43
+ "devDependencies": {
44
+ "@cloudflare/kumo": "2.6.0",
45
+ "@emdash-cms/admin": "1.0.1",
46
+ "@tanstack/react-query": "5.90.21",
47
+ "@types/react": "19.3.0",
48
+ "astro": "7.3.5",
49
+ "emdash": "1.0.1",
50
+ "react": "19.2.4",
51
+ "react-dom": "19.2.4",
52
+ "tsdown": "^0.23.0",
53
+ "typescript": "~6.0.3",
54
+ "vitest": "^5.0.2"
55
+ },
56
+ "keywords": [
57
+ "emdash",
58
+ "emdash-plugin",
59
+ "umami",
60
+ "analytics"
61
+ ],
62
+ "author": "Thord D. Hedengren <tdh@tdh.se>",
63
+ "license": "MIT",
64
+ "repository": {
65
+ "type": "git",
66
+ "url": "git+https://github.com/tdhftw/emdash-umami.git"
67
+ },
68
+ "homepage": "https://github.com/tdhftw/emdash-umami#readme",
69
+ "bugs": "https://github.com/tdhftw/emdash-umami/issues",
70
+ "publishConfig": {
71
+ "access": "public"
72
+ },
73
+ "engines": {
74
+ "node": ">=22"
75
+ }
76
+ }
@@ -0,0 +1,73 @@
1
+ import { Banner, Button, Loader, Tabs } from "@cloudflare/kumo";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import * as React from "react";
4
+
5
+ import type { StatusResponse } from "../shared/constants.js";
6
+ import { callRoute } from "./api.js";
7
+ import { OverviewView } from "./OverviewView.js";
8
+ import { SettingsView } from "./SettingsView.js";
9
+
10
+ type Tab = "overview" | "settings";
11
+
12
+ function tabFromHash(): Tab | null {
13
+ if (typeof window === "undefined") return null;
14
+ return window.location.hash === "#settings" ? "settings" : window.location.hash === "#overview" ? "overview" : null;
15
+ }
16
+
17
+ export function AnalyticsPage() {
18
+ const status = useQuery({
19
+ queryKey: ["plugin-umami", "status"],
20
+ queryFn: () => callRoute<StatusResponse>("status"),
21
+ });
22
+ const [tab, setTab] = React.useState<Tab | null>(tabFromHash);
23
+
24
+ // Until Umami is connected, the settings are the useful view.
25
+ const active: Tab = tab ?? (status.data && !status.data.configured ? "settings" : "overview");
26
+ const choose = (next: Tab) => {
27
+ setTab(next);
28
+ window.history.replaceState(null, "", `#${next}`);
29
+ };
30
+
31
+ return (
32
+ <section className="space-y-6">
33
+ <div className="flex flex-wrap items-center justify-between gap-4">
34
+ <h1 className="text-2xl font-semibold">Analytics</h1>
35
+ {status.data?.dashboardUrl ? (
36
+ <a className="text-sm underline" href={status.data.dashboardUrl} target="_blank" rel="noreferrer">
37
+ Open in Umami
38
+ </a>
39
+ ) : null}
40
+ </div>
41
+ <Tabs
42
+ variant="underline"
43
+ value={active}
44
+ onValueChange={(value) => choose(value as Tab)}
45
+ tabs={[
46
+ { value: "overview", label: "Overview" },
47
+ { value: "settings", label: "Settings" },
48
+ ]}
49
+ />
50
+ {active === "settings" ? (
51
+ <SettingsView />
52
+ ) : status.isLoading ? (
53
+ <Loader />
54
+ ) : status.error ? (
55
+ <Banner variant="error" title="Could not load the plugin status" description={(status.error as Error).message} />
56
+ ) : status.data && !status.data.configured ? (
57
+ <Banner
58
+ title="Umami is not connected yet"
59
+ description={
60
+ <>
61
+ Finish the setup to see statistics here.{" "}
62
+ <Button type="button" variant="secondary" size="sm" onClick={() => choose("settings")}>
63
+ Open settings
64
+ </Button>
65
+ </>
66
+ }
67
+ />
68
+ ) : (
69
+ <OverviewView onOpenSettings={() => choose("settings")} />
70
+ )}
71
+ </section>
72
+ );
73
+ }
@@ -0,0 +1,23 @@
1
+ // Loaded lazily (see OverviewView) so ECharts is only downloaded on the Analytics page.
2
+ // Keep this module's imports limited to ECharts and Kumo's chart: importing a module that the
3
+ // eagerly loaded admin code also uses lets the bundler pull ECharts into that shared chunk.
4
+ // Kumo's TimeseriesChart always stacks bars and uses a time axis with ticks in the browser's zone,
5
+ // so this uses the low-level Chart with options from chartOptions.ts.
6
+ import { Chart as KumoChart, ChartPalette } from "@cloudflare/kumo/components/chart";
7
+ import { BarChart } from "echarts/charts";
8
+ import { AriaComponent, GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
9
+ import * as echarts from "echarts/core";
10
+ import { CanvasRenderer } from "echarts/renderers";
11
+
12
+ import type { Overview } from "../shared/stats.js";
13
+ import { chartOptions } from "./chartOptions.js";
14
+
15
+ echarts.use([BarChart, AriaComponent, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]);
16
+
17
+ export default function Chart({ overview, dark }: { overview: Overview; dark: boolean }) {
18
+ const options = chartOptions(overview, {
19
+ pageviews: ChartPalette.categorical(0, dark),
20
+ visitors: ChartPalette.categorical(4, dark),
21
+ });
22
+ return <KumoChart echarts={echarts} options={options} isDarkMode={dark} height={260} optionUpdateBehavior={{ notMerge: true }} />;
23
+ }
@@ -0,0 +1,150 @@
1
+ import { Badge, Banner, Button, Loader, Surface, Tabs } from "@cloudflare/kumo";
2
+ import * as React from "react";
3
+
4
+ import { METRIC_TYPES, RANGES, type MetricRow, type MetricType, type Overview, type Range } from "../shared/stats.js";
5
+ import { METRIC_TITLES, RANGE_LABELS, change, kpis, metricLabel, relativeTime, type Kpi } from "./format.js";
6
+ import { useActive, useDarkMode, useOverview, useRange } from "./hooks.js";
7
+
8
+ const Chart = React.lazy(() => import("./Chart.js"));
9
+
10
+ function Delta({ kpi }: { kpi: Kpi }) {
11
+ const c = change(kpi.value, kpi.previous);
12
+ if (c === null || !Number.isFinite(c)) return <span className="text-xs text-kumo-subtle">no earlier data</span>;
13
+ const rounded = Math.round(c * 100);
14
+ if (rounded === 0) return <span className="text-xs text-kumo-subtle">no change</span>;
15
+ const good = kpi.lowerIsBetter ? rounded < 0 : rounded > 0;
16
+ return (
17
+ <span className={`text-xs tabular-nums ${good ? "text-kumo-success" : "text-kumo-danger"}`}>
18
+ {rounded > 0 ? "▲" : "▼"} {Math.abs(rounded)}%
19
+ </span>
20
+ );
21
+ }
22
+
23
+ function Tile({ label, value, children }: { label: string; value: string; children?: React.ReactNode }) {
24
+ return (
25
+ <Surface className="rounded-lg p-4">
26
+ <div className="text-sm text-kumo-subtle">{label}</div>
27
+ <div className="text-2xl font-semibold tabular-nums">{value}</div>
28
+ <div>{children}</div>
29
+ </Surface>
30
+ );
31
+ }
32
+
33
+ function ActiveTile() {
34
+ const active = useActive();
35
+ return (
36
+ <Tile label="Active now" value={active.data ? String(active.data.visitors) : active.isError ? "–" : "…"}>
37
+ <span className="text-xs text-kumo-subtle">last 5 minutes</span>
38
+ </Tile>
39
+ );
40
+ }
41
+
42
+ function TopList({ type, rows }: { type: MetricType; rows: MetricRow[] }) {
43
+ const max = rows[0]?.value ?? 0;
44
+ return (
45
+ <Surface className="rounded-lg p-4">
46
+ <div className="flex items-center justify-between">
47
+ <h3 className="font-semibold">{METRIC_TITLES[type]}</h3>
48
+ <span className="text-xs text-kumo-subtle">{type === "event" ? "Events" : "Visitors"}</span>
49
+ </div>
50
+ {rows.length === 0 ? (
51
+ <p className="text-sm text-kumo-subtle" style={{ marginTop: 12 }}>
52
+ {type === "event" ? "No custom events in this period." : "Nothing recorded in this period."}
53
+ </p>
54
+ ) : (
55
+ <ol style={{ listStyle: "none", margin: "12px 0 0", padding: 0, display: "grid", gap: 4 }}>
56
+ {rows.map((row) => (
57
+ <li key={row.label} style={{ position: "relative", padding: "4px 8px", borderRadius: 6, overflow: "hidden" }}>
58
+ <div
59
+ aria-hidden
60
+ style={{
61
+ position: "absolute",
62
+ inset: 0,
63
+ width: `${max > 0 ? (row.value / max) * 100 : 0}%`,
64
+ background: "var(--color-kumo-tint)",
65
+ }}
66
+ />
67
+ <div className="flex items-center justify-between gap-3 text-sm" style={{ position: "relative" }}>
68
+ <span className="truncate" title={row.label}>
69
+ {metricLabel(type, row.label)}
70
+ </span>
71
+ <span className="tabular-nums">{row.value.toLocaleString()}</span>
72
+ </div>
73
+ </li>
74
+ ))}
75
+ </ol>
76
+ )}
77
+ </Surface>
78
+ );
79
+ }
80
+
81
+ function Body({ overview }: { overview: Overview }) {
82
+ const dark = useDarkMode();
83
+ return (
84
+ <>
85
+ <div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))" }}>
86
+ {kpis(overview.totals, overview.previous).map((kpi) => (
87
+ <Tile key={kpi.key} label={kpi.label} value={kpi.format(kpi.value)}>
88
+ <Delta kpi={kpi} />
89
+ </Tile>
90
+ ))}
91
+ <ActiveTile />
92
+ </div>
93
+ <Surface className="rounded-lg p-4">
94
+ <React.Suspense fallback={<div style={{ height: 260 }} />}>
95
+ <Chart overview={overview} dark={dark} />
96
+ </React.Suspense>
97
+ </Surface>
98
+ <div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))" }}>
99
+ {METRIC_TYPES.map((type) => (
100
+ <TopList key={type} type={type} rows={overview.top[type]} />
101
+ ))}
102
+ </div>
103
+ </>
104
+ );
105
+ }
106
+
107
+ export function OverviewView({ onOpenSettings }: { onOpenSettings: () => void }) {
108
+ const [range, setRange] = useRange();
109
+ const overview = useOverview(range);
110
+ const error = overview.error as (Error & { code?: string }) | null;
111
+
112
+ return (
113
+ <div className="space-y-4">
114
+ <div className="flex flex-wrap items-center justify-between gap-3">
115
+ <Tabs
116
+ variant="segmented"
117
+ size="sm"
118
+ value={range}
119
+ onValueChange={(value) => setRange(value as Range)}
120
+ tabs={RANGES.map((r) => ({ value: r, label: RANGE_LABELS[r] }))}
121
+ />
122
+ <div className="flex items-center gap-3 text-xs text-kumo-subtle">
123
+ {overview.data?.stale ? <Badge variant="orange">Umami unreachable, showing saved data</Badge> : null}
124
+ {overview.data ? <span>Updated {relativeTime(overview.data.fetchedAt)}</span> : null}
125
+ {overview.isFetching ? <Loader size="sm" /> : null}
126
+ </div>
127
+ </div>
128
+ {error && !overview.data ? (
129
+ <Banner
130
+ variant="error"
131
+ title="Could not load statistics"
132
+ description={
133
+ <>
134
+ {error.message}{" "}
135
+ {error.code === "not-configured" || error.code === "unauthorized" || error.code === "not-found" ? (
136
+ <Button type="button" size="sm" variant="secondary" onClick={onOpenSettings}>
137
+ Open settings
138
+ </Button>
139
+ ) : null}
140
+ </>
141
+ }
142
+ />
143
+ ) : overview.data ? (
144
+ <Body overview={overview.data} />
145
+ ) : (
146
+ <Loader />
147
+ )}
148
+ </div>
149
+ );
150
+ }
@@ -0,0 +1,333 @@
1
+ import { Banner, Button, Input, Loader, Radio, Select, SensitiveInput, Surface, Switch } from "@cloudflare/kumo";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import * as React from "react";
4
+
5
+ import type { ConnectionTestResult, SaveSettingsInput, SettingsView as Settings } from "../shared/api-types.js";
6
+ import { CLOUD_ENABLED, type AuthMode, type CloudRegion, type Hosting } from "../shared/constants.js";
7
+ import { defaultScriptUrl } from "../shared/tracker.js";
8
+ import { callRoute } from "./api.js";
9
+
10
+ interface Draft {
11
+ hosting: Hosting;
12
+ baseUrl: string;
13
+ region: CloudRegion;
14
+ authMode: AuthMode;
15
+ apiKey: string;
16
+ username: string;
17
+ password: string;
18
+ websiteId: string;
19
+ injectScript: boolean;
20
+ clientRouter: boolean;
21
+ scriptUrl: string;
22
+ }
23
+
24
+ function draftFrom(s: Settings): Draft {
25
+ const v = s.values;
26
+ return {
27
+ hosting: v.hosting,
28
+ baseUrl: v.baseUrl ?? "",
29
+ region: v.region,
30
+ authMode: v.authMode,
31
+ apiKey: "",
32
+ username: v.username ?? "",
33
+ password: "",
34
+ websiteId: v.websiteId ?? "",
35
+ injectScript: v.injectScript,
36
+ clientRouter: v.clientRouter,
37
+ scriptUrl: v.scriptUrl ?? "",
38
+ };
39
+ }
40
+
41
+ /** Only send what the chosen setup uses; empty secret fields mean "keep the saved one". */
42
+ function toInput(d: Draft): SaveSettingsInput {
43
+ const input: SaveSettingsInput = {
44
+ hosting: d.hosting,
45
+ authMode: d.hosting === "cloud" ? "apiKey" : d.authMode,
46
+ websiteId: d.websiteId || null,
47
+ injectScript: d.injectScript,
48
+ clientRouter: d.clientRouter,
49
+ scriptUrl: d.scriptUrl || null,
50
+ };
51
+ if (d.hosting === "self-hosted") input.baseUrl = d.baseUrl || null;
52
+ else input.region = d.region;
53
+ if (d.apiKey) input.apiKey = d.apiKey;
54
+ if (input.authMode === "password") {
55
+ input.username = d.username || null;
56
+ if (d.password) input.password = d.password;
57
+ }
58
+ return input;
59
+ }
60
+
61
+ function Section({ title, description, children }: { title: string; description?: React.ReactNode; children: React.ReactNode }) {
62
+ return (
63
+ <Surface className="rounded-lg p-5">
64
+ <div className="space-y-4">
65
+ <div className="space-y-1">
66
+ <h2 className="text-lg font-semibold">{title}</h2>
67
+ {description ? <p className="text-sm text-kumo-subtle">{description}</p> : null}
68
+ </div>
69
+ {children}
70
+ </div>
71
+ </Surface>
72
+ );
73
+ }
74
+
75
+ function urlError(value: string): string | undefined {
76
+ if (!value) return undefined;
77
+ try {
78
+ const url = new URL(value);
79
+ return url.protocol === "https:" || url.protocol === "http:" ? undefined : "Use an address starting with https://";
80
+ } catch {
81
+ return "Use a full address, for example https://stats.example.com";
82
+ }
83
+ }
84
+
85
+ function trackerUrl(d: Draft): string | null {
86
+ return defaultScriptUrl(d.hosting, d.baseUrl || null, d.scriptUrl || null);
87
+ }
88
+
89
+ export function SettingsView({ onSaved }: { onSaved?: () => void }) {
90
+ const queryClient = useQueryClient();
91
+ const settings = useQuery({ queryKey: ["plugin-umami", "settings"], queryFn: () => callRoute<Settings>("settings") });
92
+ const [draft, setDraft] = React.useState<Draft | null>(null);
93
+ const [showCustomScript, setShowCustomScript] = React.useState(false);
94
+
95
+ React.useEffect(() => {
96
+ if (settings.data && !draft) {
97
+ setDraft(draftFrom(settings.data));
98
+ setShowCustomScript(Boolean(settings.data.values.scriptUrl));
99
+ }
100
+ }, [settings.data, draft]);
101
+
102
+ const hasCredentials = settings.data
103
+ ? settings.data.values.authMode === "apiKey"
104
+ ? settings.data.secretsSet.apiKey
105
+ : settings.data.secretsSet.password && Boolean(settings.data.values.username)
106
+ : false;
107
+
108
+ // Check the saved connection on load so the website list is ready.
109
+ const test = useQuery({
110
+ queryKey: ["plugin-umami", "connection"],
111
+ queryFn: () => callRoute<ConnectionTestResult>("connection/test", {}),
112
+ enabled: hasCredentials,
113
+ staleTime: 5 * 60_000,
114
+ });
115
+
116
+ const save = useMutation({
117
+ mutationFn: async (d: Draft) => {
118
+ const saved = await callRoute<Settings>("settings/save", toInput(d));
119
+ const result = await callRoute<ConnectionTestResult>("connection/test", {});
120
+ return { saved, result };
121
+ },
122
+ onSuccess: ({ saved, result }) => {
123
+ queryClient.setQueryData(["plugin-umami", "settings"], saved);
124
+ queryClient.setQueryData(["plugin-umami", "connection"], result);
125
+ void queryClient.invalidateQueries({ queryKey: ["plugin-umami", "status"] });
126
+ setDraft(draftFrom(saved));
127
+ onSaved?.();
128
+ },
129
+ });
130
+
131
+ if (settings.isLoading || !draft) {
132
+ return settings.error ? (
133
+ <Banner variant="error" title="Could not load the settings" description={String((settings.error as Error).message)} />
134
+ ) : (
135
+ <Loader />
136
+ );
137
+ }
138
+
139
+ const set = <K extends keyof Draft>(key: K, value: Draft[K]) => setDraft((d) => (d ? { ...d, [key]: value } : d));
140
+ const secrets = settings.data?.secretsSet ?? { apiKey: false, password: false };
141
+ const connection = test.data;
142
+ const websites = connection?.ok ? connection.websites : [];
143
+ const selectedKnown = websites.some((w) => w.id === draft.websiteId);
144
+ const tracker = trackerUrl(draft);
145
+ const authMode: AuthMode = draft.hosting === "cloud" ? "apiKey" : draft.authMode;
146
+
147
+ return (
148
+ <form
149
+ className="max-w-2xl space-y-6"
150
+ onSubmit={(event) => {
151
+ event.preventDefault();
152
+ if (draft.hosting === "self-hosted" && urlError(draft.baseUrl)) return;
153
+ if (draft.scriptUrl && urlError(draft.scriptUrl)) return;
154
+ save.mutate(draft);
155
+ }}
156
+ >
157
+ <Section title="Umami server" description="Where your Umami statistics live.">
158
+ <Radio.Group
159
+ legend="Hosting"
160
+ orientation="horizontal"
161
+ value={draft.hosting}
162
+ onValueChange={(value) => set("hosting", value as Hosting)}
163
+ >
164
+ <Radio.Item label="Self-hosted Umami" value="self-hosted" />
165
+ <Radio.Item
166
+ label={CLOUD_ENABLED ? "Umami Cloud" : "Umami Cloud (coming in 0.2.0)"}
167
+ value="cloud"
168
+ disabled={!CLOUD_ENABLED}
169
+ />
170
+ </Radio.Group>
171
+ {draft.hosting === "self-hosted" ? (
172
+ <Input
173
+ label="Umami URL"
174
+ description="The address you open Umami at, for example https://stats.example.com."
175
+ type="url"
176
+ placeholder="https://stats.example.com"
177
+ value={draft.baseUrl}
178
+ error={urlError(draft.baseUrl)}
179
+ onChange={(event) => set("baseUrl", event.target.value.trim())}
180
+ />
181
+ ) : (
182
+ <Select
183
+ label="Region"
184
+ description="Leave on account default unless Umami support told you otherwise."
185
+ value={draft.region}
186
+ onValueChange={(value) => set("region", value as CloudRegion)}
187
+ items={{ default: "Account default", us: "United States", eu: "European Union" }}
188
+ />
189
+ )}
190
+ </Section>
191
+
192
+ <Section
193
+ title="Sign in"
194
+ description={
195
+ authMode === "apiKey"
196
+ ? "Create a key in Umami under Settings → API keys. It is stored encrypted and never shown again."
197
+ : "Use this only for Umami versions without API keys. The password is stored encrypted."
198
+ }
199
+ >
200
+ {draft.hosting === "self-hosted" ? (
201
+ <Radio.Group
202
+ legend="Method"
203
+ orientation="horizontal"
204
+ value={draft.authMode}
205
+ onValueChange={(value) => set("authMode", value as AuthMode)}
206
+ >
207
+ <Radio.Item label="API key" value="apiKey" />
208
+ <Radio.Item label="Username and password" value="password" />
209
+ </Radio.Group>
210
+ ) : null}
211
+ {authMode === "apiKey" ? (
212
+ <SensitiveInput
213
+ label="API key"
214
+ description={secrets.apiKey ? "A key is saved. Paste a new one only to replace it." : undefined}
215
+ placeholder={secrets.apiKey ? "Saved key, leave empty to keep" : "umami_…"}
216
+ value={draft.apiKey}
217
+ onValueChange={(value) => set("apiKey", value)}
218
+ />
219
+ ) : (
220
+ <>
221
+ <Input label="Username" value={draft.username} onChange={(event) => set("username", event.target.value)} />
222
+ <SensitiveInput
223
+ label="Password"
224
+ description={secrets.password ? "A password is saved. Type a new one only to replace it." : undefined}
225
+ placeholder={secrets.password ? "Saved password, leave empty to keep" : ""}
226
+ value={draft.password}
227
+ onValueChange={(value) => set("password", value)}
228
+ />
229
+ </>
230
+ )}
231
+ </Section>
232
+
233
+ <Section title="Website" description="Which Umami website belongs to this site.">
234
+ {test.isFetching ? (
235
+ <Loader />
236
+ ) : websites.length > 0 ? (
237
+ <Select
238
+ label="Website"
239
+ placeholder="Choose a website"
240
+ value={draft.websiteId || null}
241
+ onValueChange={(value) => set("websiteId", typeof value === "string" ? value : "")}
242
+ items={Object.fromEntries([
243
+ ...websites.map((w) => [w.id, w.domain ? `${w.name} (${w.domain})` : w.name]),
244
+ ...(draft.websiteId && !selectedKnown ? [[draft.websiteId, `${draft.websiteId} (not found in Umami)`]] : []),
245
+ ])}
246
+ />
247
+ ) : hasCredentials && connection?.ok ? (
248
+ <Input
249
+ label="Website ID"
250
+ description="This Umami account lists no websites, so enter the ID by hand. In Umami, open Settings → Websites → Edit and copy the Website ID."
251
+ placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
252
+ value={draft.websiteId}
253
+ onChange={(event) => set("websiteId", event.target.value.trim())}
254
+ />
255
+ ) : (
256
+ <p className="text-sm text-kumo-subtle">
257
+ {hasCredentials
258
+ ? "No websites to choose from until the connection works."
259
+ : "Save your server and sign-in details first. The websites you can see in Umami will then appear here."}
260
+ </p>
261
+ )}
262
+ </Section>
263
+
264
+ <Section title="Tracking" description="Optional. Let this plugin add the Umami tracker to your public pages.">
265
+ <Switch
266
+ label="Add the Umami tracker to public pages"
267
+ checked={draft.injectScript}
268
+ onCheckedChange={(checked) => set("injectScript", checked)}
269
+ />
270
+ <p className="text-sm text-kumo-subtle">
271
+ If your site caches pages, cached copies change over when their cache expires, not the moment you save.
272
+ </p>
273
+ {draft.injectScript ? (
274
+ <>
275
+ <Banner
276
+ variant="alert"
277
+ title="Remove any tracker already in your theme"
278
+ description="If the theme also includes the Umami script, every visit is counted twice. The theme must render EmDashHead."
279
+ />
280
+ <Switch
281
+ label="The site uses Astro's <ClientRouter /> (view transitions)"
282
+ checked={draft.clientRouter}
283
+ onCheckedChange={(checked) => set("clientRouter", checked)}
284
+ />
285
+ <p className="text-sm text-kumo-subtle">
286
+ Turn this on if your layout includes <code>&lt;ClientRouter /&gt;</code>. Pages then change without a full
287
+ load, and this counts each one once, including back and forward. Needs Umami 3.2 or later.
288
+ </p>
289
+ </>
290
+ ) : null}
291
+ <p className="text-sm text-kumo-subtle">
292
+ Tracker script: <code>{tracker ?? "set the Umami URL first"}</code>{" "}
293
+ <Button type="button" variant="ghost" size="sm" onClick={() => setShowCustomScript((v) => !v)}>
294
+ {showCustomScript ? "Use the default" : "Use a custom script URL"}
295
+ </Button>
296
+ </p>
297
+ {showCustomScript ? (
298
+ <Input
299
+ label="Custom tracker script URL"
300
+ description="For a renamed tracker (recommended against ad blockers) or a proxy."
301
+ type="url"
302
+ value={draft.scriptUrl}
303
+ error={urlError(draft.scriptUrl)}
304
+ onChange={(event) => set("scriptUrl", event.target.value.trim())}
305
+ />
306
+ ) : null}
307
+ </Section>
308
+
309
+ <div className="space-y-4">
310
+ {save.error ? (
311
+ <Banner variant="error" title="Could not save" description={(save.error as Error).message} />
312
+ ) : null}
313
+ {connection && !test.isFetching ? (
314
+ connection.ok ? (
315
+ <Banner
316
+ title="Connected to Umami"
317
+ description={
318
+ connection.website
319
+ ? `Showing statistics for ${connection.website.name}.`
320
+ : `${connection.websites.length} website${connection.websites.length === 1 ? "" : "s"} available. Choose one above and save.`
321
+ }
322
+ />
323
+ ) : (
324
+ <Banner variant="error" title="Could not connect to Umami" description={connection.message} />
325
+ )
326
+ ) : null}
327
+ <Button type="submit" variant="primary" loading={save.isPending}>
328
+ Save and test connection
329
+ </Button>
330
+ </div>
331
+ </form>
332
+ );
333
+ }