@checkstack/status-page-frontend 0.1.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 +109 -0
- package/package.json +32 -0
- package/src/index.tsx +46 -0
- package/src/pages/PublicStatusPage.tsx +109 -0
- package/src/pages/StatusPageBuilderPage.tsx +809 -0
- package/src/pages/StatusPagesListPage.tsx +249 -0
- package/src/renderers.tsx +481 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { useParams, useNavigate } from "react-router-dom";
|
|
3
|
+
import {
|
|
4
|
+
PageLayout,
|
|
5
|
+
Button,
|
|
6
|
+
Card,
|
|
7
|
+
Input,
|
|
8
|
+
Label,
|
|
9
|
+
Textarea,
|
|
10
|
+
Toggle,
|
|
11
|
+
Checkbox,
|
|
12
|
+
Badge,
|
|
13
|
+
LoadingSpinner,
|
|
14
|
+
Select,
|
|
15
|
+
SelectTrigger,
|
|
16
|
+
SelectValue,
|
|
17
|
+
SelectContent,
|
|
18
|
+
SelectItem,
|
|
19
|
+
useToast,
|
|
20
|
+
QueryErrorState,
|
|
21
|
+
} from "@checkstack/ui";
|
|
22
|
+
import {
|
|
23
|
+
ArrowUp,
|
|
24
|
+
ArrowDown,
|
|
25
|
+
ArrowLeft,
|
|
26
|
+
Trash2,
|
|
27
|
+
Plus,
|
|
28
|
+
Save,
|
|
29
|
+
Send,
|
|
30
|
+
EyeOff,
|
|
31
|
+
MonitorCheck,
|
|
32
|
+
} from "lucide-react";
|
|
33
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
34
|
+
import { useInitOnceForKey } from "@checkstack/ui";
|
|
35
|
+
import { resolveRoute, extractErrorMessage } from "@checkstack/common";
|
|
36
|
+
import { CatalogApi } from "@checkstack/catalog-common";
|
|
37
|
+
import {
|
|
38
|
+
StatusPageApi,
|
|
39
|
+
statusPageRoutes,
|
|
40
|
+
statusPublicRoutes,
|
|
41
|
+
BUILTIN_WIDGET_IDS,
|
|
42
|
+
type StatusPageBlock,
|
|
43
|
+
type StatusPageVisibility,
|
|
44
|
+
} from "@checkstack/status-page-common";
|
|
45
|
+
import { BlockRenderer } from "../renderers";
|
|
46
|
+
|
|
47
|
+
/** Minimal default config per widget type (matches the config schemas). */
|
|
48
|
+
function defaultConfig(type: string): unknown {
|
|
49
|
+
switch (type) {
|
|
50
|
+
case BUILTIN_WIDGET_IDS.banner: {
|
|
51
|
+
return { systemIds: [] };
|
|
52
|
+
}
|
|
53
|
+
case BUILTIN_WIDGET_IDS.systemHealth: {
|
|
54
|
+
return { items: [], showUptime: false };
|
|
55
|
+
}
|
|
56
|
+
case BUILTIN_WIDGET_IDS.groupStatus: {
|
|
57
|
+
return { groupId: "" };
|
|
58
|
+
}
|
|
59
|
+
case BUILTIN_WIDGET_IDS.uptime: {
|
|
60
|
+
return { systemId: "", days: 90 };
|
|
61
|
+
}
|
|
62
|
+
case BUILTIN_WIDGET_IDS.incidents:
|
|
63
|
+
case BUILTIN_WIDGET_IDS.maintenance: {
|
|
64
|
+
return {
|
|
65
|
+
systemIds: [],
|
|
66
|
+
limit: 5,
|
|
67
|
+
showUpdates: true,
|
|
68
|
+
maxUpdates: 3,
|
|
69
|
+
includePast: false,
|
|
70
|
+
pastMaxAgeDays: 7,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
case BUILTIN_WIDGET_IDS.text: {
|
|
74
|
+
return { markdown: "" };
|
|
75
|
+
}
|
|
76
|
+
case BUILTIN_WIDGET_IDS.heading: {
|
|
77
|
+
return { text: "", level: 2 };
|
|
78
|
+
}
|
|
79
|
+
case BUILTIN_WIDGET_IDS.links: {
|
|
80
|
+
return { links: [] };
|
|
81
|
+
}
|
|
82
|
+
case BUILTIN_WIDGET_IDS.image: {
|
|
83
|
+
return { url: "" };
|
|
84
|
+
}
|
|
85
|
+
default: {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type SystemOption = { id: string; name: string };
|
|
92
|
+
type GroupOption = { id: string; name: string };
|
|
93
|
+
|
|
94
|
+
/** Searchable, counted multi-select of systems. */
|
|
95
|
+
const SystemMultiSelect: React.FC<{
|
|
96
|
+
systems: SystemOption[];
|
|
97
|
+
selected: string[];
|
|
98
|
+
onChange: (ids: string[]) => void;
|
|
99
|
+
}> = ({ systems, selected, onChange }) => {
|
|
100
|
+
const [query, setQuery] = useState("");
|
|
101
|
+
const set = new Set(selected);
|
|
102
|
+
const filtered = query
|
|
103
|
+
? systems.filter((s) => s.name.toLowerCase().includes(query.toLowerCase()))
|
|
104
|
+
: systems;
|
|
105
|
+
const toggle = (id: string, on: boolean) => {
|
|
106
|
+
const next = new Set(selected);
|
|
107
|
+
if (on) next.add(id);
|
|
108
|
+
else next.delete(id);
|
|
109
|
+
onChange([...next]);
|
|
110
|
+
};
|
|
111
|
+
return (
|
|
112
|
+
<div className="space-y-1.5">
|
|
113
|
+
<div className="flex items-center justify-between gap-2">
|
|
114
|
+
<Input
|
|
115
|
+
value={query}
|
|
116
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
117
|
+
placeholder="Search systems…"
|
|
118
|
+
className="h-8"
|
|
119
|
+
/>
|
|
120
|
+
<span className="shrink-0 text-xs text-muted-foreground">
|
|
121
|
+
{selected.length} selected
|
|
122
|
+
</span>
|
|
123
|
+
</div>
|
|
124
|
+
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border p-2">
|
|
125
|
+
{systems.length === 0 ? (
|
|
126
|
+
<p className="text-xs text-muted-foreground">No systems available.</p>
|
|
127
|
+
) : filtered.length === 0 ? (
|
|
128
|
+
<p className="text-xs text-muted-foreground">No matches.</p>
|
|
129
|
+
) : (
|
|
130
|
+
filtered.map((s) => (
|
|
131
|
+
<label
|
|
132
|
+
key={s.id}
|
|
133
|
+
className="flex cursor-pointer items-center gap-2 text-sm"
|
|
134
|
+
>
|
|
135
|
+
<Checkbox
|
|
136
|
+
checked={set.has(s.id)}
|
|
137
|
+
onCheckedChange={(c) => toggle(s.id, Boolean(c))}
|
|
138
|
+
/>
|
|
139
|
+
{s.name}
|
|
140
|
+
</label>
|
|
141
|
+
))
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** Inline editor for a list of labelled links (the links widget config). */
|
|
149
|
+
const LinksConfigEditor: React.FC<{
|
|
150
|
+
links: Array<{ label: string; url: string }>;
|
|
151
|
+
onChange: (links: Array<{ label: string; url: string }>) => void;
|
|
152
|
+
}> = ({ links, onChange }) => {
|
|
153
|
+
const [label, setLabel] = useState("");
|
|
154
|
+
const [url, setUrl] = useState("");
|
|
155
|
+
return (
|
|
156
|
+
<div className="space-y-2">
|
|
157
|
+
{links.length > 0 && (
|
|
158
|
+
<ul className="space-y-1">
|
|
159
|
+
{links.map((l, i) => (
|
|
160
|
+
<li key={i} className="flex items-center justify-between gap-2 text-sm">
|
|
161
|
+
<span className="min-w-0 truncate">
|
|
162
|
+
{l.label} <span className="text-muted-foreground">{l.url}</span>
|
|
163
|
+
</span>
|
|
164
|
+
<Button
|
|
165
|
+
variant="ghost"
|
|
166
|
+
size="sm"
|
|
167
|
+
onClick={() => onChange(links.filter((_, j) => j !== i))}
|
|
168
|
+
aria-label="Remove link"
|
|
169
|
+
>
|
|
170
|
+
<Trash2 className="h-4 w-4 text-destructive" />
|
|
171
|
+
</Button>
|
|
172
|
+
</li>
|
|
173
|
+
))}
|
|
174
|
+
</ul>
|
|
175
|
+
)}
|
|
176
|
+
<div className="flex gap-2">
|
|
177
|
+
<Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="Label" />
|
|
178
|
+
<Input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://…" />
|
|
179
|
+
<Button
|
|
180
|
+
variant="outline"
|
|
181
|
+
size="sm"
|
|
182
|
+
disabled={!label.trim() || !url.trim()}
|
|
183
|
+
onClick={() => {
|
|
184
|
+
onChange([...links, { label: label.trim(), url: url.trim() }]);
|
|
185
|
+
setLabel("");
|
|
186
|
+
setUrl("");
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
Add
|
|
190
|
+
</Button>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const clamp = (n: number, lo: number, hi: number) =>
|
|
197
|
+
Math.min(hi, Math.max(lo, n));
|
|
198
|
+
|
|
199
|
+
/** Shared config controls for the event-feed widgets (incidents, maintenance). */
|
|
200
|
+
const EventFeedControls: React.FC<{
|
|
201
|
+
config: Record<string, unknown>;
|
|
202
|
+
set: (patch: Record<string, unknown>) => void;
|
|
203
|
+
}> = ({ config, set }) => {
|
|
204
|
+
const showUpdates = config.showUpdates !== false;
|
|
205
|
+
const includePast = Boolean(config.includePast);
|
|
206
|
+
return (
|
|
207
|
+
<div className="space-y-2.5 border-t pt-3">
|
|
208
|
+
<label className="flex items-center justify-between gap-2 text-sm">
|
|
209
|
+
<span>Show updates</span>
|
|
210
|
+
<Toggle
|
|
211
|
+
checked={showUpdates}
|
|
212
|
+
onCheckedChange={(v) => set({ showUpdates: v })}
|
|
213
|
+
/>
|
|
214
|
+
</label>
|
|
215
|
+
{showUpdates && (
|
|
216
|
+
<div className="flex items-center justify-between gap-2">
|
|
217
|
+
<Label className="text-xs text-muted-foreground">
|
|
218
|
+
Max updates per item
|
|
219
|
+
</Label>
|
|
220
|
+
<Input
|
|
221
|
+
type="number"
|
|
222
|
+
className="h-8 w-20"
|
|
223
|
+
value={Number(config.maxUpdates ?? 3)}
|
|
224
|
+
onChange={(e) =>
|
|
225
|
+
set({ maxUpdates: clamp(Number(e.target.value) || 3, 1, 10) })
|
|
226
|
+
}
|
|
227
|
+
/>
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
<label className="flex items-center justify-between gap-2 text-sm">
|
|
231
|
+
<span>Show recently resolved / completed</span>
|
|
232
|
+
<Toggle
|
|
233
|
+
checked={includePast}
|
|
234
|
+
onCheckedChange={(v) => set({ includePast: v })}
|
|
235
|
+
/>
|
|
236
|
+
</label>
|
|
237
|
+
{includePast && (
|
|
238
|
+
<div className="flex items-center justify-between gap-2">
|
|
239
|
+
<Label className="text-xs text-muted-foreground">Max age (days)</Label>
|
|
240
|
+
<Input
|
|
241
|
+
type="number"
|
|
242
|
+
className="h-8 w-20"
|
|
243
|
+
value={Number(config.pastMaxAgeDays ?? 7)}
|
|
244
|
+
onChange={(e) =>
|
|
245
|
+
set({ pastMaxAgeDays: clamp(Number(e.target.value) || 7, 1, 90) })
|
|
246
|
+
}
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const BlockConfigEditor: React.FC<{
|
|
255
|
+
block: StatusPageBlock;
|
|
256
|
+
systems: SystemOption[];
|
|
257
|
+
groups: GroupOption[];
|
|
258
|
+
onChange: (config: unknown) => void;
|
|
259
|
+
}> = ({ block, systems, groups, onChange }) => {
|
|
260
|
+
const config = (block.config ?? {}) as Record<string, unknown>;
|
|
261
|
+
const set = (patch: Record<string, unknown>) => onChange({ ...config, ...patch });
|
|
262
|
+
|
|
263
|
+
switch (block.type) {
|
|
264
|
+
case BUILTIN_WIDGET_IDS.banner: {
|
|
265
|
+
return (
|
|
266
|
+
<SystemMultiSelect
|
|
267
|
+
systems={systems}
|
|
268
|
+
selected={(config.systemIds as string[]) ?? []}
|
|
269
|
+
onChange={(ids) => set({ systemIds: ids })}
|
|
270
|
+
/>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
case BUILTIN_WIDGET_IDS.incidents:
|
|
274
|
+
case BUILTIN_WIDGET_IDS.maintenance: {
|
|
275
|
+
return (
|
|
276
|
+
<div className="space-y-3">
|
|
277
|
+
<SystemMultiSelect
|
|
278
|
+
systems={systems}
|
|
279
|
+
selected={(config.systemIds as string[]) ?? []}
|
|
280
|
+
onChange={(ids) => set({ systemIds: ids })}
|
|
281
|
+
/>
|
|
282
|
+
<EventFeedControls config={config} set={set} />
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
case BUILTIN_WIDGET_IDS.systemHealth: {
|
|
287
|
+
const items = (config.items as Array<{ systemId: string }>) ?? [];
|
|
288
|
+
return (
|
|
289
|
+
<div className="space-y-2">
|
|
290
|
+
<SystemMultiSelect
|
|
291
|
+
systems={systems}
|
|
292
|
+
selected={items.map((i) => i.systemId)}
|
|
293
|
+
onChange={(ids) => set({ items: ids.map((systemId) => ({ systemId })) })}
|
|
294
|
+
/>
|
|
295
|
+
<label className="flex items-center gap-2 text-sm">
|
|
296
|
+
<Toggle
|
|
297
|
+
checked={Boolean(config.showUptime)}
|
|
298
|
+
onCheckedChange={(v) => set({ showUptime: v })}
|
|
299
|
+
/>
|
|
300
|
+
Show uptime %
|
|
301
|
+
</label>
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
case BUILTIN_WIDGET_IDS.uptime: {
|
|
306
|
+
return (
|
|
307
|
+
<div className="space-y-2">
|
|
308
|
+
<Select
|
|
309
|
+
value={(config.systemId as string) || ""}
|
|
310
|
+
onValueChange={(v) => set({ systemId: v })}
|
|
311
|
+
>
|
|
312
|
+
<SelectTrigger>
|
|
313
|
+
<SelectValue placeholder="Select a system" />
|
|
314
|
+
</SelectTrigger>
|
|
315
|
+
<SelectContent>
|
|
316
|
+
{systems.map((s) => (
|
|
317
|
+
<SelectItem key={s.id} value={s.id}>
|
|
318
|
+
{s.name}
|
|
319
|
+
</SelectItem>
|
|
320
|
+
))}
|
|
321
|
+
</SelectContent>
|
|
322
|
+
</Select>
|
|
323
|
+
<div className="flex items-center gap-2">
|
|
324
|
+
<Label className="text-xs">Days</Label>
|
|
325
|
+
<Input
|
|
326
|
+
type="number"
|
|
327
|
+
className="w-24"
|
|
328
|
+
value={Number(config.days ?? 90)}
|
|
329
|
+
onChange={(e) =>
|
|
330
|
+
set({ days: Math.min(90, Math.max(1, Number(e.target.value) || 90)) })
|
|
331
|
+
}
|
|
332
|
+
/>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
case BUILTIN_WIDGET_IDS.text: {
|
|
338
|
+
return (
|
|
339
|
+
<Textarea
|
|
340
|
+
value={(config.markdown as string) ?? ""}
|
|
341
|
+
onChange={(e) => set({ markdown: e.target.value })}
|
|
342
|
+
placeholder="Markdown text…"
|
|
343
|
+
rows={4}
|
|
344
|
+
/>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
case BUILTIN_WIDGET_IDS.heading: {
|
|
348
|
+
return (
|
|
349
|
+
<div className="flex gap-2">
|
|
350
|
+
<Input
|
|
351
|
+
value={(config.text as string) ?? ""}
|
|
352
|
+
onChange={(e) => set({ text: e.target.value })}
|
|
353
|
+
placeholder="Heading text"
|
|
354
|
+
/>
|
|
355
|
+
<Select
|
|
356
|
+
value={String(config.level ?? 2)}
|
|
357
|
+
onValueChange={(v) => set({ level: Number(v) })}
|
|
358
|
+
>
|
|
359
|
+
<SelectTrigger className="w-24">
|
|
360
|
+
<SelectValue />
|
|
361
|
+
</SelectTrigger>
|
|
362
|
+
<SelectContent>
|
|
363
|
+
<SelectItem value="1">H1</SelectItem>
|
|
364
|
+
<SelectItem value="2">H2</SelectItem>
|
|
365
|
+
<SelectItem value="3">H3</SelectItem>
|
|
366
|
+
</SelectContent>
|
|
367
|
+
</Select>
|
|
368
|
+
</div>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
case BUILTIN_WIDGET_IDS.image: {
|
|
372
|
+
return (
|
|
373
|
+
<div className="space-y-2">
|
|
374
|
+
<Input
|
|
375
|
+
value={(config.url as string) ?? ""}
|
|
376
|
+
onChange={(e) => set({ url: e.target.value })}
|
|
377
|
+
placeholder="https://…/logo.png"
|
|
378
|
+
/>
|
|
379
|
+
<Input
|
|
380
|
+
value={(config.alt as string) ?? ""}
|
|
381
|
+
onChange={(e) => set({ alt: e.target.value })}
|
|
382
|
+
placeholder="Alt text"
|
|
383
|
+
/>
|
|
384
|
+
</div>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
case BUILTIN_WIDGET_IDS.groupStatus: {
|
|
388
|
+
return (
|
|
389
|
+
<Select
|
|
390
|
+
value={(config.groupId as string) || ""}
|
|
391
|
+
onValueChange={(v) => set({ groupId: v })}
|
|
392
|
+
>
|
|
393
|
+
<SelectTrigger>
|
|
394
|
+
<SelectValue placeholder="Select a group" />
|
|
395
|
+
</SelectTrigger>
|
|
396
|
+
<SelectContent>
|
|
397
|
+
{groups.length === 0 ? (
|
|
398
|
+
<SelectItem value="_none" disabled>
|
|
399
|
+
No groups available
|
|
400
|
+
</SelectItem>
|
|
401
|
+
) : (
|
|
402
|
+
groups.map((g) => (
|
|
403
|
+
<SelectItem key={g.id} value={g.id}>
|
|
404
|
+
{g.name}
|
|
405
|
+
</SelectItem>
|
|
406
|
+
))
|
|
407
|
+
)}
|
|
408
|
+
</SelectContent>
|
|
409
|
+
</Select>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
case BUILTIN_WIDGET_IDS.links: {
|
|
413
|
+
const links =
|
|
414
|
+
(config.links as Array<{ label: string; url: string }>) ?? [];
|
|
415
|
+
return (
|
|
416
|
+
<LinksConfigEditor links={links} onChange={(l) => set({ links: l })} />
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
case BUILTIN_WIDGET_IDS.divider: {
|
|
420
|
+
return <p className="text-xs text-muted-foreground">A horizontal divider.</p>;
|
|
421
|
+
}
|
|
422
|
+
default: {
|
|
423
|
+
return (
|
|
424
|
+
<p className="text-xs text-muted-foreground">
|
|
425
|
+
No inline editor for this widget yet.
|
|
426
|
+
</p>
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
export const StatusPageBuilderPage: React.FC = () => {
|
|
433
|
+
const { id = "" } = useParams();
|
|
434
|
+
const navigate = useNavigate();
|
|
435
|
+
const toast = useToast();
|
|
436
|
+
const client = usePluginClient(StatusPageApi);
|
|
437
|
+
const catalog = usePluginClient(CatalogApi);
|
|
438
|
+
|
|
439
|
+
const {
|
|
440
|
+
data: page,
|
|
441
|
+
isLoading,
|
|
442
|
+
isError,
|
|
443
|
+
error,
|
|
444
|
+
refetch,
|
|
445
|
+
} = client.getStatusPage.useQuery({ id }, { gcTime: 0 });
|
|
446
|
+
const { data: widgetTypesData } = client.listWidgetTypes.useQuery({});
|
|
447
|
+
const { data: systemsData } = catalog.getSystems.useQuery({});
|
|
448
|
+
const { data: groupsData } = catalog.getGroups.useQuery({});
|
|
449
|
+
const systems: SystemOption[] = systemsData?.systems ?? [];
|
|
450
|
+
const groups: GroupOption[] = groupsData ?? [];
|
|
451
|
+
const widgetTypes = widgetTypesData?.widgetTypes ?? [];
|
|
452
|
+
|
|
453
|
+
const [title, setTitle] = useState("");
|
|
454
|
+
const [slug, setSlug] = useState("");
|
|
455
|
+
const [visibility, setVisibility] = useState<StatusPageVisibility>("public");
|
|
456
|
+
const [brandColor, setBrandColor] = useState("");
|
|
457
|
+
const [logoUrl, setLogoUrl] = useState("");
|
|
458
|
+
const [blocks, setBlocks] = useState<StatusPageBlock[]>([]);
|
|
459
|
+
const [addType, setAddType] = useState("");
|
|
460
|
+
|
|
461
|
+
useInitOnceForKey(page ?? undefined, page?.id, (p) => {
|
|
462
|
+
setTitle(p.title);
|
|
463
|
+
setSlug(p.slug);
|
|
464
|
+
setVisibility(p.visibility);
|
|
465
|
+
setBrandColor(p.theme.brandColorHsl ?? "");
|
|
466
|
+
setLogoUrl(p.theme.logoUrl ?? "");
|
|
467
|
+
setBlocks(p.draftLayout);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const updateMutation = client.updateStatusPage.useMutation();
|
|
471
|
+
const publishMutation = client.publishStatusPage.useMutation();
|
|
472
|
+
const unpublishMutation = client.unpublishStatusPage.useMutation();
|
|
473
|
+
const busy =
|
|
474
|
+
updateMutation.isPending ||
|
|
475
|
+
publishMutation.isPending ||
|
|
476
|
+
unpublishMutation.isPending;
|
|
477
|
+
|
|
478
|
+
// Dirty = local edits diverge from the loaded snapshot. Drives the unsaved
|
|
479
|
+
// guard + Save/Publish enablement (there is no autosave).
|
|
480
|
+
const dirty = page
|
|
481
|
+
? JSON.stringify([title, slug, visibility, brandColor, logoUrl, blocks]) !==
|
|
482
|
+
JSON.stringify([
|
|
483
|
+
page.title,
|
|
484
|
+
page.slug,
|
|
485
|
+
page.visibility,
|
|
486
|
+
page.theme.brandColorHsl ?? "",
|
|
487
|
+
page.theme.logoUrl ?? "",
|
|
488
|
+
page.draftLayout,
|
|
489
|
+
])
|
|
490
|
+
: false;
|
|
491
|
+
|
|
492
|
+
React.useEffect(() => {
|
|
493
|
+
if (!dirty) return;
|
|
494
|
+
const handler = (e: BeforeUnloadEvent) => {
|
|
495
|
+
e.preventDefault();
|
|
496
|
+
e.returnValue = "";
|
|
497
|
+
};
|
|
498
|
+
window.addEventListener("beforeunload", handler);
|
|
499
|
+
return () => window.removeEventListener("beforeunload", handler);
|
|
500
|
+
}, [dirty]);
|
|
501
|
+
|
|
502
|
+
const buildPatch = () => ({
|
|
503
|
+
id,
|
|
504
|
+
title,
|
|
505
|
+
slug,
|
|
506
|
+
visibility,
|
|
507
|
+
theme: {
|
|
508
|
+
mode: "auto" as const,
|
|
509
|
+
...(brandColor ? { brandColorHsl: brandColor } : {}),
|
|
510
|
+
...(logoUrl ? { logoUrl } : {}),
|
|
511
|
+
},
|
|
512
|
+
draftLayout: blocks,
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const save = async () => {
|
|
516
|
+
try {
|
|
517
|
+
await updateMutation.mutateAsync(buildPatch());
|
|
518
|
+
toast.success("Saved");
|
|
519
|
+
} catch (error) {
|
|
520
|
+
toast.error(extractErrorMessage(error, "Couldn't save"));
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const publish = async () => {
|
|
525
|
+
// Publish always snapshots the current draft first, so save then publish —
|
|
526
|
+
// and if only the publish leg fails, say the save DID land.
|
|
527
|
+
try {
|
|
528
|
+
await updateMutation.mutateAsync(buildPatch());
|
|
529
|
+
} catch (error) {
|
|
530
|
+
toast.error(extractErrorMessage(error, "Couldn't save"));
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
await publishMutation.mutateAsync({ id });
|
|
535
|
+
toast.success("Published");
|
|
536
|
+
} catch (error) {
|
|
537
|
+
toast.error(extractErrorMessage(error, "Saved, but couldn't publish"));
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const move = (index: number, dir: -1 | 1) => {
|
|
542
|
+
const next = [...blocks];
|
|
543
|
+
const target = index + dir;
|
|
544
|
+
if (target < 0 || target >= next.length) return;
|
|
545
|
+
[next[index], next[target]] = [next[target], next[index]];
|
|
546
|
+
setBlocks(next);
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const addBlock = () => {
|
|
550
|
+
if (!addType) return;
|
|
551
|
+
setBlocks([
|
|
552
|
+
...blocks,
|
|
553
|
+
{ id: crypto.randomUUID(), type: addType, config: defaultConfig(addType) },
|
|
554
|
+
]);
|
|
555
|
+
setAddType("");
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
if (isLoading) {
|
|
559
|
+
return (
|
|
560
|
+
<div className="flex justify-center py-12">
|
|
561
|
+
<LoadingSpinner />
|
|
562
|
+
</div>
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
if (isError) {
|
|
566
|
+
return (
|
|
567
|
+
<PageLayout title="Status page" icon={MonitorCheck}>
|
|
568
|
+
<QueryErrorState error={error} onRetry={() => void refetch()} />
|
|
569
|
+
</PageLayout>
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
if (!page) {
|
|
573
|
+
return (
|
|
574
|
+
<PageLayout title="Status page" icon={MonitorCheck}>
|
|
575
|
+
<p className="text-sm text-muted-foreground">
|
|
576
|
+
This status page no longer exists.
|
|
577
|
+
</p>
|
|
578
|
+
</PageLayout>
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const goBack = () => {
|
|
583
|
+
if (
|
|
584
|
+
!dirty ||
|
|
585
|
+
globalThis.confirm("You have unsaved changes. Discard them and leave?")
|
|
586
|
+
) {
|
|
587
|
+
navigate(resolveRoute(statusPageRoutes.routes.list));
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
return (
|
|
592
|
+
<PageLayout
|
|
593
|
+
title={title || "Status page"}
|
|
594
|
+
icon={MonitorCheck}
|
|
595
|
+
subtitle={
|
|
596
|
+
page.published
|
|
597
|
+
? `Published${page.publishedAt ? ` · ${new Date(page.publishedAt).toLocaleString()}` : ""}${dirty ? " · unsaved changes" : ""}`
|
|
598
|
+
: dirty
|
|
599
|
+
? "Draft · unsaved changes"
|
|
600
|
+
: "Draft · not yet public"
|
|
601
|
+
}
|
|
602
|
+
actions={
|
|
603
|
+
<div className="flex items-center gap-2">
|
|
604
|
+
<Button variant="ghost" size="sm" onClick={goBack}>
|
|
605
|
+
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
|
606
|
+
</Button>
|
|
607
|
+
{page.published && (
|
|
608
|
+
<Button
|
|
609
|
+
variant="outline"
|
|
610
|
+
size="sm"
|
|
611
|
+
onClick={() => unpublishMutation.mutate({ id })}
|
|
612
|
+
disabled={busy}
|
|
613
|
+
>
|
|
614
|
+
<EyeOff className="mr-1.5 h-4 w-4" /> Unpublish
|
|
615
|
+
</Button>
|
|
616
|
+
)}
|
|
617
|
+
<Button
|
|
618
|
+
variant="outline"
|
|
619
|
+
size="sm"
|
|
620
|
+
onClick={save}
|
|
621
|
+
disabled={busy || !dirty}
|
|
622
|
+
>
|
|
623
|
+
<Save className="mr-1.5 h-4 w-4" /> Save draft
|
|
624
|
+
</Button>
|
|
625
|
+
<Button size="sm" onClick={publish} disabled={busy}>
|
|
626
|
+
<Send className="mr-1.5 h-4 w-4" />{" "}
|
|
627
|
+
{page.published ? "Publish changes" : "Publish"}
|
|
628
|
+
</Button>
|
|
629
|
+
</div>
|
|
630
|
+
}
|
|
631
|
+
>
|
|
632
|
+
<div className="grid gap-6 lg:grid-cols-[1fr_24rem]">
|
|
633
|
+
{/* Builder */}
|
|
634
|
+
<div className="space-y-4">
|
|
635
|
+
<p className="text-xs text-muted-foreground">
|
|
636
|
+
<strong>Save draft</strong> stores your changes privately.{" "}
|
|
637
|
+
<strong>Publish</strong> makes the current draft public at{" "}
|
|
638
|
+
<code>/status/{slug || "your-slug"}</code>.
|
|
639
|
+
</p>
|
|
640
|
+
<Card className="space-y-3 p-4">
|
|
641
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
642
|
+
<div className="space-y-1.5">
|
|
643
|
+
<Label>Title</Label>
|
|
644
|
+
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
|
|
645
|
+
</div>
|
|
646
|
+
<div className="space-y-1.5">
|
|
647
|
+
<Label>Slug</Label>
|
|
648
|
+
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
|
|
649
|
+
</div>
|
|
650
|
+
<div className="space-y-1.5">
|
|
651
|
+
<Label>Visibility</Label>
|
|
652
|
+
<Select
|
|
653
|
+
value={visibility}
|
|
654
|
+
onValueChange={(v) => setVisibility(v as StatusPageVisibility)}
|
|
655
|
+
>
|
|
656
|
+
<SelectTrigger>
|
|
657
|
+
<SelectValue />
|
|
658
|
+
</SelectTrigger>
|
|
659
|
+
<SelectContent>
|
|
660
|
+
<SelectItem value="public">Public (anyone)</SelectItem>
|
|
661
|
+
<SelectItem value="authenticated">
|
|
662
|
+
Internal (signed-in users)
|
|
663
|
+
</SelectItem>
|
|
664
|
+
</SelectContent>
|
|
665
|
+
</Select>
|
|
666
|
+
</div>
|
|
667
|
+
<div className="space-y-1.5">
|
|
668
|
+
<Label>Brand color (HSL)</Label>
|
|
669
|
+
<Input
|
|
670
|
+
value={brandColor}
|
|
671
|
+
onChange={(e) => setBrandColor(e.target.value)}
|
|
672
|
+
placeholder="262 83% 58%"
|
|
673
|
+
/>
|
|
674
|
+
</div>
|
|
675
|
+
<div className="space-y-1.5 sm:col-span-2">
|
|
676
|
+
<Label>Logo URL</Label>
|
|
677
|
+
<Input value={logoUrl} onChange={(e) => setLogoUrl(e.target.value)} />
|
|
678
|
+
</div>
|
|
679
|
+
</div>
|
|
680
|
+
</Card>
|
|
681
|
+
|
|
682
|
+
{blocks.map((block, i) => {
|
|
683
|
+
const descriptor = widgetTypes.find((w) => w.id === block.type);
|
|
684
|
+
return (
|
|
685
|
+
<Card key={block.id} className="space-y-2 p-3">
|
|
686
|
+
<div className="flex items-center justify-between">
|
|
687
|
+
<span className="text-sm font-medium">
|
|
688
|
+
{descriptor?.displayName ?? block.type}
|
|
689
|
+
</span>
|
|
690
|
+
<div className="flex items-center gap-1">
|
|
691
|
+
<Button variant="ghost" size="sm" onClick={() => move(i, -1)} aria-label="Move up">
|
|
692
|
+
<ArrowUp className="h-4 w-4" />
|
|
693
|
+
</Button>
|
|
694
|
+
<Button variant="ghost" size="sm" onClick={() => move(i, 1)} aria-label="Move down">
|
|
695
|
+
<ArrowDown className="h-4 w-4" />
|
|
696
|
+
</Button>
|
|
697
|
+
<Button
|
|
698
|
+
variant="ghost"
|
|
699
|
+
size="sm"
|
|
700
|
+
onClick={() => setBlocks(blocks.filter((b) => b.id !== block.id))}
|
|
701
|
+
aria-label="Remove"
|
|
702
|
+
>
|
|
703
|
+
<Trash2 className="h-4 w-4 text-destructive" />
|
|
704
|
+
</Button>
|
|
705
|
+
</div>
|
|
706
|
+
</div>
|
|
707
|
+
<Input
|
|
708
|
+
value={block.label ?? ""}
|
|
709
|
+
onChange={(e) =>
|
|
710
|
+
setBlocks(
|
|
711
|
+
blocks.map((b) =>
|
|
712
|
+
b.id === block.id
|
|
713
|
+
? { ...b, label: e.target.value || undefined }
|
|
714
|
+
: b,
|
|
715
|
+
),
|
|
716
|
+
)
|
|
717
|
+
}
|
|
718
|
+
placeholder="Block heading (optional)"
|
|
719
|
+
/>
|
|
720
|
+
<BlockConfigEditor
|
|
721
|
+
block={block}
|
|
722
|
+
systems={systems}
|
|
723
|
+
groups={groups}
|
|
724
|
+
onChange={(config) =>
|
|
725
|
+
setBlocks(
|
|
726
|
+
blocks.map((b) => (b.id === block.id ? { ...b, config } : b)),
|
|
727
|
+
)
|
|
728
|
+
}
|
|
729
|
+
/>
|
|
730
|
+
</Card>
|
|
731
|
+
);
|
|
732
|
+
})}
|
|
733
|
+
|
|
734
|
+
<Card className="flex items-center gap-2 border-dashed p-3">
|
|
735
|
+
<Select value={addType} onValueChange={setAddType}>
|
|
736
|
+
<SelectTrigger className="flex-1">
|
|
737
|
+
<SelectValue placeholder="Add a block…" />
|
|
738
|
+
</SelectTrigger>
|
|
739
|
+
<SelectContent>
|
|
740
|
+
{widgetTypes.map((w) => (
|
|
741
|
+
<SelectItem key={w.id} value={w.id}>
|
|
742
|
+
{w.displayName}
|
|
743
|
+
</SelectItem>
|
|
744
|
+
))}
|
|
745
|
+
</SelectContent>
|
|
746
|
+
</Select>
|
|
747
|
+
<Button onClick={addBlock} disabled={!addType}>
|
|
748
|
+
<Plus className="mr-1.5 h-4 w-4" /> Add
|
|
749
|
+
</Button>
|
|
750
|
+
</Card>
|
|
751
|
+
</div>
|
|
752
|
+
|
|
753
|
+
{/* Preview (structural; status widgets show live data on the public page) */}
|
|
754
|
+
<div className="space-y-3">
|
|
755
|
+
<div className="flex items-center gap-2">
|
|
756
|
+
<span className="text-sm font-semibold">Preview</span>
|
|
757
|
+
<Badge variant="outline">structural</Badge>
|
|
758
|
+
</div>
|
|
759
|
+
<div className="space-y-3 rounded-lg border bg-card p-4">
|
|
760
|
+
{blocks.length === 0 ? (
|
|
761
|
+
<p className="text-sm text-muted-foreground">
|
|
762
|
+
Add blocks to build your page.
|
|
763
|
+
</p>
|
|
764
|
+
) : (
|
|
765
|
+
blocks.map((b) => (
|
|
766
|
+
<PreviewBlock key={b.id} block={b} />
|
|
767
|
+
))
|
|
768
|
+
)}
|
|
769
|
+
</div>
|
|
770
|
+
{page.published && (
|
|
771
|
+
<a
|
|
772
|
+
href={resolveRoute(statusPublicRoutes.routes.page, { slug: page.slug })}
|
|
773
|
+
target="_blank"
|
|
774
|
+
rel="noopener noreferrer"
|
|
775
|
+
className="text-xs text-primary hover:underline"
|
|
776
|
+
>
|
|
777
|
+
Open published page →
|
|
778
|
+
</a>
|
|
779
|
+
)}
|
|
780
|
+
</div>
|
|
781
|
+
</div>
|
|
782
|
+
</PageLayout>
|
|
783
|
+
);
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Structural preview: content widgets render from config (their config IS their
|
|
788
|
+
* DTO); status widgets show a labelled placeholder (live data appears on the
|
|
789
|
+
* published page, which resolves through the secure public endpoint).
|
|
790
|
+
*/
|
|
791
|
+
const PreviewBlock: React.FC<{ block: StatusPageBlock }> = ({ block }) => {
|
|
792
|
+
const content = new Set<string>([
|
|
793
|
+
BUILTIN_WIDGET_IDS.text,
|
|
794
|
+
BUILTIN_WIDGET_IDS.heading,
|
|
795
|
+
BUILTIN_WIDGET_IDS.links,
|
|
796
|
+
BUILTIN_WIDGET_IDS.image,
|
|
797
|
+
BUILTIN_WIDGET_IDS.divider,
|
|
798
|
+
]);
|
|
799
|
+
if (content.has(block.type)) {
|
|
800
|
+
return (
|
|
801
|
+
<BlockRenderer block={{ id: block.id, type: block.type, label: block.label, data: block.config }} />
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
return (
|
|
805
|
+
<Card className="p-3 text-sm text-muted-foreground">
|
|
806
|
+
{block.label ?? block.type.replace("statuspage.", "")} (live on published page)
|
|
807
|
+
</Card>
|
|
808
|
+
);
|
|
809
|
+
};
|