@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,249 @@
1
+ import React, { useState } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import {
4
+ PageLayout,
5
+ Button,
6
+ Card,
7
+ Input,
8
+ Label,
9
+ Badge,
10
+ EmptyState,
11
+ LoadingSpinner,
12
+ Dialog,
13
+ DialogContent,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ DialogFooter,
17
+ useToast,
18
+ ConfirmationModal,
19
+ } from "@checkstack/ui";
20
+ import { Plus, ExternalLink, Trash2, Pencil, MonitorCheck } from "lucide-react";
21
+ import {
22
+ usePluginClient,
23
+ useApi,
24
+ accessApiRef,
25
+ } from "@checkstack/frontend-api";
26
+ import { resolveRoute, extractErrorMessage } from "@checkstack/common";
27
+ import {
28
+ StatusPageApi,
29
+ statusPageAccess,
30
+ statusPageRoutes,
31
+ statusPublicRoutes,
32
+ } from "@checkstack/status-page-common";
33
+ import { TeamOwnershipPicker } from "@checkstack/auth-frontend";
34
+
35
+ const slugify = (value: string) =>
36
+ value
37
+ .toLowerCase()
38
+ .trim()
39
+ .replaceAll(/[^a-z0-9]+/g, "-")
40
+ .replaceAll(/^-+|-+$/g, "");
41
+
42
+ export const StatusPagesListPage: React.FC = () => {
43
+ const client = usePluginClient(StatusPageApi);
44
+ const navigate = useNavigate();
45
+ const toast = useToast();
46
+ const accessApi = useApi(accessApiRef);
47
+ const { allowed: canCreateGlobal } = accessApi.useAccess(
48
+ statusPageAccess.page.manage,
49
+ );
50
+
51
+ const { data, isLoading } = client.listStatusPages.useQuery({});
52
+ const pages = data?.pages ?? [];
53
+
54
+ const [creating, setCreating] = useState(false);
55
+ const [title, setTitle] = useState("");
56
+ const [slug, setSlug] = useState("");
57
+ // The slug auto-follows the title UNTIL the user edits the slug themselves.
58
+ const [slugEdited, setSlugEdited] = useState(false);
59
+ const [teamId, setTeamId] = useState<string | null>(null);
60
+ const [createError, setCreateError] = useState<string | null>(null);
61
+ const [deleteId, setDeleteId] = useState<string | null>(null);
62
+
63
+ const createMutation = client.createStatusPage.useMutation({
64
+ onSuccess: (page) => {
65
+ setCreating(false);
66
+ setTitle("");
67
+ setSlug("");
68
+ setSlugEdited(false);
69
+ navigate(resolveRoute(statusPageRoutes.routes.builder, { id: page.id }));
70
+ },
71
+ onError: (e) => setCreateError(extractErrorMessage(e, "Couldn't create page")),
72
+ });
73
+ const deleteMutation = client.deleteStatusPage.useMutation({
74
+ onSuccess: () => {
75
+ toast.success("Status page deleted");
76
+ setDeleteId(null);
77
+ },
78
+ onError: (e) => toast.error(extractErrorMessage(e, "Couldn't delete")),
79
+ });
80
+
81
+ return (
82
+ <PageLayout
83
+ title="Status pages"
84
+ icon={MonitorCheck}
85
+ actions={
86
+ <Button
87
+ onClick={() => {
88
+ setTitle("");
89
+ setSlug("");
90
+ setSlugEdited(false);
91
+ setTeamId(null);
92
+ setCreateError(null);
93
+ setCreating(true);
94
+ }}
95
+ >
96
+ <Plus className="mr-1.5 h-4 w-4" /> New status page
97
+ </Button>
98
+ }
99
+ >
100
+ {isLoading ? (
101
+ <div className="flex justify-center py-12">
102
+ <LoadingSpinner />
103
+ </div>
104
+ ) : pages.length === 0 ? (
105
+ <EmptyState
106
+ title="No status pages yet"
107
+ description="Build a public page from health, incident, and maintenance widgets."
108
+ />
109
+ ) : (
110
+ <div className="space-y-2">
111
+ {pages.map((page) => (
112
+ <Card
113
+ key={page.id}
114
+ className="flex items-center justify-between gap-3 p-3"
115
+ >
116
+ <div className="min-w-0">
117
+ <div className="flex items-center gap-2">
118
+ <span className="font-medium">{page.title}</span>
119
+ {page.published ? (
120
+ <Badge variant="secondary">Published</Badge>
121
+ ) : (
122
+ <Badge variant="outline">Draft</Badge>
123
+ )}
124
+ {page.visibility === "authenticated" && (
125
+ <Badge variant="outline">Internal</Badge>
126
+ )}
127
+ </div>
128
+ <span className="text-xs text-muted-foreground">/{page.slug}</span>
129
+ </div>
130
+ <div className="flex items-center gap-1">
131
+ {page.published && (
132
+ <Button
133
+ variant="ghost"
134
+ size="sm"
135
+ onClick={() =>
136
+ window.open(
137
+ resolveRoute(statusPublicRoutes.routes.page, {
138
+ slug: page.slug,
139
+ }),
140
+ "_blank",
141
+ )
142
+ }
143
+ aria-label="View public page"
144
+ >
145
+ <ExternalLink className="h-4 w-4" />
146
+ </Button>
147
+ )}
148
+ <Button
149
+ variant="ghost"
150
+ size="sm"
151
+ onClick={() =>
152
+ navigate(
153
+ resolveRoute(statusPageRoutes.routes.builder, {
154
+ id: page.id,
155
+ }),
156
+ )
157
+ }
158
+ aria-label="Edit"
159
+ >
160
+ <Pencil className="h-4 w-4" />
161
+ </Button>
162
+ <Button
163
+ variant="ghost"
164
+ size="sm"
165
+ onClick={() => setDeleteId(page.id)}
166
+ aria-label="Delete"
167
+ >
168
+ <Trash2 className="h-4 w-4 text-destructive" />
169
+ </Button>
170
+ </div>
171
+ </Card>
172
+ ))}
173
+ </div>
174
+ )}
175
+
176
+ <Dialog open={creating} onOpenChange={setCreating}>
177
+ <DialogContent>
178
+ <DialogHeader>
179
+ <DialogTitle>New status page</DialogTitle>
180
+ </DialogHeader>
181
+ <div className="space-y-4 py-2">
182
+ <div className="space-y-1.5">
183
+ <Label>Title</Label>
184
+ <Input
185
+ value={title}
186
+ onChange={(e) => {
187
+ setTitle(e.target.value);
188
+ if (!slugEdited) setSlug(slugify(e.target.value));
189
+ }}
190
+ placeholder="Acme Status"
191
+ />
192
+ </div>
193
+ <div className="space-y-1.5">
194
+ <Label>Slug</Label>
195
+ <Input
196
+ value={slug}
197
+ onChange={(e) => {
198
+ setSlugEdited(true);
199
+ setSlug(slugify(e.target.value));
200
+ }}
201
+ placeholder="acme"
202
+ />
203
+ <p className="text-xs text-muted-foreground">
204
+ Public URL: /status/{slug || "your-slug"}
205
+ </p>
206
+ </div>
207
+ <TeamOwnershipPicker
208
+ value={teamId}
209
+ onChange={setTeamId}
210
+ allowGlobal={canCreateGlobal}
211
+ />
212
+ {createError && (
213
+ <p className="text-sm text-destructive">{createError}</p>
214
+ )}
215
+ </div>
216
+ <DialogFooter>
217
+ <Button variant="outline" onClick={() => setCreating(false)}>
218
+ Cancel
219
+ </Button>
220
+ <Button
221
+ disabled={!title || !slug || createMutation.isPending}
222
+ onClick={() => {
223
+ setCreateError(null);
224
+ createMutation.mutate({
225
+ title,
226
+ slug,
227
+ ...(teamId ? { teamId } : {}),
228
+ });
229
+ }}
230
+ >
231
+ Create
232
+ </Button>
233
+ </DialogFooter>
234
+ </DialogContent>
235
+ </Dialog>
236
+
237
+ <ConfirmationModal
238
+ isOpen={deleteId !== null}
239
+ onClose={() => setDeleteId(null)}
240
+ title="Delete status page?"
241
+ message="This permanently deletes the page and unpublishes it."
242
+ confirmText="Delete"
243
+ variant="danger"
244
+ isLoading={deleteMutation.isPending}
245
+ onConfirm={() => deleteId && deleteMutation.mutate({ id: deleteId })}
246
+ />
247
+ </PageLayout>
248
+ );
249
+ };