Openstatus
www.openstatus.dev
1"use client";
2
3import { Link } from "@/components/common/link";
4import {
5 Section,
6 SectionDescription,
7 SectionGroup,
8 SectionGroupHeader,
9 SectionHeader,
10 SectionTitle,
11} from "@/components/content/section";
12import { recomputeStyles } from "@/components/status-page/floating-button";
13import {
14 Status,
15 StatusContent,
16 StatusDescription,
17 StatusHeader,
18 StatusTitle,
19} from "@/components/status-page/status";
20import { StatusBanner } from "@/components/status-page/status-banner";
21import {
22 StatusEvent,
23 StatusEventAffected,
24 StatusEventAffectedBadge,
25 StatusEventContent,
26 StatusEventDate,
27 StatusEventTimelineReport,
28 StatusEventTitle,
29} from "@/components/status-page/status-events";
30import { StatusMonitor } from "@/components/status-page/status-monitor";
31import { ThemePalettePicker } from "@/components/themes/theme-palette-picker";
32import { ThemeSelect } from "@/components/themes/theme-select";
33import { Button } from "@/components/ui/button";
34import { Input } from "@/components/ui/input";
35import { Separator } from "@/components/ui/separator";
36import { useSidebar } from "@/components/ui/sidebar";
37import { Skeleton } from "@/components/ui/skeleton";
38import {
39 Tooltip,
40 TooltipContent,
41 TooltipProvider,
42 TooltipTrigger,
43} from "@/components/ui/tooltip";
44import { monitors } from "@/data/monitors";
45import { useTRPC } from "@/lib/trpc/client";
46import { cn } from "@/lib/utils";
47import { THEMES, THEME_KEYS } from "@openstatus/theme-store";
48import { useQuery } from "@tanstack/react-query";
49import { useTheme } from "next-themes";
50import { useQueryStates } from "nuqs";
51import { useEffect, useState } from "react";
52import { searchParamsParsers } from "./search-params";
53
54const MAIN_COLORS = [
55 { key: "--primary", label: "Primary" },
56 { key: "--success", label: "Operational" },
57 { key: "--destructive", label: "Error" },
58 { key: "--warning", label: "Degraded" },
59 { key: "--info", label: "Maintenance" },
60] as const;
61
62// TODO: add keyboard navigation for selection?
63
64export function Client() {
65 const { resolvedTheme } = useTheme();
66 const [isMounted, setIsMounted] = useState(false);
67 const [{ q, t }, setSearchParams] = useQueryStates(searchParamsParsers);
68 const theme = t ? THEMES[t as keyof typeof THEMES] : undefined;
69 const { toggleSidebar } = useSidebar();
70
71 useEffect(() => {
72 setIsMounted(true);
73 }, []);
74
75 useEffect(() => {
76 if (isMounted && t) {
77 recomputeStyles(t);
78 }
79 }, [t, isMounted]);
80
81 return (
82 <SectionGroup>
83 <SectionGroupHeader>
84 <h1 className="font-bold text-2xl md:text-4xl">
85 Status Page Theme Explorer
86 </h1>
87 <h2 className="font-medium text-muted-foreground md:text-lg">
88 View all the openstatus themes for your status page and learn how to
89 create your own theme.
90 </h2>
91 </SectionGroupHeader>
92 <Section>
93 <SectionHeader>
94 <SectionTitle>Explorer</SectionTitle>
95 <SectionDescription>
96 Search for your favorite status page theme.{" "}
97 <Link href="#contribute-theme">Contribute your own?</Link>
98 </SectionDescription>
99 </SectionHeader>
100 <div className="sticky top-0 z-10 overflow-hidden rounded-lg border border-border bg-background outline-[3px] outline-background sm:relative">
101 <div className="relative">
102 <div className="absolute top-0 right-0 rounded-bl-lg border-border border-b border-l bg-muted/50 px-2 py-0.5 text-[10px]">
103 {theme?.name}
104 </div>
105 <div className="sm:p-8">
106 <ThemePlaygroundStatus className="scale-80 sm:scale-100" />
107 </div>
108 </div>
109 </div>
110 <div className="flex gap-3">
111 <ThemeSelect className="min-w-[125px] max-w-[125px]" />
112 <Input
113 placeholder={`Search from ${THEME_KEYS.length} themes`}
114 value={q ?? ""}
115 onChange={(e) => {
116 if (e.target.value.length === 0) {
117 setSearchParams({ q: null });
118 }
119 setSearchParams({ q: e.target.value.trim().toLowerCase() });
120 }}
121 />
122 <ThemePalettePicker />
123 </div>
124 <ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
125 {THEME_KEYS.filter((k) => {
126 const theme = THEMES[k];
127 return (
128 theme.author.name
129 .toLowerCase()
130 .includes(q?.toLowerCase() ?? "") ||
131 theme.name.toLowerCase().includes(q?.toLowerCase() ?? "")
132 );
133 }).map((k) => {
134 const theme = THEMES[k];
135 const style = isMounted
136 ? theme[resolvedTheme as "dark" | "light"]
137 : undefined;
138
139 return (
140 <li key={k} className="group/theme-card space-y-1.5">
141 <div
142 data-active={k === t}
143 data-slot="theme-card"
144 data-theme={k}
145 className="relative h-40 cursor-pointer overflow-hidden rounded-md border border-border outline-none transition-all focus:outline-ring/50 focus:ring-2 focus:ring-ring/50 data-[active=true]:border-ring data-[active=true]:outline-[3px] data-[active=true]:outline-ring/50"
146 onClick={() => setSearchParams({ t: k })}
147 role="button"
148 tabIndex={0}
149 onKeyDown={(e) => {
150 if (e.key === "Enter" || e.key === " ") {
151 setSearchParams({ t: k });
152 }
153 }}
154 >
155 {isMounted ? (
156 <div
157 className="absolute h-full w-full bg-background text-foreground"
158 style={style as React.CSSProperties}
159 inert
160 >
161 <ThemePlaygroundStatus className="pointer-events-none scale-80" />
162 </div>
163 ) : (
164 <Skeleton className="absolute h-full w-full" />
165 )}
166 </div>
167 <div className="flex items-start justify-between gap-2">
168 <div className="space-y-0.5 truncate">
169 <div className="truncate font-medium text-foreground text-sm leading-none">
170 {theme.name}
171 </div>
172 <div className="font-mono text-xs">
173 <Link
174 href={theme.author.url}
175 target="_blank"
176 rel="noopener noreferrer"
177 className="text-muted-foreground"
178 >
179 by {theme.author.name}
180 </Link>
181 </div>
182 </div>
183 <div className="flex gap-0.5">
184 {MAIN_COLORS.map((color) => {
185 const backgroundColor = style
186 ? style[color.key]
187 : undefined;
188
189 if (!isMounted) {
190 return (
191 <Skeleton
192 key={color.key}
193 className="size-3.5 rounded-sm"
194 />
195 );
196 }
197 return (
198 <TooltipProvider key={color.key}>
199 <Tooltip>
200 <TooltipTrigger>
201 <div
202 className="size-3.5 rounded-sm border bg-muted-foreground"
203 style={{ backgroundColor }}
204 />
205 </TooltipTrigger>
206 <TooltipContent>{color.label}</TooltipContent>
207 </Tooltip>
208 </TooltipProvider>
209 );
210 })}
211 </div>
212 </div>
213 </li>
214 );
215 })}
216 </ul>
217 </Section>
218 <Separator />
219 <Section>
220 <SectionHeader id="contribute-theme">
221 <SectionTitle>Contribute Theme</SectionTitle>
222 <SectionDescription>
223 Contribute your own theme to the community.
224 </SectionDescription>
225 </SectionHeader>
226 <div className="prose dark:prose-invert prose-sm max-w-none">
227 <p>
228 You can contribute your own theme by creating a new file in the{" "}
229 <code>@openstatus/theme-store</code> package. You'll only need
230 to override css variables. If you are familiar with shadcn, you'll
231 know the trick (it also allows you to override `--radius`). Make
232 sure your object is satisfying the <code>Theme</code> interface. We
233 provide a theme builder to help you with the process.
234 </p>
235 <Button onClick={toggleSidebar}>Toggle Theme Builder</Button>
236 <p>
237 Go to the{" "}
238 <Link href="https://github.com/openstatusHQ/openstatus/tree/main/packages/theme-store">
239 GitHub directory
240 </Link>{" "}
241 to see the existing themes and create a new one by forking and
242 creating a pull request.
243 </p>
244 <p>
245 Once you're done, you can test it by adding the following snippet to
246 your status page:
247 </p>
248 <pre>
249 <code>sessionStorage.setItem("community-theme", "true");</code>
250 </pre>
251 <p>
252 Or use the following button to test it on the `status` page slug:
253 </p>
254 <Button
255 onClick={() => {
256 // NOTE: we use it to display the 'floating-theme' component
257 sessionStorage.setItem("community-theme", "true");
258 window.location.href = "/status";
259 }}
260 >
261 Test it
262 </Button>
263 {/* TODO: OR go to the status-page config and click on the View and Configure button */}
264 </div>
265 </Section>
266 <Separator />
267 <Section>
268 <div className="prose dark:prose-invert prose-sm max-w-none">
269 <p>
270 Why don't we allow custom css styles to be overridden and only
271 support themes?
272 </p>
273 <ul>
274 <li>Keep it simple for the user</li>
275 <li>Don't end up with a xmas tree</li>
276 <li>Keep the theme consistent</li>
277 <li>Avoid conflicts with other styles</li>
278 <li>
279 Keep the theme maintainable (but this will also mean, a change
280 will affect all users)
281 </li>
282 </ul>
283 </div>
284 </Section>
285 </SectionGroup>
286 );
287}
288
289function ThemePlaygroundStatus({
290 className,
291 ...props
292}: React.ComponentProps<"div"> & {}) {
293 const trpc = useTRPC();
294 const { data: uptimeData, isLoading } = useQuery(
295 trpc.statusPage.getNoopUptime.queryOptions(),
296 );
297 return (
298 // NOTE: we use pointer-events-none to prevent the hover card or tooltip from being interactive - the Portal container is document body and we loose the styles
299 <div className={cn("h-full w-full", className)} {...props}>
300 <Status variant="success">
301 <StatusHeader>
302 <StatusTitle>Acme Inc.</StatusTitle>
303 <StatusDescription>
304 Get informed about our services.
305 </StatusDescription>
306 </StatusHeader>
307 <StatusBanner status="success" />
308 <StatusContent>
309 {/* TODO: create mock data */}
310 <StatusMonitor
311 status="success"
312 data={uptimeData?.data || []}
313 monitor={monitors[0]}
314 showUptime={true}
315 uptime={uptimeData?.uptime}
316 isLoading={isLoading}
317 />
318 </StatusContent>
319 </Status>
320 </div>
321 );
322}
323
324// NOTE: we could add a tabs component here to switch between status and events
325function ThemePlaygroundEvents({
326 className,
327 ...props
328}: React.ComponentProps<"div"> & {}) {
329 const trpc = useTRPC();
330 const { data: report } = useQuery(
331 trpc.statusPage.getNoopReport.queryOptions(),
332 );
333 const firstUpdate = report?.statusReportUpdates[0];
334
335 if (!firstUpdate || !report) return null;
336
337 return (
338 <div className={cn("h-full w-full", className)} {...props}>
339 <Status variant="success">
340 <StatusEvent>
341 <StatusEventDate date={firstUpdate.date} className="lg:flex-row" />
342 <StatusEventContent hoverable={false}>
343 <StatusEventTitle className="inline-flex gap-1">
344 {report.title}
345 </StatusEventTitle>
346 {report.statusReportsToPageComponents.length > 0 ? (
347 <StatusEventAffected>
348 {report.statusReportsToPageComponents.map((affected) => (
349 <StatusEventAffectedBadge key={affected.pageComponent.id}>
350 {affected.pageComponent.name}
351 </StatusEventAffectedBadge>
352 ))}
353 </StatusEventAffected>
354 ) : null}
355 <StatusEventTimelineReport
356 updates={report.statusReportUpdates}
357 reportId={report.id}
358 />
359 </StatusEventContent>
360 </StatusEvent>
361 </Status>
362 </div>
363 );
364}