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