@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.
@@ -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
+ };