Openstatus www.openstatus.dev

feat: nerd mode (#1379)

* feat: nerd mode

* fix: tabs width

* chore: enable floating button

* feat: font commit mono

* fix: format

* chore: small stuff

* chore: leading

* chore: check

* chore: title truncate

* fix: class

* fix: format

* wip:

* chore: improve tab outline style

* fix: toast

* fix: use pathname instead of url for protected redirect

* fix: format

* feat: radius in floating button

* chore: community-themes

* wip:

* chore: up down keyboard navigation

* chore: theme store

* chore: community themes

* fix: build

* chore: floating theme session storage

* fix: radius xs

* fix: status monitor title leading

* chore: status updates

* chore: blank state

authored by

Maximilian Kaske and committed by
GitHub
f545d061 56104b5c

+1260 -385
+1
apps/dashboard/next-env.d.ts
··· 1 1 /// <reference types="next" /> 2 2 /// <reference types="next/image-types/global" /> 3 + /// <reference path="./.next/types/routes.d.ts" /> 3 4 4 5 // NOTE: This file should not be edited 5 6 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -1
apps/dashboard/src/components/forms/status-page/form-configuration.tsx
··· 329 329 <FormCardFooterInfo> 330 330 Learn more about{" "} 331 331 <Link 332 - href="https://docs.openstatus.dev/" 332 + href="https://docs.openstatus.dev/tutorial/how-to-configure-status-page" 333 333 rel="noreferrer" 334 334 target="_blank" 335 335 >
+2 -1
apps/dashboard/src/components/forms/status-page/update.tsx
··· 140 140 }} 141 141 /> 142 142 {/* TODO: feature flagged - remove once we have the new version in production */} 143 - {process.env.NEXT_PUBLIC_STATUS_PAGE_V2 === "true" ? ( 143 + {process.env.NEXT_PUBLIC_STATUS_PAGE_V2 === "true" || 144 + process.env.NODE_ENV === "development" ? ( 144 145 <FormConfiguration 145 146 defaultValues={{ 146 147 new: !statusPage.legacyPage,
+1
apps/status-page/next-env.d.ts
··· 1 1 /// <reference types="next" /> 2 2 /// <reference types="next/image-types/global" /> 3 + /// <reference path="./.next/types/routes.d.ts" /> 3 4 4 5 // NOTE: This file should not be edited 5 6 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1
apps/status-page/package.json
··· 97 97 }, 98 98 "devDependencies": { 99 99 "@tailwindcss/postcss": "4.1.11", 100 + "@tailwindcss/typography": "0.5.10", 100 101 "@types/dom-speech-recognition": "0.0.6", 101 102 "@types/node": "24.0.8", 102 103 "@types/react": "19.1.13",
apps/status-page/public/fonts/CommitMono-400-Italic.otf

This is a binary file and will not be displayed.

apps/status-page/public/fonts/CommitMono-400-Regular.otf

This is a binary file and will not be displayed.

apps/status-page/public/fonts/CommitMono-700-Italic.otf

This is a binary file and will not be displayed.

apps/status-page/public/fonts/CommitMono-700-Regular.otf

This is a binary file and will not be displayed.

+8 -2
apps/status-page/src/app/(public)/layout.tsx
··· 1 + import { Link } from "@/components/common/link"; 1 2 import { ThemeProvider } from "@/components/theme-provider"; 2 3 import { Toaster } from "@/components/ui/sonner"; 3 4 ··· 9 10 return ( 10 11 <ThemeProvider 11 12 attribute="class" 12 - defaultTheme="system" 13 + defaultTheme="light" 13 14 enableSystem 14 15 disableTransitionOnChange 15 16 > 16 - {children} 17 + <main>{children}</main> 18 + <footer className="flex items-center justify-center gap-4 p-4 text-center font-mono text-muted-foreground text-sm"> 19 + <p> 20 + powered by <Link href="https://openstatus.dev">openstatus</Link> 21 + </p> 22 + </footer> 17 23 <Toaster richColors expand /> 18 24 </ThemeProvider> 19 25 );
+87 -34
apps/status-page/src/app/(public)/page.tsx
··· 1 1 "use client"; 2 2 3 + import { Link } from "@/components/common/link"; 3 4 import { 4 5 Section, 5 6 SectionDescription, ··· 7 8 SectionHeader, 8 9 SectionTitle, 9 10 } from "@/components/content/section"; 10 - import { THEMES } from "@/components/status-page/community-themes"; 11 - import { COMMUNITY_THEME } from "@/components/status-page/floating-button"; 12 11 import { 13 12 Status, 14 13 StatusContent, ··· 18 17 } from "@/components/status-page/status"; 19 18 import { StatusBanner } from "@/components/status-page/status-banner"; 20 19 import { StatusMonitor } from "@/components/status-page/status-monitor"; 20 + import { Button } from "@/components/ui/button"; 21 21 import { monitors } from "@/data/monitors"; 22 + import { THEMES, THEME_KEYS } from "@/lib/community-themes"; 22 23 import { useTRPC } from "@/lib/trpc/client"; 23 24 import { cn } from "@/lib/utils"; 24 25 import { useQuery } from "@tanstack/react-query"; ··· 30 31 <SectionHeader> 31 32 <SectionTitle>Status Page Themes</SectionTitle> 32 33 <SectionDescription> 33 - View all the current themes you can use. Or contribute your own one. 34 + View all the current themes you can use.{" "} 35 + <Link href="#contribute-theme">Contribute your own?</Link> 34 36 </SectionDescription> 35 37 </SectionHeader> 36 38 <div className="flex flex-col gap-4"> 37 - {COMMUNITY_THEME.filter((theme) => theme !== "default").map( 38 - (theme) => { 39 - const t = THEMES[theme]; 40 - return ( 41 - <div key={theme} className="flex flex-col gap-2"> 42 - <ThemeHeader> 43 - <ThemeTitle>{t.name}</ThemeTitle> 44 - <ThemeAuthor> 45 - by{" "} 46 - <a 47 - href={t.author.url} 48 - target="_blank" 49 - rel="noopener noreferrer" 50 - > 51 - {t.author.name} 52 - </a> 53 - </ThemeAuthor> 54 - </ThemeHeader> 55 - <ThemeGroup> 56 - <ThemeCard theme={theme} mode="light" /> 57 - <ThemeCard theme={theme} mode="dark" /> 58 - </ThemeGroup> 59 - </div> 60 - ); 61 - }, 62 - )} 39 + {THEME_KEYS.map((theme) => { 40 + const t = THEMES[theme]; 41 + return ( 42 + <div key={theme} className="flex flex-col gap-2"> 43 + <ThemeHeader> 44 + <ThemeTitle>{t.name}</ThemeTitle> 45 + <ThemeAuthor> 46 + by{" "} 47 + <a 48 + href={t.author.url} 49 + target="_blank" 50 + rel="noopener noreferrer" 51 + > 52 + {t.author.name} 53 + </a> 54 + </ThemeAuthor> 55 + </ThemeHeader> 56 + <ThemeGroup> 57 + <ThemeCard theme={theme} mode="light" /> 58 + <ThemeCard theme={theme} mode="dark" /> 59 + </ThemeGroup> 60 + </div> 61 + ); 62 + })} 63 + </div> 64 + </Section> 65 + <Section> 66 + <SectionHeader id="contribute-theme"> 67 + <SectionTitle>Contribute Theme</SectionTitle> 68 + <SectionDescription> 69 + Contribute your own theme to the community. 70 + </SectionDescription> 71 + </SectionHeader> 72 + <div className="prose dark:prose-invert prose-sm max-w-none"> 73 + <p> 74 + You can contribute your own theme by creating a new file in the{" "} 75 + <code>src/lib/community-themes</code> directory. You&apos;ll only 76 + need to override css variables. Make sure your object is satisfiying 77 + the <code>Theme</code> interface. 78 + </p> 79 + <p> 80 + Go to the{" "} 81 + <Link href="https://github.com/openstatus-dev/status-page/blob/main/src/lib/community-themes"> 82 + GitHub directory 83 + </Link>{" "} 84 + to see the existing themes and create a new one by forking and 85 + creating a pull request. 86 + </p> 87 + <Button 88 + onClick={() => { 89 + // NOTE: we use it to display the 'floating-theme' component 90 + sessionStorage.setItem("community-theme", "true"); 91 + window.location.href = "/status"; 92 + }} 93 + > 94 + Test it 95 + </Button> 96 + <hr /> 97 + <p> 98 + Why don't we allow custom css styles to be overridden and only 99 + support themes? 100 + </p> 101 + <ul> 102 + <li>Keep it simple for the user</li> 103 + <li>Don't end up with a xmas tree</li> 104 + <li>Keep the theme consistent</li> 105 + <li>Avoid conflicts with other styles</li> 106 + <li> 107 + Keep the theme maintainable (but this will also mean, a change 108 + will affect all users) 109 + </li> 110 + </ul> 63 111 </div> 64 112 </Section> 65 113 </SectionGroup> ··· 81 129 trpc.statusPage.getNoopUptime.queryOptions(), 82 130 ); 83 131 return ( 84 - <div className="group/theme-card overflow-hidden rounded-lg border"> 132 + <div 133 + className={cn( 134 + "group/theme-card overflow-hidden rounded-lg border", 135 + mode === "dark" ? "dark" : "", 136 + )} 137 + > 85 138 <div 86 - style={{ 87 - ...t, 88 - }} 139 + style={t as React.CSSProperties} 89 140 className="h-full w-full bg-background" 90 141 > 91 142 {/* 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 */} ··· 129 180 } 130 181 131 182 function ThemeTitle({ children, className }: React.ComponentProps<"div">) { 132 - return <div className={cn("font-bold text-base", className)}>{children}</div>; 183 + return ( 184 + <div className={cn("font-semibold text-base", className)}>{children}</div> 185 + ); 133 186 } 134 187 135 188 function ThemeAuthor({ children, className }: React.ComponentProps<"div">) {
+42 -23
apps/status-page/src/app/(status-page)/[domain]/(public)/events/(list)/page.tsx
··· 1 1 "use client"; 2 2 3 3 import { 4 + StatusBlankContainer, 5 + StatusBlankContent, 6 + StatusBlankDescription, 7 + StatusBlankReport, 8 + StatusBlankTitle, 9 + } from "@/components/status-page/status-blank"; 10 + import { 4 11 StatusEvent, 5 12 StatusEventAffected, 6 13 StatusEventAside, ··· 9 16 StatusEventTimelineReport, 10 17 StatusEventTitle, 11 18 } from "@/components/status-page/status-events"; 12 - import { useTRPC } from "@/lib/trpc/client"; 13 - import { useQuery } from "@tanstack/react-query"; 14 - import { useParams } from "next/navigation"; 15 - 16 - import { 17 - StatusEmptyState, 18 - StatusEmptyStateDescription, 19 - StatusEmptyStateTitle, 20 - } from "@/components/status-page/status"; 21 19 import { Badge } from "@/components/ui/badge"; 22 20 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 23 21 import { formatDate } from "@/lib/formatter"; 24 - import { CircleCheck } from "lucide-react"; 22 + import { useTRPC } from "@/lib/trpc/client"; 23 + import { useQuery } from "@tanstack/react-query"; 24 + import { Check } from "lucide-react"; 25 25 import Link from "next/link"; 26 + import { useParams } from "next/navigation"; 26 27 27 28 // TODO: include ?filter=maintenance/reports 28 29 ··· 71 72 <StatusEventTitle className="inline-flex gap-1"> 72 73 {report.title} 73 74 {isReportResolvedOnly ? ( 74 - <CircleCheck className="size-4 text-success shrink-0 mt-1 ml-1.5" /> 75 + <div className="mt-1 ml-1.5"> 76 + <div className="rounded-full border border-success/20 bg-success/10 p-0.5 text-success"> 77 + <Check className="size-3 shrink-0" /> 78 + </div> 79 + </div> 75 80 ) : null} 76 81 </StatusEventTitle> 77 82 {report.monitorsToStatusReports.length > 0 ? ( ··· 96 101 ); 97 102 }) 98 103 ) : ( 99 - <StatusEmptyState> 100 - <StatusEmptyStateTitle>No reports found</StatusEmptyStateTitle> 101 - <StatusEmptyStateDescription> 102 - No reports found for this status page. 103 - </StatusEmptyStateDescription> 104 - </StatusEmptyState> 104 + <StatusBlankContainer> 105 + <div className="relative mt-8 flex w-full flex-col items-center justify-center"> 106 + <StatusBlankReport className="-top-16 absolute scale-60 opacity-50" /> 107 + <StatusBlankReport className="-top-8 absolute scale-80 opacity-80" /> 108 + <StatusBlankReport /> 109 + </div> 110 + <StatusBlankContent> 111 + <StatusBlankTitle>No reports found</StatusBlankTitle> 112 + <StatusBlankDescription> 113 + No reports found for this status page. 114 + </StatusBlankDescription> 115 + </StatusBlankContent> 116 + </StatusBlankContainer> 105 117 )} 106 118 </TabsContent> 107 119 <TabsContent value="maintenances" className="flex flex-col gap-4"> ··· 144 156 ); 145 157 }) 146 158 ) : ( 147 - <StatusEmptyState> 148 - <StatusEmptyStateTitle>No maintenances found</StatusEmptyStateTitle> 149 - <StatusEmptyStateDescription> 150 - No maintenances found for this status page. 151 - </StatusEmptyStateDescription> 152 - </StatusEmptyState> 159 + <StatusBlankContainer> 160 + <div className="relative mt-8 flex w-full flex-col items-center justify-center"> 161 + <StatusBlankReport className="-top-16 absolute scale-60 opacity-50" /> 162 + <StatusBlankReport className="-top-8 absolute scale-80 opacity-80" /> 163 + <StatusBlankReport /> 164 + </div> 165 + <StatusBlankContent> 166 + <StatusBlankTitle>No maintenances found</StatusBlankTitle> 167 + <StatusBlankDescription> 168 + No maintenances found for this status page. 169 + </StatusBlankDescription> 170 + </StatusBlankContent> 171 + </StatusBlankContainer> 153 172 )} 154 173 </TabsContent> 155 174 </Tabs>
+21 -10
apps/status-page/src/app/(status-page)/[domain]/(public)/monitors/page.tsx
··· 8 8 Status, 9 9 StatusContent, 10 10 StatusDescription, 11 - StatusEmptyState, 12 - StatusEmptyStateDescription, 13 - StatusEmptyStateTitle, 14 11 StatusHeader, 15 12 StatusTitle, 16 13 } from "@/components/status-page/status"; 14 + import { 15 + StatusBlankContainer, 16 + StatusBlankContent, 17 + StatusBlankDescription, 18 + StatusBlankMonitor, 19 + StatusBlankTitle, 20 + } from "@/components/status-page/status-blank"; 17 21 import { StatusMonitorTitle } from "@/components/status-page/status-monitor"; 18 22 import { StatusMonitorDescription } from "@/components/status-page/status-monitor"; 19 23 import { useTRPC } from "@/lib/trpc/client"; ··· 69 73 className="rounded-lg" 70 74 > 71 75 <div className="group -mx-3 -my-2 flex flex-col gap-2 rounded-lg border border-transparent px-3 py-2 hover:border-border/50 hover:bg-muted/50"> 72 - <div className="flex flex-row items-center gap-2"> 76 + <div className="flex flex-row items-center justify-start gap-2"> 73 77 <StatusMonitorTitle>{monitor.name}</StatusMonitorTitle> 74 78 <StatusMonitorDescription> 75 79 {monitor.description} ··· 90 94 ); 91 95 }) 92 96 ) : ( 93 - <StatusEmptyState> 94 - <StatusEmptyStateTitle>No public monitors</StatusEmptyStateTitle> 95 - <StatusEmptyStateDescription> 96 - No public monitors have been added to this page. 97 - </StatusEmptyStateDescription> 98 - </StatusEmptyState> 97 + <StatusBlankContainer> 98 + <div className="relative mt-8 flex w-full flex-col items-center justify-center"> 99 + <StatusBlankMonitor className="-top-16 absolute scale-60 opacity-50" /> 100 + <StatusBlankMonitor className="-top-8 absolute scale-80 opacity-80" /> 101 + <StatusBlankMonitor /> 102 + </div> 103 + <StatusBlankContent> 104 + <StatusBlankTitle>No public monitors</StatusBlankTitle> 105 + <StatusBlankDescription> 106 + No public monitors have been added to this page. 107 + </StatusBlankDescription> 108 + </StatusBlankContent> 109 + </StatusBlankContainer> 99 110 )} 100 111 </StatusContent> 101 112 </Status>
+73 -54
apps/status-page/src/app/(status-page)/[domain]/(public)/page.tsx
··· 12 12 StatusBanner, 13 13 StatusBannerContainer, 14 14 StatusBannerContent, 15 - StatusBannerTitle, 15 + StatusBannerTabs, 16 + StatusBannerTabsContent, 17 + StatusBannerTabsList, 18 + StatusBannerTabsTrigger, 16 19 } from "@/components/status-page/status-banner"; 17 20 import { 18 21 StatusEventTimelineMaintenance, ··· 22 25 import { StatusMonitor } from "@/components/status-page/status-monitor"; 23 26 import { Separator } from "@/components/ui/separator"; 24 27 import { useTRPC } from "@/lib/trpc/client"; 28 + import { cn } from "@/lib/utils"; 25 29 import { useQuery } from "@tanstack/react-query"; 26 - import Link from "next/link"; 27 30 import { useParams } from "next/navigation"; 28 31 29 32 export default function Page() { ··· 55 58 </StatusHeader> 56 59 {page.openEvents.length > 0 ? ( 57 60 <StatusContent> 58 - {page.openEvents.map((e) => { 59 - if (e.type === "maintenance") { 60 - const maintenance = page.maintenances.find( 61 - (maintenance) => maintenance.id === e.id, 62 - ); 63 - if (!maintenance) return null; 64 - return ( 65 - <Link 66 - href={`./events/maintenance/${e.id}`} 67 - key={e.id} 68 - className="rounded-lg" 69 - > 70 - <StatusBannerContainer status={e.status}> 71 - <StatusBannerTitle>{e.name}</StatusBannerTitle> 72 - <StatusBannerContent> 73 - <StatusEventTimelineMaintenance 74 - maintenance={maintenance} 75 - withDot={false} 76 - /> 77 - </StatusBannerContent> 78 - </StatusBannerContainer> 79 - </Link> 80 - ); 81 - } 82 - if (e.type === "report") { 83 - const report = page.statusReports.find( 84 - (report) => report.id === e.id, 85 - ); 86 - if (!report) return null; 87 - return ( 88 - <Link 89 - href={`./events/report/${e.id}`} 90 - key={e.id} 91 - className="rounded-lg" 92 - > 93 - <StatusBannerContainer status={e.status}> 94 - <StatusBannerTitle>{e.name}</StatusBannerTitle> 95 - <StatusBannerContent> 96 - <StatusEventTimelineReport 97 - updates={report.statusReportUpdates} 98 - withDot={false} 99 - /> 100 - </StatusBannerContent> 101 - </StatusBannerContainer> 102 - </Link> 103 - ); 104 - } 105 - return null; 106 - })} 61 + <StatusBannerTabs 62 + defaultValue={`${page.openEvents[0].type}-${page.openEvents[0].id}`} 63 + > 64 + <StatusBannerTabsList> 65 + {page.openEvents.map((e, i) => { 66 + return ( 67 + <StatusBannerTabsTrigger 68 + value={`${e.type}-${e.id}`} 69 + status={e.status} 70 + key={e.id} 71 + className={cn( 72 + i === 0 && "rounded-tl-lg", 73 + i === page.openEvents.length - 1 && "rounded-tr-lg", 74 + )} 75 + > 76 + {e.name} 77 + </StatusBannerTabsTrigger> 78 + ); 79 + })} 80 + </StatusBannerTabsList> 81 + {page.openEvents.map((e) => { 82 + if (e.type === "report") { 83 + const report = page.statusReports.find( 84 + (report) => report.id === e.id, 85 + ); 86 + if (!report) return null; 87 + return ( 88 + <StatusBannerTabsContent 89 + value={`${e.type}-${e.id}`} 90 + key={e.id} 91 + > 92 + <StatusBannerContainer status={e.status}> 93 + <StatusBannerContent> 94 + <StatusEventTimelineReport 95 + updates={report.statusReportUpdates} 96 + withDot={false} 97 + /> 98 + </StatusBannerContent> 99 + </StatusBannerContainer> 100 + </StatusBannerTabsContent> 101 + ); 102 + } 103 + if (e.type === "maintenance") { 104 + const maintenance = page.maintenances.find( 105 + (maintenance) => maintenance.id === e.id, 106 + ); 107 + if (!maintenance) return null; 108 + return ( 109 + <StatusBannerTabsContent 110 + value={`${e.type}-${e.id}`} 111 + key={e.id} 112 + > 113 + <StatusBannerContainer status={e.status}> 114 + <StatusBannerContent> 115 + <StatusEventTimelineMaintenance 116 + maintenance={maintenance} 117 + withDot={false} 118 + /> 119 + </StatusBannerContent> 120 + </StatusBannerContainer> 121 + </StatusBannerTabsContent> 122 + ); 123 + } 124 + return null; 125 + })} 126 + </StatusBannerTabs> 107 127 </StatusContent> 108 128 ) : ( 109 129 <StatusBanner status={page.status} /> 110 130 )} 111 - {/* TODO: check how to display current events */} 112 - <StatusContent> 131 + {/* NOTE: check what gap feels right */} 132 + <StatusContent className="gap-5"> 113 133 {page.monitors.map((monitor) => { 114 134 const { data, uptime } = 115 135 uptimeData?.find((m) => m.id === monitor.id) ?? {}; ··· 128 148 </StatusContent> 129 149 <Separator /> 130 150 <StatusContent> 131 - <StatusTitle>Recent Events</StatusTitle> 132 151 <StatusFeed 133 152 statusReports={page.statusReports 134 153 .filter((report) =>
+11 -6
apps/status-page/src/app/(status-page)/[domain]/layout.tsx
··· 3 3 FloatingButton, 4 4 StatusPageProvider, 5 5 } from "@/components/status-page/floating-button"; 6 + import { FloatingTheme } from "@/components/status-page/floating-theme"; 6 7 import { ThemeProvider } from "@/components/theme-provider"; 7 8 import { Toaster } from "@/components/ui/sonner"; 8 9 import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; ··· 16 17 uptime: z.coerce.boolean().default(true), 17 18 theme: z.enum(["default"]).default("default"), 18 19 }); 19 - 20 - const DISPLAY_FLOATING_BUTTON = 21 - process.env.NODE_ENV === "development" || 22 - process.env.ENABLE_FLOATING_BUTTON === "true"; 23 20 24 21 export default async function Layout({ 25 22 children, ··· 51 48 defaultCommunityTheme={validation.data?.theme} 52 49 > 53 50 {children} 54 - {DISPLAY_FLOATING_BUTTON ? <FloatingButton /> : null} 55 - <Toaster richColors expand /> 51 + <FloatingButton /> 52 + <FloatingTheme /> 53 + <Toaster 54 + toastOptions={{ 55 + classNames: {}, 56 + style: { borderRadius: "var(--radius-lg)" }, 57 + }} 58 + richColors 59 + expand 60 + /> 56 61 </StatusPageProvider> 57 62 </ThemeProvider> 58 63 </HydrateClient>
+12 -2
apps/status-page/src/app/globals.css
··· 1 1 @import "tailwindcss"; 2 2 @import "tw-animate-css"; 3 + @plugin "@tailwindcss/typography"; 3 4 4 5 /* safelist */ 5 6 @source inline("has-data-[slot=slider-range]:bg-red-500"); ··· 14 15 --color-background: var(--background); 15 16 --color-foreground: var(--foreground); 16 17 --font-sans: var(--font-geist-sans); 17 - --font-mono: var(--font-geist-mono); 18 + --font-mono: var(--font-commit-mono, var(--font-geist-mono)); 18 19 --color-sidebar-ring: var(--sidebar-ring); 19 20 --color-sidebar-border: var(--sidebar-border); 20 21 --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); ··· 44 45 --color-popover: var(--popover); 45 46 --color-card-foreground: var(--card-foreground); 46 47 --color-card: var(--card); 48 + --radius-xs: calc(var(--radius) - 8px); 47 49 --radius-sm: calc(var(--radius) - 4px); 48 50 --radius-md: calc(var(--radius) - 2px); 49 51 --radius-lg: var(--radius); ··· 55 57 } 56 58 57 59 :root { 58 - --radius: 0.625rem; 60 + /* --radius: 0.625rem; */ 61 + --radius: 0rem; 59 62 --background: oklch(1 0 0); 60 63 --foreground: oklch(0.145 0 0); 61 64 --card: oklch(1 0 0); ··· 175 178 @apply bg-background text-foreground; 176 179 } 177 180 } 181 + 182 + @layer utilities { 183 + /* NOTE: allows us to --radius: 0px and avoid rounding issues - otherwise it is 'infinite * 1px' */ 184 + .rounded-full { 185 + border-radius: calc(var(--radius) * 99999999); 186 + } 187 + }
+27
apps/status-page/src/app/layout.tsx
··· 24 24 subsets: ["latin"], 25 25 }); 26 26 27 + const commitMono = LocalFont({ 28 + src: [ 29 + { 30 + path: "../../public/fonts/CommitMono-400-Regular.otf", 31 + weight: "400", 32 + style: "normal", 33 + }, 34 + { 35 + path: "../../public/fonts/CommitMono-400-Italic.otf", 36 + weight: "400", 37 + style: "italic", 38 + }, 39 + { 40 + path: "../../public/fonts/CommitMono-700-Regular.otf", 41 + weight: "700", 42 + style: "normal", 43 + }, 44 + { 45 + path: "../../public/fonts/CommitMono-700-Italic.otf", 46 + weight: "700", 47 + style: "italic", 48 + }, 49 + ], 50 + variable: "--font-commit-mono", 51 + }); 52 + 27 53 export const metadata: Metadata = { 28 54 ...defaultMetadata, 29 55 twitter: { ··· 48 74 geistSans.variable, 49 75 geistMono.variable, 50 76 cal.variable, 77 + commitMono.variable, 51 78 "antialiased", 52 79 )} 53 80 >
+1 -1
apps/status-page/src/components/chart/chart-legend-badge.tsx
··· 80 80 <itemConfig.icon /> 81 81 ) : ( 82 82 <div 83 - className="h-2 w-2 shrink-0 rounded-[2px]" 83 + className="h-2 w-2 shrink-0 rounded-(--radius-xs)" 84 84 style={{ 85 85 backgroundColor: item.color, 86 86 }}
+1 -1
apps/status-page/src/components/chart/chart-tooltip-number.tsx
··· 20 20 return ( 21 21 <> 22 22 <div 23 - className="h-2.5 w-2.5 shrink-0 rounded-[2px] bg-(--color-bg)" 23 + className="h-2.5 w-2.5 shrink-0 rounded-(--radius-xs) bg-(--color-bg)" 24 24 style={ 25 25 { 26 26 "--color-bg": `var(--color-${name})`,
+19 -6
apps/status-page/src/components/common/link.tsx
··· 1 1 import { cn } from "@/lib/utils"; 2 + import { type VariantProps, cva } from "class-variance-authority"; 2 3 import NextLink from "next/link"; 3 4 4 - // TODO: we could add cva variants for the link 5 + export const linkVariants = cva( 6 + // NOTE: use same ring styles as the button 7 + "outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px] rounded-sm", 8 + { 9 + variants: { 10 + variant: { 11 + default: "text-foreground font-medium", 12 + container: "focus-visible:border-ring", 13 + }, 14 + }, 15 + defaultVariants: { 16 + variant: "default", 17 + }, 18 + }, 19 + ); 5 20 6 21 export function Link({ 7 22 children, 8 23 className, 24 + variant, 9 25 ...props 10 - }: React.ComponentProps<typeof NextLink>) { 26 + }: React.ComponentProps<typeof NextLink> & VariantProps<typeof linkVariants>) { 11 27 return ( 12 - <NextLink 13 - className={cn("font-medium text-foreground", className)} 14 - {...props} 15 - > 28 + <NextLink className={cn(linkVariants({ variant, className }))} {...props}> 16 29 {children} 17 30 </NextLink> 18 31 );
+15 -2
apps/status-page/src/components/content/process-message.tsx
··· 1 - import type { AnchorHTMLAttributes } from "react"; 1 + import type { AnchorHTMLAttributes, HTMLAttributes } from "react"; 2 2 import { Fragment, createElement } from "react"; 3 3 import { jsx, jsxs } from "react/jsx-runtime"; 4 4 import rehypeReact from "rehype-react"; ··· 16 16 jsx, 17 17 jsxs, 18 18 components: { 19 + ul: (props: HTMLAttributes<HTMLUListElement>) => { 20 + return ( 21 + <ul 22 + className="list-inside list-disc marker:text-muted-foreground/50" 23 + {...props} 24 + /> 25 + ); 26 + }, 27 + ol: (_props: HTMLAttributes<HTMLOListElement>) => { 28 + return ( 29 + <ol className="list-inside list-decimal marker:text-muted-foreground/50" /> 30 + ); 31 + }, 19 32 a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => { 20 33 return ( 21 34 <a 22 35 target="_blank" 23 36 rel="noreferrer" 24 - className="underline" 37 + className="rounded-sm underline outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" 25 38 {...props} 26 39 /> 27 40 );
+3 -3
apps/status-page/src/components/nav/footer.tsx
··· 20 20 return ( 21 21 <footer {...props}> 22 22 <div className="mx-auto flex max-w-2xl items-center justify-between gap-4 px-3 py-2"> 23 - <div className="leading-[0.9]"> 24 - <p className="text-muted-foreground text-sm"> 25 - Powered by <Link href="#">OpenStatus</Link> 23 + <div> 24 + <p className="font-mono text-muted-foreground text-sm leading-none"> 25 + powered by <Link href="#">openstatus</Link> 26 26 </p> 27 27 <TimestampHoverCard date={new Date(dataUpdatedAt)} side="top"> 28 28 <span className="text-muted-foreground/70 text-xs">
+19 -19
apps/status-page/src/components/nav/header.tsx
··· 69 69 ); 70 70 71 71 const types = ( 72 - page?.workspacePlan === "free" 73 - ? ["rss", "atom", "ssh"] 74 - : ["email", "rss", "atom", "ssh"] 75 - ) satisfies ("email" | "rss" | "atom" | "ssh")[]; 72 + page?.workspacePlan === "free" ? ["rss", "ssh"] : ["email", "rss", "ssh"] 73 + ) satisfies ("email" | "rss" | "ssh")[]; 76 74 77 75 return ( 78 76 <header {...props}> 79 77 <nav className="mx-auto flex max-w-2xl items-center justify-between gap-3 px-3 py-2"> 80 78 {/* NOTE: same width as the `StatusUpdates` button */} 81 - <div className="w-[150px] shrink-0"> 82 - <Link 83 - href={page?.homepageUrl || "/"} 84 - target={page?.homepageUrl ? "_blank" : undefined} 85 - rel={page?.homepageUrl ? "noreferrer" : undefined} 86 - className="rounded-full" 87 - > 88 - {page?.icon ? ( 89 - <img 90 - src={page.icon} 91 - alt={`${page.title} status page`} 92 - className="size-8 rounded-full border" 93 - /> 94 - ) : null} 95 - </Link> 79 + <div className="flex w-[150px] shrink-0"> 80 + <div className="flex items-center justify-center"> 81 + <Link 82 + href={page?.homepageUrl || "/"} 83 + target={page?.homepageUrl ? "_blank" : undefined} 84 + rel={page?.homepageUrl ? "noreferrer" : undefined} 85 + className="rounded-full" 86 + > 87 + {page?.icon ? ( 88 + <img 89 + src={page.icon} 90 + alt={`${page.title} status page`} 91 + className="size-8 rounded-full border" 92 + /> 93 + ) : null} 94 + </Link> 95 + </div> 96 96 </div> 97 97 <NavDesktop className="hidden md:flex" /> 98 98 <div className="flex min-w-[150px] items-center justify-end gap-2">
-117
apps/status-page/src/components/status-page/community-themes.ts
··· 1 - export const defaultTheme = { 2 - light: {} as React.CSSProperties, 3 - dark: {} as React.CSSProperties, 4 - } as const; 5 - 6 - export const supabaseTheme = { 7 - light: { 8 - "--background": "oklch(99.11% 0 0)", 9 - "--foreground": "oklch(20.46% 0 0)", 10 - "--border": "oklch(90.37% 0 0)", 11 - "--input": "oklch(90.37% 0 0)", 12 - 13 - "--primary": "oklch(76.26% 0.154 159.27)", 14 - "--primary-foreground": "oklch(20.46% 0 0)", 15 - "--muted": "oklch(97.61% 0 0)", 16 - "--muted-foreground": "oklch(54.52% 0 0)", 17 - "--secondary": "oklch(97.61% 0 0)", 18 - "--secondary-foreground": "oklch(20.46% 0 0)", 19 - "--accent": "oklch(97.61% 0 0)", 20 - "--accent-foreground": "oklch(20.46% 0 0)", 21 - 22 - "--success": "oklch(76.26% 0.154 159.27)", 23 - "--destructive": "oklch(62.71% 0.1936 33.34)", 24 - "--warning": "oklch(81.69% 0.1639 75.84)", 25 - "--info": "oklch(61.26% 0.218 283.85)", 26 - } as React.CSSProperties, 27 - dark: { 28 - "--background": "oklch(18.22% 0 0)", 29 - "--foreground": "oklch(98.51% 0 0)", 30 - "--border": "oklch(30.12% 0 0)", 31 - "--input": "oklch(30.12% 0 0)", 32 - 33 - "--primary": "oklch(68.56% 0.1558 158.13)", 34 - "--primary-foreground": "oklch(18.22% 0 0)", 35 - "--muted": "oklch(26.03% 0 0)", 36 - "--muted-foreground": "oklch(63.01% 0 0)", 37 - "--secondary": "oklch(26.03% 0 0)", 38 - "--secondary-foreground": "oklch(98.51% 0 0)", 39 - "--accent": "oklch(26.03% 0 0)", 40 - "--accent-foreground": "oklch(98.51% 0 0)", 41 - 42 - "--success": "oklch(68.56% 0.1558 158.13)", 43 - "--destructive": "oklch(62.71% 0.1936 33.34)", 44 - "--warning": "oklch(70.84% 0.1523 71.24)", 45 - "--info": "oklch(61.26% 0.218 283.85)", 46 - } as React.CSSProperties, 47 - }; 48 - 49 - export const githubTheme = { 50 - light: { 51 - "--background": "oklch(100% 0 0)", 52 - "--foreground": "oklch(24.29% 0.0045 247.86)", 53 - "--border": "oklch(85.86% 0.0054 251.18)", 54 - "--input": "oklch(85.86% 0.0054 251.18)", 55 - 56 - "--primary": "oklch(60.81% 0.1567 142.5)", 57 - "--primary-foreground": "oklch(24.29% 0.0045 247.86)", 58 - "--muted": "oklch(97.86% 0.0019 247.86)", 59 - "--muted-foreground": "oklch(40.78% 0.0056 247.86)", 60 - "--secondary": "oklch(97.86% 0.0019 247.86)", 61 - "--secondary-foreground": "oklch(24.29% 0.0045 247.86)", 62 - "--accent": "oklch(97.86% 0.0019 247.86)", 63 - "--accent-foreground": "oklch(24.29% 0.0045 247.86)", 64 - 65 - "--success": "oklch(60.81% 0.1567 142.5)", 66 - "--destructive": "oklch(58.79% 0.1577 22.18)", 67 - "--warning": "oklch(81.84% 0.1328 85.87)", 68 - "--info": "oklch(45.2% 0.1445 252.03)", 69 - } as React.CSSProperties, 70 - dark: { 71 - "--background": "oklch(10.39% 0.0194 248.34)", 72 - "--foreground": "oklch(100% 0 0)", 73 - "--border": "oklch(58.41% 0.011 252.87)", 74 - "--input": "oklch(58.41% 0.011 252.87)", 75 - 76 - "--primary": "oklch(54.34% 0.1634 145.98)", 77 - "--primary-foreground": "oklch(100% 0 0)", 78 - "--muted": "oklch(33.39% 0.0223 256.4)", 79 - "--muted-foreground": "oklch(79.7% 0.0169 262.74)", 80 - "--secondary": "oklch(33.39% 0.0223 256.4)", 81 - "--secondary-foreground": "oklch(100% 0 0)", 82 - "--accent": "oklch(33.39% 0.0223 256.4)", 83 - "--accent-foreground": "oklch(100% 0 0)", 84 - 85 - "--success": "oklch(54.34% 0.1634 145.98)", 86 - "--destructive": "oklch(47.1% 0.1909 25.95)", 87 - "--warning": "oklch(81.84% 0.1328 85.87)", 88 - "--info": "oklch(46.96% 0.2957 264.51)", 89 - } as React.CSSProperties, 90 - }; 91 - 92 - export const THEMES = { 93 - // supabase: supabaseTheme, 94 - default: { 95 - name: "Default", 96 - author: { name: "@openstatus", url: "https://openstatus.dev" }, 97 - ...defaultTheme, 98 - }, 99 - github: { 100 - name: "Github", 101 - author: { name: "@github", url: "https://github.com" }, 102 - ...githubTheme, 103 - }, 104 - supabase: { 105 - name: "Supabase", 106 - author: { name: "@supabase", url: "https://supabase.com" }, 107 - ...supabaseTheme, 108 - }, 109 - } as const satisfies Record< 110 - string, 111 - { 112 - name: string; 113 - author: { name: string; url: string }; 114 - light: React.CSSProperties; 115 - dark: React.CSSProperties; 116 - } 117 - >;
+53 -35
apps/status-page/src/components/status-page/floating-button.tsx
··· 16 16 SelectValue, 17 17 } from "@/components/ui/select"; 18 18 import { Separator } from "@/components/ui/separator"; 19 + import { THEMES, THEME_KEYS } from "@/lib/community-themes"; 19 20 import { cn } from "@/lib/utils"; 20 21 import { Settings } from "lucide-react"; 21 22 import { useTheme } from "next-themes"; 22 23 import type React from "react"; 23 24 import { createContext, useContext, useEffect, useState } from "react"; 24 - import { THEMES } from "./community-themes"; 25 25 26 26 export const VARIANT = ["success", "degraded", "error", "info"] as const; 27 27 export type VariantType = (typeof VARIANT)[number]; ··· 37 37 export const BAR_TYPE = ["absolute", "dominant", "manual"] as const; 38 38 export type BarType = (typeof BAR_TYPE)[number]; 39 39 40 - export const COMMUNITY_THEME = ["default", "github", "supabase"] as const; 40 + export const COMMUNITY_THEME = THEME_KEYS; 41 41 export type CommunityTheme = (typeof COMMUNITY_THEME)[number]; 42 + 43 + export const RADIUS = ["square", "rounded"] as const; 44 + export type Radius = (typeof RADIUS)[number]; 42 45 interface StatusPageContextType { 43 - variant: VariantType; 44 - setVariant: (variant: VariantType) => void; 45 46 cardType: CardType; 46 47 setCardType: (cardType: CardType) => void; 47 48 barType: BarType; ··· 50 51 setShowUptime: (showUptime: boolean) => void; 51 52 communityTheme: CommunityTheme; 52 53 setCommunityTheme: (communityTheme: CommunityTheme) => void; 54 + radius: Radius; 55 + setRadius: (radius: Radius) => void; 53 56 } 54 57 55 58 const StatusPageContext = createContext<StatusPageContextType | null>(null); ··· 64 67 65 68 export function StatusPageProvider({ 66 69 children, 67 - defaultVariant = "success", 68 70 defaultCardType = "duration", 69 71 defaultBarType = "absolute", 70 72 defaultShowUptime = true, 71 73 defaultCommunityTheme = "default", 74 + defaultRadius = "square", 72 75 }: { 73 76 children: React.ReactNode; 74 - defaultVariant?: VariantType; 75 77 defaultCardType?: CardType; 76 78 defaultBarType?: BarType; 77 79 defaultShowUptime?: boolean; 78 80 defaultCommunityTheme?: CommunityTheme; 81 + defaultRadius?: Radius; 79 82 }) { 80 - const [variant, setVariant] = useState<VariantType>(defaultVariant); 81 83 const [cardType, setCardType] = useState<CardType>(defaultCardType); 82 84 const [barType, setBarType] = useState<BarType>(defaultBarType); 83 85 const [showUptime, setShowUptime] = useState<boolean>(defaultShowUptime); 86 + const [radius, setRadius] = useState<Radius>(defaultRadius); 84 87 const { resolvedTheme } = useTheme(); 85 88 const [communityTheme, setCommunityTheme] = useState<CommunityTheme>( 86 89 defaultCommunityTheme, ··· 105 108 } 106 109 }, [resolvedTheme, communityTheme]); 107 110 111 + useEffect(() => { 112 + const computedRadius = getComputedStyle( 113 + document.documentElement, 114 + ).getPropertyValue("--radius"); 115 + if (radius === "square" && computedRadius !== "0rem") { 116 + document.documentElement.style.setProperty("--radius", "0rem"); 117 + } else if (radius === "rounded" && computedRadius !== "0.625rem") { 118 + document.documentElement.style.setProperty("--radius", "0.625rem"); 119 + } 120 + }, [radius]); 121 + 108 122 return ( 109 123 <StatusPageContext.Provider 110 124 value={{ 111 - variant, 112 - setVariant, 113 125 cardType, 114 126 setCardType, 115 127 barType, ··· 118 130 setShowUptime, 119 131 communityTheme, 120 132 setCommunityTheme, 133 + radius, 134 + setRadius, 121 135 }} 122 136 > 123 137 <div 124 138 style={ 125 139 communityTheme 126 - ? THEMES[communityTheme][resolvedTheme as "dark" | "light"] 140 + ? (THEMES[communityTheme][ 141 + resolvedTheme as "dark" | "light" 142 + ] as React.CSSProperties) 127 143 : undefined 128 144 } 129 145 > ··· 133 149 ); 134 150 } 135 151 152 + const DISPLAY_FLOATING_BUTTON = 153 + process.env.NODE_ENV === "development" || 154 + process.env.NEXT_PUBLIC_ENABLE_FLOATING_BUTTON === "true"; 155 + 136 156 export function FloatingButton({ className }: { className?: string }) { 137 157 const { 138 - variant, 139 - setVariant, 140 158 cardType, 141 159 setCardType, 142 160 barType, ··· 145 163 setShowUptime, 146 164 communityTheme, 147 165 setCommunityTheme, 166 + radius, 167 + setRadius, 148 168 } = useStatusPage(); 149 169 170 + if (!DISPLAY_FLOATING_BUTTON) return null; 171 + 150 172 return ( 151 173 <div className={cn("fixed right-4 bottom-4 z-50", className)}> 152 174 <Popover> ··· 154 176 <Button 155 177 size="icon" 156 178 variant="outline" 157 - className="size-12 rounded-full" 179 + className="size-12 rounded-full dark:bg-background" 158 180 > 159 181 <Settings className="size-5" /> 160 182 <span className="sr-only">Open status page settings</span> ··· 169 191 </p> 170 192 </div> 171 193 <div className="grid grid-cols-2 gap-4"> 172 - <div className="space-y-2"> 173 - <Label htmlFor="status-variant">Status Variant</Label> 174 - <Select 175 - value={variant} 176 - onValueChange={(v) => setVariant(v as VariantType)} 177 - > 178 - <SelectTrigger 179 - id="status-variant" 180 - className="w-full capitalize" 181 - disabled 182 - > 183 - <SelectValue /> 184 - </SelectTrigger> 185 - <SelectContent> 186 - {VARIANT.map((v) => ( 187 - <SelectItem key={v} value={v} className="capitalize"> 188 - {v} 189 - </SelectItem> 190 - ))} 191 - </SelectContent> 192 - </Select> 193 - </div> 194 194 <div className="space-y-2"> 195 195 <Label htmlFor="show-uptime">Show Uptime</Label> 196 196 <Select ··· 276 276 </SelectTrigger> 277 277 <SelectContent> 278 278 {COMMUNITY_THEME.map((v) => ( 279 + <SelectItem key={v} value={v} className="capitalize"> 280 + {v} 281 + </SelectItem> 282 + ))} 283 + </SelectContent> 284 + </Select> 285 + </div> 286 + <div className="space-y-2"> 287 + <Label htmlFor="radius">Radius</Label> 288 + <Select 289 + value={radius} 290 + onValueChange={(v) => setRadius(v as Radius)} 291 + > 292 + <SelectTrigger id="radius" className="w-full capitalize"> 293 + <SelectValue /> 294 + </SelectTrigger> 295 + <SelectContent> 296 + {RADIUS.map((v) => ( 279 297 <SelectItem key={v} value={v} className="capitalize"> 280 298 {v} 281 299 </SelectItem>
+102
apps/status-page/src/components/status-page/floating-theme.tsx
··· 1 + "use client"; 2 + 3 + import { ThemeToggle } from "@/components/theme-toggle"; 4 + import { Button } from "@/components/ui/button"; 5 + import { Label } from "@/components/ui/label"; 6 + import { 7 + Popover, 8 + PopoverContent, 9 + PopoverTrigger, 10 + } from "@/components/ui/popover"; 11 + import { 12 + Select, 13 + SelectContent, 14 + SelectItem, 15 + SelectTrigger, 16 + SelectValue, 17 + } from "@/components/ui/select"; 18 + import { THEMES, THEME_KEYS } from "@/lib/community-themes"; 19 + import { cn } from "@/lib/utils"; 20 + import { Palette } from "lucide-react"; 21 + import { useEffect } from "react"; 22 + import { useState } from "react"; 23 + import { useStatusPage } from "./floating-button"; 24 + 25 + export const COMMUNITY_THEME = THEME_KEYS; 26 + export type CommunityTheme = (typeof COMMUNITY_THEME)[number]; 27 + 28 + export function FloatingTheme({ className }: { className?: string }) { 29 + const { communityTheme, setCommunityTheme } = useStatusPage(); 30 + const [display, setDisplay] = useState(false); 31 + 32 + useEffect(() => { 33 + const enabled = sessionStorage.getItem("community-theme") === "true"; 34 + const host = window.location.host; 35 + if ( 36 + (host.includes("localhost") || 37 + host.includes("stpg.dev") || 38 + host.includes("vercel.app")) && 39 + enabled 40 + ) { 41 + setDisplay(true); 42 + } 43 + }, []); 44 + 45 + if (!display) return null; 46 + 47 + return ( 48 + <div className={cn("fixed right-4 bottom-4 z-50", className)}> 49 + <Popover> 50 + <PopoverTrigger asChild> 51 + <Button 52 + size="icon" 53 + variant="outline" 54 + className="size-12 rounded-full dark:bg-background" 55 + > 56 + <Palette className="size-5" /> 57 + <span className="sr-only">Open theme settings</span> 58 + </Button> 59 + </PopoverTrigger> 60 + <PopoverContent className="w-80 p-0" align="end"> 61 + <div className="space-y-4 p-4"> 62 + <div className="space-y-2"> 63 + <h4 className="font-medium leading-none">Theme Settings</h4> 64 + <p className="text-muted-foreground text-sm"> 65 + Test the community themes on the status page. 66 + </p> 67 + </div> 68 + <div className="space-y-2"> 69 + <Label htmlFor="theme">Theme</Label> 70 + <ThemeToggle id="theme" className="w-full" /> 71 + </div> 72 + <div className="space-y-2"> 73 + <Label htmlFor="community-theme">Community Theme</Label> 74 + <Select 75 + value={communityTheme} 76 + onValueChange={(v) => setCommunityTheme(v as CommunityTheme)} 77 + > 78 + <SelectTrigger 79 + id="community-theme" 80 + className="w-full capitalize" 81 + > 82 + <SelectValue /> 83 + </SelectTrigger> 84 + <SelectContent> 85 + {COMMUNITY_THEME.map((theme) => ( 86 + <SelectItem 87 + key={theme} 88 + value={theme} 89 + className="capitalize" 90 + > 91 + {THEMES[theme].name} 92 + </SelectItem> 93 + ))} 94 + </SelectContent> 95 + </Select> 96 + </div> 97 + </div> 98 + </PopoverContent> 99 + </Popover> 100 + </div> 101 + ); 102 + }
+99 -5
apps/status-page/src/components/status-page/status-banner.tsx
··· 1 + import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 1 2 import { cn } from "@/lib/utils"; 2 3 import { 3 4 AlertCircleIcon, ··· 48 49 data-status={status} 49 50 className={cn( 50 51 "group/status-banner overflow-hidden rounded-lg border", 51 - "data-[status=success]:border-success", 52 - "data-[status=degraded]:border-warning", 53 - "data-[status=error]:border-destructive", 54 - "data-[status=info]:border-info", 52 + "data-[status=success]:border-success data-[status=success]:bg-success/5 dark:data-[status=success]:bg-success/10", 53 + "data-[status=degraded]:border-warning data-[status=degraded]:bg-warning/5 dark:data-[status=degraded]:bg-warning/10", 54 + "data-[status=error]:border-destructive data-[status=error]:bg-destructive/5 dark:data-[status=error]:bg-destructive/10", 55 + "data-[status=info]:border-info data-[status=info]:bg-info/5 dark:data-[status=info]:bg-info/10", 55 56 className, 56 57 )} 57 58 > ··· 90 91 return ( 91 92 <div 92 93 className={cn( 93 - "px-3 py-2 font-medium text-background sm:px-4 sm:py-3", 94 + "px-3 py-2 font-medium text-background", 94 95 "group-data-[status=success]/status-banner:bg-success", 95 96 "group-data-[status=degraded]/status-banner:bg-warning", 96 97 "group-data-[status=error]/status-banner:bg-destructive", ··· 139 140 </div> 140 141 ); 141 142 } 143 + 144 + // Tabs Components 145 + 146 + export function StatusBannerTabs({ 147 + className, 148 + children, 149 + status, 150 + ...props 151 + }: React.ComponentProps<typeof Tabs> & { 152 + status?: "success" | "degraded" | "error" | "info"; 153 + }) { 154 + return ( 155 + <Tabs 156 + data-slot="status-banner-tabs" 157 + data-status={status} 158 + className={cn( 159 + "gap-0", 160 + "data-[status=success]:bg-success/20", 161 + "data-[status=degraded]:bg-warning/20", 162 + "data-[status=error]:bg-destructive/20", 163 + "data-[status=info]:bg-info/20", 164 + className, 165 + )} 166 + {...props} 167 + > 168 + {children} 169 + </Tabs> 170 + ); 171 + } 172 + 173 + export function StatusBannerTabsList({ 174 + className, 175 + children, 176 + ...props 177 + }: React.ComponentProps<typeof TabsList>) { 178 + return ( 179 + <div className={cn("rounded-t-lg", "w-full overflow-x-auto")}> 180 + <TabsList 181 + className={cn( 182 + "rounded-none rounded-t-lg p-0", 183 + "border-none", 184 + className, 185 + )} 186 + {...props} 187 + > 188 + {children} 189 + </TabsList> 190 + </div> 191 + ); 192 + } 193 + 194 + export function StatusBannerTabsTrigger({ 195 + className, 196 + children, 197 + status, 198 + ...props 199 + }: React.ComponentProps<typeof TabsTrigger> & { 200 + status?: "success" | "degraded" | "error" | "info"; 201 + }) { 202 + return ( 203 + <TabsTrigger 204 + data-slot="status-banner-tabs-trigger" 205 + data-status={status} 206 + className={cn( 207 + "font-mono", 208 + "rounded-none border-none focus-visible:ring-inset", 209 + "h-full text-foreground data-[state=active]:text-background dark:text-foreground dark:data-[state=active]:text-background", 210 + "data-[state=active]:data-[status=success]:bg-success data-[status=success]:bg-success/50 dark:data-[state=active]:data-[status=success]:bg-success dark:data-[status=success]:bg-success/50", 211 + "data-[state=active]:data-[status=degraded]:bg-warning data-[status=degraded]:bg-warning/50 dark:data-[state=active]:data-[status=degraded]:bg-warning dark:data-[status=degraded]:bg-warning/50", 212 + "data-[state=active]:data-[status=error]:bg-destructive data-[status=error]:bg-destructive/50 dark:data-[state=active]:data-[status=error]:bg-destructive dark:data-[status=error]:bg-destructive/50", 213 + "data-[state=active]:data-[status=info]:bg-info data-[status=info]:bg-info/50 dark:data-[state=active]:data-[status=info]:bg-info dark:data-[status=info]:bg-info/50", 214 + "data-[state=active]:shadow-none", 215 + className, 216 + )} 217 + {...props} 218 + > 219 + {children} 220 + </TabsTrigger> 221 + ); 222 + } 223 + 224 + // NOTE: tabing into content is not being highlighted 225 + export function StatusBannerTabsContent({ 226 + className, 227 + children, 228 + ...props 229 + }: React.ComponentProps<typeof TabsContent>) { 230 + return ( 231 + <TabsContent className={cn("-mx-3", className)} {...props}> 232 + {children} 233 + </TabsContent> 234 + ); 235 + }
+194
apps/status-page/src/components/status-page/status-blank.tsx
··· 1 + import { cn } from "@/lib/utils"; 2 + 3 + export function StatusBlankContainer({ 4 + children, 5 + className, 6 + ...props 7 + }: React.ComponentProps<"div">) { 8 + return ( 9 + <div 10 + className={cn( 11 + "flex flex-col items-center justify-center gap-2 rounded-lg border bg-muted/30 px-3 py-2 text-center sm:px-8 sm:py-6", 12 + className, 13 + )} 14 + {...props} 15 + > 16 + {children} 17 + </div> 18 + ); 19 + } 20 + 21 + export function StatusBlankTitle({ 22 + children, 23 + className, 24 + ...props 25 + }: React.ComponentProps<"div">) { 26 + return ( 27 + <div className={cn("font-medium", className)} {...props}> 28 + {children} 29 + </div> 30 + ); 31 + } 32 + 33 + export function StatusBlankDescription({ 34 + children, 35 + className, 36 + ...props 37 + }: React.ComponentProps<"div">) { 38 + return ( 39 + <div 40 + className={cn("font-mono text-muted-foreground text-sm", className)} 41 + {...props} 42 + > 43 + {children} 44 + </div> 45 + ); 46 + } 47 + 48 + export function StatusBlankContent({ 49 + children, 50 + className, 51 + ...props 52 + }: React.ComponentProps<"div">) { 53 + return ( 54 + <div className={cn("", className)} {...props}> 55 + {children} 56 + </div> 57 + ); 58 + } 59 + 60 + export function StatusBlankReport({ ...props }: React.ComponentProps<"div">) { 61 + return ( 62 + <StatusBlankPage {...props}> 63 + <StatusBlankPageHeader /> 64 + <div className="flex w-full flex-col"> 65 + <StatusBlankReportUpdate /> 66 + <StatusBlankReportUpdate /> 67 + </div> 68 + <StatusBlankOverlay /> 69 + </StatusBlankPage> 70 + ); 71 + } 72 + 73 + export function StatusBlankMonitor({ ...props }: React.ComponentProps<"div">) { 74 + return ( 75 + <StatusBlankPage {...props}> 76 + <StatusBlankPageHeader /> 77 + <StatusBlankMonitorUptime /> 78 + <StatusBlankOverlay /> 79 + </StatusBlankPage> 80 + ); 81 + } 82 + 83 + export function StatusBlankPage({ 84 + className, 85 + children, 86 + ...props 87 + }: React.ComponentProps<"div">) { 88 + return ( 89 + <div 90 + className={cn( 91 + "relative flex w-full max-w-xs flex-1 flex-col items-center justify-center gap-4 overflow-hidden rounded-lg border border-border/70 bg-background px-3 py-2 text-center", 92 + className, 93 + )} 94 + {...props} 95 + > 96 + {children} 97 + </div> 98 + ); 99 + } 100 + 101 + export function StatusBlankPageHeader({ 102 + className, 103 + ...props 104 + }: React.ComponentProps<"div">) { 105 + return ( 106 + <div 107 + className={cn( 108 + "flex w-full items-center justify-between gap-4", 109 + className, 110 + )} 111 + {...props} 112 + > 113 + <div className="size-3 rounded-sm bg-accent/60" /> 114 + <div className="flex flex-row gap-1"> 115 + <div className="h-3 w-8 rounded-sm bg-accent/60" /> 116 + <div className="h-3 w-8 rounded-sm bg-accent/60" /> 117 + <div className="h-3 w-8 rounded-sm bg-accent/60" /> 118 + </div> 119 + <div className="h-3 w-8 rounded-sm bg-accent/60" /> 120 + </div> 121 + ); 122 + } 123 + 124 + export function StatusBlankMonitorUptime({ 125 + className, 126 + ...props 127 + }: React.ComponentProps<"div">) { 128 + return ( 129 + <div 130 + className={cn( 131 + "flex w-full flex-col items-center justify-between gap-1", 132 + className, 133 + )} 134 + {...props} 135 + > 136 + <div className="flex w-full flex-row gap-1"> 137 + <div className="h-3 w-8 rounded-sm bg-accent" /> 138 + <div className="h-3 w-12 rounded-sm bg-accent" /> 139 + <div className="h-3 w-10 rounded-sm bg-accent" /> 140 + </div> 141 + <div className="flex w-full flex-row items-end gap-0.5"> 142 + {Array.from({ length: 30 }).map((_, index) => ( 143 + <div 144 + key={index} 145 + className={cn( 146 + "h-12 flex-1 rounded-sm bg-accent", 147 + [10, 20].includes(index) && "h-8", 148 + [25].includes(index) && "h-10", 149 + )} 150 + /> 151 + ))} 152 + </div> 153 + </div> 154 + ); 155 + } 156 + 157 + export function StatusBlankReportUpdate({ 158 + className, 159 + ...props 160 + }: React.ComponentProps<"div">) { 161 + return ( 162 + <div className={cn("flex w-full items-start gap-2", className)} {...props}> 163 + <div className="flex h-full flex-col items-center gap-0.5"> 164 + <div className="size-3 rounded-sm bg-accent" /> 165 + </div> 166 + <div className="flex flex-1 flex-col gap-1 pb-2"> 167 + <div className="flex items-center gap-1"> 168 + <div className="h-3 w-12 rounded-sm bg-accent" /> 169 + <div className="h-3 w-16 rounded-sm bg-accent" /> 170 + </div> 171 + <div className="h-3 w-full rounded-sm bg-accent" /> 172 + <div className="h-3 w-full rounded-sm bg-accent" /> 173 + </div> 174 + </div> 175 + ); 176 + } 177 + 178 + export function StatusBlankOverlay({ 179 + children, 180 + className, 181 + ...props 182 + }: React.ComponentProps<"div">) { 183 + return ( 184 + <div 185 + className={cn( 186 + "absolute inset-0 flex flex-col items-center justify-center gap-2 bg-gradient-to-b from-40% from-transparent to-background p-2", 187 + className, 188 + )} 189 + {...props} 190 + > 191 + {children} 192 + </div> 193 + ); 194 + }
+10 -3
apps/status-page/src/components/status-page/status-events.tsx
··· 124 124 } 125 125 withSeparator={index !== updates.length - 1} 126 126 withDot={withDot} 127 + isLast={index === updates.length - 1} 127 128 /> 128 129 ))} 129 130 </div> ··· 135 136 duration, 136 137 withSeparator = true, 137 138 withDot = true, 139 + isLast = false, 138 140 }: { 139 141 report: { 140 142 date: Date; ··· 144 146 withSeparator?: boolean; 145 147 duration?: string; 146 148 withDot?: boolean; 149 + isLast?: boolean; 147 150 }) { 148 151 return ( 149 152 <div data-variant={report.status} className="group"> ··· 157 160 {withSeparator ? <StatusEventTimelineSeparator /> : null} 158 161 </div> 159 162 ) : null} 160 - <div className="mb-2"> 163 + <div className={cn(isLast ? "mb-0" : "mb-2")}> 161 164 <StatusEventTimelineTitle> 162 165 <span>{status[report.status]}</span>{" "} 163 166 {/* underline decoration-dashed underline-offset-2 decoration-muted-foreground/30 */} ··· 209 212 </div> 210 213 </div> 211 214 ) : null} 212 - <div className="mb-2"> 215 + {/* NOTE: is always last, no need for className="mb-2" */} 216 + <div> 213 217 <StatusEventTimelineTitle> 214 218 <span>Maintenance</span>{" "} 215 219 <span className="font-mono text-muted-foreground/70 text-xs"> ··· 259 263 ...props 260 264 }: React.ComponentProps<"div">) { 261 265 return ( 262 - <div className={cn("text-muted-foreground text-sm", className)} {...props}> 266 + <div 267 + className={cn("font-mono text-muted-foreground text-sm", className)} 268 + {...props} 269 + > 263 270 {children} 264 271 </div> 265 272 );
+19 -12
apps/status-page/src/components/status-page/status-feed.tsx
··· 4 4 import { usePathnamePrefix } from "@/hooks/use-pathname-prefix"; 5 5 import { formatDate } from "@/lib/formatter"; 6 6 import { cn } from "@/lib/utils"; 7 - import { Newspaper } from "lucide-react"; 8 7 import Link from "next/link"; 9 8 import { 10 - StatusEmptyState, 11 - StatusEmptyStateDescription, 12 - StatusEmptyStateTitle, 13 - } from "./status"; 9 + StatusBlankContainer, 10 + StatusBlankContent, 11 + StatusBlankDescription, 12 + StatusBlankReport, 13 + StatusBlankTitle, 14 + } from "./status-blank"; 14 15 import { 15 16 StatusEvent, 16 17 StatusEventAffected, ··· 80 81 81 82 if (unifiedEvents.length === 0) { 82 83 return ( 83 - <StatusEmptyState> 84 - <Newspaper className="size-4 text-muted-foreground" /> 85 - <StatusEmptyStateTitle>No recent reports</StatusEmptyStateTitle> 86 - <StatusEmptyStateDescription> 87 - There have been no reports within the last 7 days. 88 - </StatusEmptyStateDescription> 89 - </StatusEmptyState> 84 + <StatusBlankContainer> 85 + <div className="relative mt-8 flex w-full flex-col items-center justify-center"> 86 + <StatusBlankReport className="-top-16 absolute scale-60 opacity-50" /> 87 + <StatusBlankReport className="-top-8 absolute scale-80 opacity-80" /> 88 + <StatusBlankReport /> 89 + </div> 90 + <StatusBlankContent> 91 + <StatusBlankTitle>No recent notifications</StatusBlankTitle> 92 + <StatusBlankDescription> 93 + There have been no reports within the last 7 days. 94 + </StatusBlankDescription> 95 + </StatusBlankContent> 96 + </StatusBlankContainer> 90 97 ); 91 98 } 92 99
+17 -7
apps/status-page/src/components/status-page/status-monitor.tsx
··· 56 56 {...props} 57 57 > 58 58 <div className="flex flex-row items-center justify-between gap-4"> 59 - <div className="flex flex-row items-center gap-2"> 59 + <div className="flex min-w-0 flex-row items-center gap-2"> 60 60 <StatusMonitorTitle>{monitor.name}</StatusMonitorTitle> 61 61 <StatusMonitorDescription> 62 62 {monitor.description} ··· 74 74 <StatusMonitorIcon /> 75 75 </> 76 76 ) : ( 77 - <StatusMonitorStatus className="text-sm" /> 77 + <StatusMonitorStatus /> 78 78 )} 79 79 </div> 80 80 </div> ··· 90 90 ...props 91 91 }: React.ComponentProps<"div">) { 92 92 return ( 93 - <div className={cn("font-medium", className)} {...props}> 93 + <div 94 + className={cn( 95 + "truncate font-medium font-mono text-base text-foreground leading-5", 96 + className, 97 + )} 98 + {...props} 99 + > 94 100 {children} 95 101 </div> 96 102 ); ··· 134 140 return ( 135 141 <div 136 142 className={cn( 137 - "flex size-4 items-center justify-center rounded-full bg-muted text-background [&>svg]:size-2.5", 143 + "flex size-[12.5px] items-center justify-center rounded-full bg-muted text-background [&>svg]:size-[9px]", 138 144 "group-data-[variant=success]/monitor:bg-success", 139 145 "group-data-[variant=degraded]/monitor:bg-warning", 140 146 "group-data-[variant=error]/monitor:bg-destructive", ··· 159 165 isLoading?: boolean; 160 166 }) { 161 167 return ( 162 - <div className="flex flex-row items-center justify-between text-muted-foreground text-xs"> 168 + <div className="flex flex-row items-center justify-between font-mono text-muted-foreground text-xs leading-none"> 163 169 <div> 164 170 {isLoading ? ( 165 - <Skeleton className="h-4 w-18" /> 171 + <Skeleton className="h-3 w-18" /> 166 172 ) : data.length > 0 ? ( 167 173 formatDistanceToNowStrict(new Date(data[0].day), { 168 174 unit: "day", ··· 185 191 return ( 186 192 <div 187 193 {...props} 188 - className={cn("font-mono text-muted-foreground text-sm", className)} 194 + className={cn( 195 + "font-mono text-foreground/80 text-sm leading-none", 196 + className, 197 + )} 189 198 > 190 199 {children} 191 200 </div> ··· 206 215 return ( 207 216 <div 208 217 className={cn( 218 + "font-mono text-sm leading-none", 209 219 "group-data-[variant=success]/monitor:text-success", 210 220 "group-data-[variant=degraded]/monitor:text-warning", 211 221 "group-data-[variant=error]/monitor:text-destructive",
+48 -2
apps/status-page/src/components/status-page/status-tracker.tsx
··· 82 82 setFocusedIndex(null); 83 83 setHoveredIndex(null); 84 84 85 + if (focusedIndex !== null) { 86 + const buttons = 87 + containerRef.current?.querySelectorAll('[role="button"]'); 88 + const button = buttons?.[focusedIndex] as HTMLElement; 89 + if (button) { 90 + button.blur(); 91 + } 92 + } 93 + 85 94 if (hoverTimeoutRef.current) { 86 95 clearTimeout(hoverTimeoutRef.current); 87 96 hoverTimeoutRef.current = null; ··· 103 112 prev !== null && prev < data.length - 1 ? prev + 1 : 0, 104 113 ); 105 114 break; 115 + case "ArrowUp": 116 + e.preventDefault(); 117 + const prevMonitor = containerRef.current?.closest( 118 + '[data-slot="status-monitor"]', 119 + )?.previousElementSibling; 120 + if (prevMonitor) { 121 + const prevTracker = prevMonitor.querySelector('[role="toolbar"]'); 122 + if (prevTracker) { 123 + const buttons = prevTracker.querySelectorAll('[role="button"]'); 124 + const button = buttons?.[focusedIndex] as HTMLElement; 125 + if (button) { 126 + button.focus(); 127 + } 128 + } 129 + } 130 + break; 131 + case "ArrowDown": 132 + e.preventDefault(); 133 + const nextMonitor = containerRef.current?.closest( 134 + '[data-slot="status-monitor"]', 135 + )?.nextElementSibling; 136 + if (nextMonitor) { 137 + const nextTracker = nextMonitor.querySelector('[role="toolbar"]'); 138 + if (nextTracker) { 139 + const buttons = nextTracker.querySelectorAll('[role="button"]'); 140 + const button = buttons?.[focusedIndex] as HTMLElement; 141 + if (button) { 142 + button.focus(); 143 + } 144 + } 145 + } 146 + break; 106 147 case "Enter": 148 + case "Escape": 107 149 case " ": 108 150 e.preventDefault(); 109 151 handleBarClick(focusedIndex); ··· 189 231 <HoverCardTrigger asChild> 190 232 <div 191 233 className={cn( 192 - "group relative flex h-full w-full cursor-pointer flex-col px-px outline-none hover:opacity-80 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 data-[aria-pressed=true]:opacity-80", 234 + "group relative mx-px flex h-full w-full cursor-pointer flex-col outline-none first:ml-0 last:mr-0 hover:opacity-80 focus-visible:opacity-80 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[aria-pressed=true]:opacity-80", 193 235 )} 194 236 onClick={() => handleBarClick(index)} 195 237 onFocus={() => handleBarFocus(index)} ··· 197 239 onMouseEnter={() => handleBarMouseEnter(index)} 198 240 onMouseLeave={handleBarMouseLeave} 199 241 tabIndex={ 200 - index === 0 && focusedIndex === null ? 0 : isFocused ? 0 : -1 242 + index === data.length - 1 && focusedIndex === null 243 + ? 0 244 + : isFocused 245 + ? 0 246 + : -1 201 247 } 202 248 role="button" 203 249 aria-label={`Day ${index + 1} status`}
+59 -31
apps/status-page/src/components/status-page/status-updates.tsx
··· 8 8 PopoverContent, 9 9 PopoverTrigger, 10 10 } from "@/components/ui/popover"; 11 + import { Separator } from "@/components/ui/separator"; 11 12 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 12 13 import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 13 14 import { cn } from "@/lib/utils"; 14 - import { Inbox } from "lucide-react"; 15 + import { Check, Copy, Inbox } from "lucide-react"; 15 16 import { useState } from "react"; 16 17 17 - type StatusUpdateType = "email" | "rss" | "atom" | "ssh"; 18 + type StatusUpdateType = "email" | "rss" | "ssh"; 19 + 20 + // TODO: use domain instead of openstatus subdomain if available 18 21 19 22 interface StatusUpdatesProps extends React.ComponentProps<typeof Button> { 20 23 types?: StatusUpdateType[]; ··· 24 27 25 28 export function StatusUpdates({ 26 29 className, 27 - types = ["rss", "atom"], 30 + types = ["rss", "ssh"], 28 31 slug, 29 32 onSubscribe, 30 33 ...props ··· 43 46 Get updates 44 47 </Button> 45 48 </PopoverTrigger> 46 - <PopoverContent align="end" className="overflow-hidden p-0"> 49 + <PopoverContent align="end" className="w-80 overflow-hidden p-0"> 47 50 <Tabs defaultValue="email"> 48 51 <TabsList className="w-full rounded-none border-b"> 49 52 {types.includes("email") ? ( ··· 52 55 {types.includes("rss") ? ( 53 56 <TabsTrigger value="rss">RSS</TabsTrigger> 54 57 ) : null} 55 - {types.includes("atom") ? ( 56 - <TabsTrigger value="atom">Atom</TabsTrigger> 57 - ) : null} 58 58 {types.includes("ssh") ? ( 59 59 <TabsTrigger value="ssh">SSH</TabsTrigger> 60 60 ) : null} ··· 65 65 ) : ( 66 66 <> 67 67 <div className="flex flex-col gap-2 border-b px-2 pb-2"> 68 - <p className="text-foreground text-sm"> 68 + <p className="text-sm"> 69 69 Get email notifications whenever a report has been created 70 70 or resolved 71 71 </p> ··· 86 86 )} 87 87 </TabsContent> 88 88 <TabsContent value="rss" className="flex flex-col gap-2"> 89 - <div className="border-b px-2 pb-2"> 90 - <Input 91 - placeholder={`https://${slug}.openstatus.dev/feed/rss`} 92 - readOnly 93 - /> 94 - </div> 95 - <div className="px-2 pb-2"> 96 - <CopyButton 89 + <div className="flex flex-col gap-2 px-2"> 90 + <p className="text-sm">Get the RSS feed</p> 91 + <CopyInputButton 97 92 className="w-full" 93 + id="rss" 98 94 value={`https://${slug}.openstatus.dev/feed/rss`} 99 95 /> 100 96 </div> 101 - </TabsContent> 102 - <TabsContent value="atom" className="flex flex-col gap-2"> 103 - <div className="border-b px-2 pb-2"> 104 - <Input 105 - placeholder={`https://${slug}.openstatus.dev/feed/atom`} 106 - readOnly 107 - /> 108 - </div> 109 - <div className="px-2 pb-2"> 110 - <CopyButton 97 + <Separator /> 98 + <div className="flex flex-col gap-2 px-2 pb-2"> 99 + <p className="text-sm">Get the Atom feed</p> 100 + <CopyInputButton 111 101 className="w-full" 102 + id="atom" 112 103 value={`https://${slug}.openstatus.dev/feed/atom`} 113 104 /> 114 105 </div> 115 106 </TabsContent> 116 107 <TabsContent value="ssh" className="flex flex-col gap-2"> 117 - <div className="border-b px-2 pb-2"> 118 - <Input placeholder={`ssh ${slug}@ssh.openstatus.dev`} readOnly /> 119 - </div> 120 - <div className="px-2 pb-2"> 121 - <CopyButton 108 + <div className="flex flex-col gap-2 px-2 pb-2"> 109 + <p className="text-sm">Get status via SSH</p> 110 + <CopyInputButton 122 111 className="w-full" 112 + id="ssh" 123 113 value={`ssh ${slug}@ssh.openstatus.dev`} 124 114 /> 125 115 </div> ··· 127 117 </Tabs> 128 118 </PopoverContent> 129 119 </Popover> 120 + ); 121 + } 122 + 123 + function CopyInputButton({ 124 + value, 125 + onClick, 126 + ...props 127 + }: React.ComponentProps<typeof Input> & { 128 + value: string; 129 + }) { 130 + const { copy, isCopied } = useCopyToClipboard(); 131 + return ( 132 + <div className="relative w-full"> 133 + <Input 134 + placeholder={value} 135 + readOnly 136 + onClick={(e) => { 137 + copy(value, { 138 + successMessage: "Link copied to clipboard", 139 + }); 140 + onClick?.(e); 141 + }} 142 + {...props} 143 + /> 144 + <Button 145 + variant="outline" 146 + size="icon" 147 + onClick={() => 148 + copy(value, { 149 + successMessage: "Link copied to clipboard", 150 + }) 151 + } 152 + className="-translate-y-1/2 absolute top-1/2 right-2 size-6" 153 + > 154 + {isCopied ? <Check /> : <Copy />} 155 + <span className="sr-only">Copy Link</span> 156 + </Button> 157 + </div> 130 158 ); 131 159 } 132 160
+4 -1
apps/status-page/src/components/status-page/status.tsx
··· 181 181 ...props 182 182 }: React.ComponentProps<"div">) { 183 183 return ( 184 - <div className={cn("text-muted-foreground text-sm", className)} {...props}> 184 + <div 185 + className={cn("font-mono text-muted-foreground text-sm", className)} 186 + {...props} 187 + > 185 188 {children} 186 189 </div> 187 190 );
+2 -2
apps/status-page/src/components/ui/chart.tsx
··· 205 205 !hideIndicator && ( 206 206 <div 207 207 className={cn( 208 - "shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", 208 + "shrink-0 rounded-(--radius-xs) border-(--color-border) bg-(--color-bg)", 209 209 { 210 210 "h-2.5 w-2.5": indicator === "dot", 211 211 "w-1": indicator === "line", ··· 295 295 <itemConfig.icon /> 296 296 ) : ( 297 297 <div 298 - className="h-2 w-2 shrink-0 rounded-[2px]" 298 + className="h-2 w-2 shrink-0 rounded-(--radius-xs)" 299 299 style={{ 300 300 backgroundColor: item.color, 301 301 }}
+98
apps/status-page/src/lib/community-themes/README.md
··· 1 + # Community Themes 2 + 3 + This directory contains community-contributed themes for openstatus status pages. These themes allow users to customize the visual appearance of their status pages with different color schemes and design styles. 4 + 5 + ## What are Community Themes? 6 + 7 + Community themes are predefined color schemes that users can apply to their status pages. Each theme includes: 8 + - **Light mode** colors for bright environments 9 + - **Dark mode** colors for low-light environments 10 + - **Consistent design** that works across all status page components 11 + - **Accessibility** considerations for better readability 12 + 13 + ## Themes Examples 14 + 15 + - **Default** - The standard openstatus theme 16 + - **GitHub (High Contrast)** - High contrast theme inspired by GitHub's design 17 + - **Supabase** - Theme matching Supabase's brand colors 18 + 19 + ## Creating a New Theme 20 + 21 + Want to contribute a theme? Follow these steps: 22 + 23 + ### 1. Fork the Repository 24 + Start by forking the openstatus repository to your GitHub account. 25 + 26 + ### 2. Create Your Theme File 27 + Create a new TypeScript file in this directory (e.g., `my-theme.ts`). You can copy an existing theme file as a starting template. 28 + 29 + ### 3. Define Your Theme 30 + Your theme file should export a constant that matches the `Theme` interface: 31 + 32 + ```typescript 33 + import type { Theme } from "./types"; 34 + 35 + export const MY_THEME = { 36 + id: "my-theme", // Unique identifier (kebab-case) 37 + name: "My Awesome Theme", // Display name 38 + author: { 39 + name: "@yourusername", 40 + url: "https://github.com/yourusername" 41 + }, 42 + light: { 43 + // CSS custom properties for light mode 44 + "--background": "oklch(100% 0 0)", 45 + "--foreground": "oklch(20% 0 0)", 46 + // ... more variables 47 + }, 48 + dark: { 49 + // CSS custom properties for dark mode 50 + "--background": "oklch(10% 0 0)", 51 + "--foreground": "oklch(90% 0 0)", 52 + // ... more variables 53 + }, 54 + } as const satisfies Theme; 55 + ``` 56 + 57 + ### 4. Add to Theme Registry 58 + Update `index.ts` to include your theme in the `THEMES_LIST` array. 59 + 60 + ### 5. Submit a Pull Request 61 + Create a pull request with your theme for review. 62 + 63 + ## Design Guidelines 64 + 65 + ### Color System 66 + - Use **OKLCH color space** for better perceptual uniformity 67 + - Ensure sufficient contrast ratios for accessibility 68 + - Test both light and dark modes thoroughly 69 + 70 + ### Theme Requirements 71 + - ✅ **Consistent design** - All components should feel cohesive 72 + - ✅ **Both modes** - Must support light and dark variants 73 + - ✅ **Accessibility** - Meet WCAG contrast guidelines 74 + - ✅ **Unique ID** - Use descriptive, kebab-case identifiers 75 + - ❌ **No "Christmas tree"** - Avoid overly colorful or distracting designs 76 + 77 + ### Available CSS Variables 78 + Themes can customize these CSS custom properties: 79 + - `--background`, `--foreground` - Base colors 80 + - `--primary`, `--secondary`, `--accent` - Brand colors 81 + - `--success`, `--warning`, `--destructive` - Status colors 82 + - `--border`, `--input`, `--ring` - Interactive elements 83 + - `--muted`, `--muted-foreground` - Subtle text and backgrounds 84 + - And many more... (see `types.ts` for the complete list) 85 + 86 + ## Testing Your Theme 87 + 88 + Before submitting: 89 + 1. Test your theme on a status page 90 + 2. Verify both light and dark modes work correctly 91 + 3. Check accessibility with browser dev tools 92 + 4. Ensure all status indicators (operational, degraded, etc.) are clearly distinguishable 93 + 94 + To test a theme, you can use the `sessionStorage.setItem("community-theme", "true");` on your stpg.dev or vercel preview link. It will open a floating button on the right left corner where you can choose between the themes and dark/light mode. 95 + 96 + ## Questions? 97 + 98 + Need help creating a theme? Open an issue or reach out to the openstatus community!
+47
apps/status-page/src/lib/community-themes/github-contrast.ts
··· 1 + import type { Theme } from "./types"; 2 + 3 + export const GITHUB_CONTRAST = { 4 + id: "github-contrast", 5 + name: "Github (High Contrast)", 6 + author: { name: "@openstatus", url: "https://openstatus.dev" }, 7 + light: { 8 + "--background": "oklch(100% 0 0)", 9 + "--foreground": "oklch(24.29% 0.0045 247.86)", 10 + "--border": "oklch(85.86% 0.0054 251.18)", 11 + "--input": "oklch(85.86% 0.0054 251.18)", 12 + 13 + "--primary": "oklch(60.81% 0.1567 142.5)", 14 + "--primary-foreground": "oklch(24.29% 0.0045 247.86)", 15 + "--muted": "oklch(97.86% 0.0019 247.86)", 16 + "--muted-foreground": "oklch(40.78% 0.0056 247.86)", 17 + "--secondary": "oklch(97.86% 0.0019 247.86)", 18 + "--secondary-foreground": "oklch(24.29% 0.0045 247.86)", 19 + "--accent": "oklch(97.86% 0.0019 247.86)", 20 + "--accent-foreground": "oklch(24.29% 0.0045 247.86)", 21 + 22 + "--success": "oklch(60.81% 0.1567 142.5)", 23 + "--destructive": "oklch(58.79% 0.1577 22.18)", 24 + "--warning": "oklch(81.84% 0.1328 85.87)", 25 + "--info": "oklch(45.2% 0.1445 252.03)", 26 + }, 27 + dark: { 28 + "--background": "oklch(10.39% 0.0194 248.34)", 29 + "--foreground": "oklch(100% 0 0)", 30 + "--border": "oklch(58.41% 0.011 252.87)", 31 + "--input": "oklch(58.41% 0.011 252.87)", 32 + 33 + "--primary": "oklch(54.34% 0.1634 145.98)", 34 + "--primary-foreground": "oklch(100% 0 0)", 35 + "--muted": "oklch(33.39% 0.0223 256.4)", 36 + "--muted-foreground": "oklch(79.7% 0.0169 262.74)", 37 + "--secondary": "oklch(33.39% 0.0223 256.4)", 38 + "--secondary-foreground": "oklch(100% 0 0)", 39 + "--accent": "oklch(33.39% 0.0223 256.4)", 40 + "--accent-foreground": "oklch(100% 0 0)", 41 + 42 + "--success": "oklch(54.34% 0.1634 145.98)", 43 + "--destructive": "oklch(47.1% 0.1909 25.95)", 44 + "--warning": "oklch(81.84% 0.1328 85.87)", 45 + "--info": "oklch(46.96% 0.2957 264.51)", 46 + }, 47 + } as const satisfies Theme;
+26
apps/status-page/src/lib/community-themes/index.ts
··· 1 + export * from "./types"; 2 + import { GITHUB_CONTRAST } from "./github-contrast"; 3 + import { SUPABASE } from "./supabase"; 4 + import type { Theme, ThemeMap } from "./types"; 5 + 6 + const DEFAULT_THEME = { 7 + id: "default" as const, 8 + name: "Default", 9 + author: { name: "@openstatus", url: "https://openstatus.dev" }, 10 + light: {}, 11 + dark: {}, 12 + } as const satisfies Theme; 13 + 14 + // TODO: Add validation to ensure that the theme IDs are unique 15 + const THEMES_LIST = [ 16 + DEFAULT_THEME, 17 + GITHUB_CONTRAST, 18 + SUPABASE, 19 + ] satisfies Theme[]; 20 + 21 + export const THEMES = THEMES_LIST.reduce<ThemeMap>((acc, theme) => { 22 + acc[theme.id as keyof ThemeMap] = theme; 23 + return acc; 24 + }, {} as ThemeMap); 25 + 26 + export const THEME_KEYS = THEMES_LIST.map((theme) => theme.id);
+47
apps/status-page/src/lib/community-themes/supabase.ts
··· 1 + import type { Theme } from "./types"; 2 + 3 + export const SUPABASE = { 4 + id: "supabase", 5 + name: "Supabase", 6 + author: { name: "@___", url: "#" }, 7 + light: { 8 + "--background": "oklch(99.11% 0 0)", 9 + "--foreground": "oklch(20.46% 0 0)", 10 + "--border": "oklch(90.37% 0 0)", 11 + "--input": "oklch(90.37% 0 0)", 12 + 13 + "--primary": "oklch(76.26% 0.154 159.27)", 14 + "--primary-foreground": "oklch(20.46% 0 0)", 15 + "--muted": "oklch(97.61% 0 0)", 16 + "--muted-foreground": "oklch(54.52% 0 0)", 17 + "--secondary": "oklch(97.61% 0 0)", 18 + "--secondary-foreground": "oklch(20.46% 0 0)", 19 + "--accent": "oklch(97.61% 0 0)", 20 + "--accent-foreground": "oklch(20.46% 0 0)", 21 + 22 + "--success": "oklch(76.26% 0.154 159.27)", 23 + "--destructive": "oklch(62.71% 0.1936 33.34)", 24 + "--warning": "oklch(81.69% 0.1639 75.84)", 25 + "--info": "oklch(61.26% 0.218 283.85)", 26 + }, 27 + dark: { 28 + "--background": "oklch(18.22% 0 0)", 29 + "--foreground": "oklch(98.51% 0 0)", 30 + "--border": "oklch(30.12% 0 0)", 31 + "--input": "oklch(30.12% 0 0)", 32 + 33 + "--primary": "oklch(68.56% 0.1558 158.13)", 34 + "--primary-foreground": "oklch(18.22% 0 0)", 35 + "--muted": "oklch(26.03% 0 0)", 36 + "--muted-foreground": "oklch(63.01% 0 0)", 37 + "--secondary": "oklch(26.03% 0 0)", 38 + "--secondary-foreground": "oklch(98.51% 0 0)", 39 + "--accent": "oklch(26.03% 0 0)", 40 + "--accent-foreground": "oklch(98.51% 0 0)", 41 + 42 + "--success": "oklch(68.56% 0.1558 158.13)", 43 + "--destructive": "oklch(62.71% 0.1936 33.34)", 44 + "--warning": "oklch(70.84% 0.1523 71.24)", 45 + "--info": "oklch(61.26% 0.218 283.85)", 46 + }, 47 + } as const satisfies Theme;
+72
apps/status-page/src/lib/community-themes/types.ts
··· 1 + export const THEME_VAR_NAMES = [ 2 + "--radius", 3 + "--background", 4 + "--foreground", 5 + "--card", 6 + "--card-foreground", 7 + "--popover", 8 + "--popover-foreground", 9 + "--primary", 10 + "--primary-foreground", 11 + "--secondary", 12 + "--secondary-foreground", 13 + "--muted", 14 + "--muted-foreground", 15 + "--accent", 16 + "--accent-foreground", 17 + "--destructive", 18 + "--border", 19 + "--input", 20 + "--ring", 21 + "--chart-1", 22 + "--chart-2", 23 + "--chart-3", 24 + "--chart-4", 25 + "--chart-5", 26 + "--sidebar", 27 + "--sidebar-foreground", 28 + "--sidebar-primary", 29 + "--sidebar-primary-foreground", 30 + "--sidebar-accent", 31 + "--sidebar-accent-foreground", 32 + "--sidebar-border", 33 + "--sidebar-ring", 34 + "--success", 35 + "--warning", 36 + "--info", 37 + "--rainbow-1", 38 + "--rainbow-2", 39 + "--rainbow-3", 40 + "--rainbow-4", 41 + "--rainbow-5", 42 + "--rainbow-6", 43 + "--rainbow-7", 44 + "--rainbow-8", 45 + "--rainbow-9", 46 + "--rainbow-10", 47 + "--rainbow-11", 48 + "--rainbow-12", 49 + "--rainbow-13", 50 + "--rainbow-14", 51 + "--rainbow-15", 52 + "--rainbow-16", 53 + "--rainbow-17", 54 + ] as const; 55 + 56 + export type ThemeVarName = (typeof THEME_VAR_NAMES)[number]; 57 + export type ThemeVars = Partial<Record<ThemeVarName, string>>; 58 + export type ThemeMode = "light" | "dark"; 59 + 60 + export interface ThemeDefinition { 61 + light: ThemeVars; 62 + dark: ThemeVars; 63 + } 64 + 65 + export interface ThemeInfo { 66 + id: string; 67 + name: string; 68 + author: { name: string; url: string }; 69 + } 70 + 71 + export type Theme = ThemeInfo & ThemeDefinition; 72 + export type ThemeMap = Record<string, Theme>;
+3 -2
apps/status-page/src/middleware.ts
··· 44 44 const protectedCookie = cookies.get(createProtectedCookieKey(prefix)); 45 45 const password = protectedCookie ? protectedCookie.value : undefined; 46 46 if (password !== _page.password && !url.pathname.endsWith("/protected")) { 47 + const { pathname, origin } = req.nextUrl; 47 48 const url = new URL( 48 - `${req.nextUrl.origin}${ 49 + `${origin}${ 49 50 type === "pathname" ? `/${prefix}` : "" 50 - }/protected?redirect=${encodeURIComponent(req.url)}`, 51 + }/protected?redirect=${encodeURIComponent(pathname)}`, 51 52 ); 52 53 return NextResponse.redirect(url); 53 54 }
+2 -3
packages/api/src/router/statusPage.ts
··· 123 123 const threshold = new Date().getTime() - 7 * 24 * 60 * 60 * 1000; 124 124 const lastEvents = pageEvents 125 125 .filter((e) => { 126 - if (e.type !== "incident") return false; 127 - if (!e.to || e.to.getTime() >= threshold) return true; 126 + if (e.type === "incident") return false; 127 + if (!e.from || e.from.getTime() >= threshold) return true; 128 128 return false; 129 129 }) 130 130 .sort((a, b) => a.from.getTime() - b.from.getTime()); 131 131 132 132 const openEvents = pageEvents.filter((event) => { 133 - console.log(event.type, event.from, event.to); 134 133 if (event.type === "incident" || event.type === "report") { 135 134 if (!event.to) return true; 136 135 if (event.to < new Date()) return false;
+13
pnpm-lock.yaml
··· 748 748 '@tailwindcss/postcss': 749 749 specifier: 4.1.11 750 750 version: 4.1.11 751 + '@tailwindcss/typography': 752 + specifier: 0.5.10 753 + version: 0.5.10(tailwindcss@4.1.11) 751 754 '@types/dom-speech-recognition': 752 755 specifier: 0.0.6 753 756 version: 0.0.6 ··· 2617 2620 '@ericcornelissen/bash-parser@0.5.2': 2618 2621 resolution: {integrity: sha512-4pIMTa1nEFfMXitv7oaNEWOdM+zpOZavesa5GaiWTgda6Zk32CFGxjUp/iIaN0PwgUW1yTq/fztSjbpE8SLGZQ==} 2619 2622 engines: {node: '>=4'} 2623 + deprecated: Support for this package will stop 2025-12-31 2620 2624 2621 2625 '@esbuild-kit/core-utils@3.3.2': 2622 2626 resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} ··· 6497 6501 '@upstash/kafka@1.3.3': 6498 6502 resolution: {integrity: sha512-CIr657FZuK+IMuwcxkj3oCB6xKO+LMlHd4BL4J/Lwbpj6+5YHO+5ZcpdMIQhbcemthJcRtE0gDUfZEnrfb3Rjg==} 6499 6503 engines: {node: '>=10'} 6504 + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. 6500 6505 6501 6506 '@upstash/qstash@2.6.2': 6502 6507 resolution: {integrity: sha512-aB/1yqMJTRyOt7Go2Db1ZIVnmTPpsc2KGY5jpLVcegNtjksaPTJF6fmITxos5HVvsQhS8IB3gvF/+gQfRQlPLQ==} ··· 17019 17024 '@tailwindcss/node': 4.1.11 17020 17025 '@tailwindcss/oxide': 4.1.11 17021 17026 postcss: 8.5.6 17027 + tailwindcss: 4.1.11 17028 + 17029 + '@tailwindcss/typography@0.5.10(tailwindcss@4.1.11)': 17030 + dependencies: 17031 + lodash.castarray: 4.4.0 17032 + lodash.isplainobject: 4.0.6 17033 + lodash.merge: 4.6.2 17034 + postcss-selector-parser: 6.0.10 17022 17035 tailwindcss: 4.1.11 17023 17036 17024 17037 '@tailwindcss/typography@0.5.10(tailwindcss@4.1.8)':