Openstatus www.openstatus.dev

feat: theme sidebar (#1489)

* wip:

* wip:

* wip:

* refactor: folder structure

* fix: build

* wip:

* fix: try catch

* fix: tooltip

* fix: style

authored by

Maximilian Kaske and committed by
GitHub
c0d3ebc2 dd2776f2

+963 -64
+55 -13
apps/status-page/src/app/(public)/client.tsx
··· 18 18 StatusTitle, 19 19 } from "@/components/status-page/status"; 20 20 import { StatusBanner } from "@/components/status-page/status-banner"; 21 + import { 22 + StatusEvent, 23 + StatusEventAffected, 24 + StatusEventAffectedBadge, 25 + StatusEventContent, 26 + StatusEventDate, 27 + StatusEventTimelineReport, 28 + StatusEventTitle, 29 + } from "@/components/status-page/status-events"; 21 30 import { StatusMonitor } from "@/components/status-page/status-monitor"; 22 31 import { ThemePalettePicker } from "@/components/themes/theme-palette-picker"; 23 32 import { ThemeSelect } from "@/components/themes/theme-select"; ··· 54 63 export function Client() { 55 64 const { resolvedTheme } = useTheme(); 56 65 const [isMounted, setIsMounted] = useState(false); 57 - const [searchParams, setSearchParams] = useQueryStates(searchParamsParsers); 58 - const { q, t } = searchParams; 66 + const [{ q, t }, setSearchParams] = useQueryStates(searchParamsParsers); 59 67 const theme = t ? THEMES[t as keyof typeof THEMES] : undefined; 60 68 61 69 useEffect(() => { ··· 92 100 {theme?.name} 93 101 </div> 94 102 <div className="sm:p-8"> 95 - <ThemePlaygroundMonitor className="scale-80 sm:scale-100" /> 103 + <ThemePlaygroundStatus className="scale-80 sm:scale-100" /> 96 104 </div> 97 105 </div> 98 106 </div> ··· 110 118 /> 111 119 <ThemePalettePicker /> 112 120 </div> 113 - <ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3"> 121 + <ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> 114 122 {THEME_KEYS.filter((k) => { 115 123 const theme = THEMES[k]; 116 124 return ( ··· 147 155 style={style as React.CSSProperties} 148 156 inert 149 157 > 150 - <ThemePlaygroundMonitor className="pointer-events-none scale-80" /> 158 + <ThemePlaygroundStatus className="pointer-events-none scale-80" /> 151 159 </div> 152 160 ) : ( 153 161 <Skeleton className="absolute h-full w-full" /> 154 162 )} 155 163 </div> 156 164 <div className="flex items-start justify-between gap-2"> 157 - <div className="space-y-0.5"> 158 - <div className="font-medium text-foreground text-sm leading-none"> 165 + <div className="space-y-0.5 truncate"> 166 + <div className="font-medium text-foreground text-sm leading-none truncate"> 159 167 {theme.name} 160 168 </div> 161 169 <div className="font-mono text-xs"> ··· 248 256 <Separator /> 249 257 <Section> 250 258 <div className="prose dark:prose-invert prose-sm max-w-none"> 251 - <blockquote> 252 - Ideally, we would allow you to customize your theme with a{" "} 253 - <code>ThemePalettePicker</code> component to easily test and export 254 - your theme. Contributions are welcome! 255 - </blockquote> 256 259 <p> 257 260 Why don't we allow custom css styles to be overridden and only 258 261 support themes? ··· 273 276 ); 274 277 } 275 278 276 - function ThemePlaygroundMonitor({ 279 + function ThemePlaygroundStatus({ 277 280 className, 278 281 ...props 279 282 }: React.ComponentProps<"div"> & {}) { ··· 307 310 </div> 308 311 ); 309 312 } 313 + 314 + // NOTE: we could add a tabs component here to switch between status and events 315 + function ThemePlaygroundEvents({ 316 + className, 317 + ...props 318 + }: React.ComponentProps<"div"> & {}) { 319 + const trpc = useTRPC(); 320 + const { data: report } = useQuery( 321 + trpc.statusPage.getNoopReport.queryOptions(), 322 + ); 323 + const firstUpdate = report?.statusReportUpdates[0]; 324 + 325 + if (!firstUpdate || !report) return null; 326 + 327 + return ( 328 + <div className={cn("h-full w-full", className)} {...props}> 329 + <Status variant="success"> 330 + <StatusEvent> 331 + <StatusEventDate date={firstUpdate.date} className="lg:flex-row" /> 332 + <StatusEventContent hoverable={false}> 333 + <StatusEventTitle className="inline-flex gap-1"> 334 + {report.title} 335 + </StatusEventTitle> 336 + {report.monitorsToStatusReports.length > 0 ? ( 337 + <StatusEventAffected> 338 + {report.monitorsToStatusReports.map((affected) => ( 339 + <StatusEventAffectedBadge key={affected.monitor.id}> 340 + {affected.monitor.name} 341 + </StatusEventAffectedBadge> 342 + ))} 343 + </StatusEventAffected> 344 + ) : null} 345 + <StatusEventTimelineReport updates={report.statusReportUpdates} /> 346 + </StatusEventContent> 347 + </StatusEvent> 348 + </Status> 349 + </div> 350 + ); 351 + }
+31 -6
apps/status-page/src/app/(public)/layout.tsx
··· 1 1 import { Link } from "@/components/common/link"; 2 2 import { ThemeProvider } from "@/components/themes/theme-provider"; 3 + import { 4 + SidebarTrigger, 5 + ThemeSidebar, 6 + } from "@/components/themes/theme-sidebar"; 7 + import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; 3 8 import { Toaster } from "@/components/ui/sonner"; 4 9 import { generateThemeStyles } from "@openstatus/theme-store"; 5 10 import PlausibleProvider from "next-plausible"; 11 + import { Suspense } from "react"; 12 + 13 + const SIDEBAR_WIDTH = "20rem"; 14 + const SIDEBAR_WIDTH_MOBILE = "18rem"; 6 15 7 16 export default async function Layout({ 8 17 children, ··· 17 26 dangerouslySetInnerHTML={{ __html: generateThemeStyles() }} 18 27 /> 19 28 <ThemeProvider attribute="class" enableSystem disableTransitionOnChange> 20 - <main>{children}</main> 21 - <footer className="flex items-center justify-center gap-4 p-4 text-center font-mono text-muted-foreground text-sm"> 22 - <p> 23 - powered by <Link href="https://openstatus.dev">openstatus</Link> 24 - </p> 25 - </footer> 29 + <SidebarProvider 30 + defaultOpen={false} 31 + style={ 32 + { 33 + "--sidebar-width": SIDEBAR_WIDTH, 34 + "--sidebar-width-mobile": SIDEBAR_WIDTH_MOBILE, 35 + } as React.CSSProperties 36 + } 37 + > 38 + <SidebarInset className="relative"> 39 + <SidebarTrigger className="absolute top-2 right-2" /> 40 + <main className="mx-auto">{children}</main> 41 + <footer className="flex items-center justify-center gap-4 p-4 text-center font-mono text-muted-foreground text-sm"> 42 + <p> 43 + powered by <Link href="https://openstatus.dev">openstatus</Link> 44 + </p> 45 + </footer> 46 + </SidebarInset> 47 + <Suspense> 48 + <ThemeSidebar /> 49 + </Suspense> 50 + </SidebarProvider> 26 51 <Toaster richColors expand /> 27 52 </ThemeProvider> 28 53 </PlausibleProvider>
+4 -2
apps/status-page/src/app/(public)/search-params.ts
··· 1 1 import { THEME_KEYS } from "@openstatus/theme-store"; 2 2 import { 3 3 createSearchParamsCache, 4 + parseAsBoolean, 4 5 parseAsString, 5 6 parseAsStringEnum, 6 7 } from "nuqs/server"; 7 8 8 9 export const searchParamsParsers = { 9 - q: parseAsString, 10 - t: parseAsStringEnum(THEME_KEYS).withDefault("default"), 10 + q: parseAsString, // q = query 11 + t: parseAsStringEnum(THEME_KEYS).withDefault("default"), // t = theme 12 + b: parseAsBoolean.withDefault(false), // b = builder 11 13 }; 12 14 13 15 export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+19 -9
apps/status-page/src/components/status-page/floating-button.tsx
··· 28 28 import { 29 29 THEMES, 30 30 THEME_KEYS, 31 + type ThemeDefinition, 31 32 generateThemeStyles, 32 33 } from "@openstatus/theme-store"; 33 34 import { Check, ChevronsUpDown, Settings } from "lucide-react"; ··· 340 341 ); 341 342 } 342 343 343 - export function recomputeStyles(newTheme: CommunityTheme) { 344 + export function recomputeStyles( 345 + newTheme: CommunityTheme, 346 + overrides?: Partial<ThemeDefinition>, 347 + ) { 344 348 // FIXME: only on prod, we have two style elements with the same id 345 349 // we need to get rid of all of them except the one we want to update 346 - const allThemeStyles = document.querySelectorAll("style[id='theme-styles']"); 347 - allThemeStyles.forEach((style, index) => { 348 - if (index === 0) { 349 - style.textContent = generateThemeStyles(newTheme); 350 - } else { 351 - style.remove(); 352 - } 353 - }); 350 + try { 351 + const allThemeStyles = document.querySelectorAll( 352 + "style[id='theme-styles']", 353 + ); 354 + allThemeStyles.forEach((style, index) => { 355 + if (index === 0) { 356 + style.textContent = generateThemeStyles(newTheme, overrides); 357 + } else { 358 + style.remove(); 359 + } 360 + }); 361 + } catch (error) { 362 + console.error(error); 363 + } 354 364 }
+23 -26
apps/status-page/src/components/themes/theme-palette-picker.tsx
··· 1 + "use client"; 2 + 3 + import { Button } from "@/components/ui/button"; 4 + import { Kbd, KbdGroup } from "@/components/ui/kbd"; 5 + import { useSidebar } from "@/components/ui/sidebar"; 1 6 import { 2 - Popover, 3 - PopoverContent, 4 - PopoverTrigger, 5 - } from "@/components/ui/popover"; 7 + Tooltip, 8 + TooltipContent, 9 + TooltipTrigger, 10 + } from "@/components/ui/tooltip"; 6 11 import { Palette } from "lucide-react"; 7 - import { 8 - EmptyStateContainer, 9 - EmptyStateDescription, 10 - EmptyStateTitle, 11 - } from "../content/empty-state"; 12 - import { Button } from "../ui/button"; 13 12 14 13 export function ThemePalettePicker() { 14 + const { toggleSidebar } = useSidebar(); 15 15 return ( 16 - <Popover> 17 - <PopoverTrigger asChild> 18 - <Button size="icon" variant="outline"> 16 + <Tooltip> 17 + <TooltipTrigger asChild> 18 + <Button size="icon" variant="outline" onClick={toggleSidebar}> 19 19 <Palette className="size-4" /> 20 20 </Button> 21 - </PopoverTrigger> 22 - <PopoverContent side="bottom" align="end" className="w-90"> 23 - <EmptyStateContainer> 24 - <EmptyStateTitle> 25 - Help us ship an epic palette picker! 26 - </EmptyStateTitle> 27 - <EmptyStateDescription> 28 - We are looking for contributions to build an epic palette picker to 29 - help others generate and export their own themes. 30 - </EmptyStateDescription> 31 - </EmptyStateContainer> 32 - </PopoverContent> 33 - </Popover> 21 + </TooltipTrigger> 22 + <TooltipContent className="flex items-center gap-2"> 23 + Toggle Sidebar{" "} 24 + <KbdGroup> 25 + <Kbd>⌘</Kbd> 26 + <span>+</span> 27 + <Kbd>B</Kbd> 28 + </KbdGroup> 29 + </TooltipContent> 30 + </Tooltip> 34 31 ); 35 32 }
+449
apps/status-page/src/components/themes/theme-sidebar.tsx
··· 1 + "use client"; 2 + 3 + import { searchParamsParsers } from "@/app/(public)/search-params"; 4 + import { recomputeStyles } from "@/components/status-page/floating-button"; 5 + import { Button } from "@/components/ui/button"; 6 + import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"; 7 + import { Checkbox } from "@/components/ui/checkbox"; 8 + import { 9 + Collapsible, 10 + CollapsibleContent, 11 + CollapsibleTrigger, 12 + } from "@/components/ui/collapsible"; 13 + import { InputGroup, InputGroupInput } from "@/components/ui/input-group"; 14 + import { Kbd, KbdGroup } from "@/components/ui/kbd"; 15 + import { Label } from "@/components/ui/label"; 16 + import { 17 + Sidebar, 18 + SidebarContent, 19 + SidebarFooter, 20 + SidebarGroup, 21 + SidebarGroupContent, 22 + SidebarGroupLabel, 23 + SidebarHeader, 24 + SidebarMenu, 25 + SidebarMenuButton, 26 + SidebarMenuItem, 27 + useSidebar, 28 + } from "@/components/ui/sidebar"; 29 + import { Skeleton } from "@/components/ui/skeleton"; 30 + import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; 31 + import { 32 + Tooltip, 33 + TooltipContent, 34 + TooltipTrigger, 35 + } from "@/components/ui/tooltip"; 36 + import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 37 + import { useDebounce } from "@/hooks/use-debounce"; 38 + import { cn } from "@/lib/utils"; 39 + import { 40 + THEMES, 41 + type Theme, 42 + type ThemeKey, 43 + type ThemeVarName, 44 + } from "@openstatus/theme-store"; 45 + import { 46 + Check, 47 + ChevronDown, 48 + Copy, 49 + PanelRightIcon, 50 + RotateCcw, 51 + } from "lucide-react"; 52 + import { useTheme } from "next-themes"; 53 + import { useQueryStates } from "nuqs"; 54 + import { useEffect, useState } from "react"; 55 + 56 + type ThemeBuilderColor = { 57 + label: string; 58 + type: "color"; 59 + values: { id: ThemeVarName; label: string }[]; 60 + }; 61 + 62 + type ThemeBuilderCheckbox = { 63 + label: string; 64 + type: "checkbox"; 65 + values: { id: ThemeVarName; label: string }[]; 66 + options: { value: string; label: boolean }[]; 67 + }; 68 + 69 + const THEME_BUILDER_INFO = { 70 + id: { 71 + label: "ID", 72 + id: "id", 73 + type: "text", 74 + }, 75 + name: { 76 + label: "Name", 77 + id: "name", 78 + type: "text", 79 + }, 80 + author: { 81 + label: "Author", 82 + id: "author.name", 83 + type: "text", 84 + }, 85 + authorUrl: { 86 + label: "Link", 87 + id: "author.url", 88 + type: "text", 89 + }, 90 + } as const; 91 + 92 + const THEME_STYLE_BUILDER = { 93 + base: { 94 + label: "Base Colors", 95 + type: "color", 96 + values: [ 97 + { id: "--foreground", label: "Foreground" }, 98 + { id: "--background", label: "Background" }, 99 + // consider linking both border and input to the same color 100 + { id: "--border", label: "Border" }, 101 + { id: "--input", label: "Input" }, 102 + ], 103 + }, 104 + status: { 105 + label: "Status Colors", 106 + type: "color", 107 + values: [ 108 + { id: "--success", label: "Operational" }, 109 + { id: "--destructive", label: "Error" }, 110 + { id: "--warning", label: "Degraded" }, 111 + { id: "--info", label: "Maintenance" }, 112 + ], 113 + }, 114 + brand: { 115 + label: "Brand Colors", 116 + type: "color", 117 + values: [ 118 + { id: "--primary", label: "Primary" }, 119 + { id: "--primary-foreground", label: "Primary Foreground" }, 120 + // consider linking both secondary, muted, accent to the same color 121 + { id: "--secondary", label: "Secondary" }, 122 + { id: "--muted", label: "Muted" }, 123 + { id: "--muted-foreground", label: "Muted Foreground" }, 124 + { id: "--accent", label: "Accent" }, 125 + { id: "--accent-foreground", label: "Accent Foreground" }, 126 + ], 127 + }, 128 + "border-radius": { 129 + label: "Border Radius", 130 + type: "checkbox", 131 + values: [{ id: "--radius", label: "Border Radius" }], 132 + options: [ 133 + { value: "0rem", label: false }, 134 + { value: "0.625rem", label: true }, 135 + ], 136 + }, 137 + } satisfies Record<string, ThemeBuilderColor | ThemeBuilderCheckbox>; 138 + 139 + // Helper function to get nested property value from an object 140 + // biome-ignore lint/suspicious/noExplicitAny: <explanation> 141 + function getNestedValue(obj: any, path: string): string | undefined { 142 + const keys = path.split("."); 143 + let value = obj; 144 + for (const key of keys) { 145 + if (value === undefined || value === null) return undefined; 146 + value = value[key]; 147 + } 148 + return value; 149 + } 150 + 151 + export function ThemeSidebar(props: React.ComponentProps<typeof Sidebar>) { 152 + const [{ t, b }, setSearchParams] = useQueryStates(searchParamsParsers); 153 + const [newTheme, setNewTheme] = useState<Theme>(THEMES[t]); 154 + const { resolvedTheme, setTheme } = useTheme(); 155 + const { copy, isCopied } = useCopyToClipboard(); 156 + const [isMounted, setIsMounted] = useState(false); 157 + const debouncedNewTheme = useDebounce(newTheme, 100); 158 + const { setOpen } = useSidebar(); 159 + 160 + useEffect(() => { 161 + setIsMounted(true); 162 + }, []); 163 + 164 + useEffect(() => { 165 + setNewTheme(THEMES[t]); 166 + }, [t]); 167 + 168 + useEffect(() => { 169 + if (b) { 170 + setOpen(true); 171 + setSearchParams({ b: null }); 172 + } 173 + }, [b, setOpen, setSearchParams]); 174 + 175 + useEffect(() => { 176 + if (!resolvedTheme || !isMounted) return; 177 + recomputeStyles(debouncedNewTheme.id as ThemeKey, { ...debouncedNewTheme }); 178 + }, [resolvedTheme, isMounted, debouncedNewTheme]); 179 + 180 + return ( 181 + <Sidebar side="right" {...props}> 182 + <SidebarHeader className="border-border border-b px-3 font-medium"> 183 + <div className="flex items-center justify-between gap-2"> 184 + <div>Theme Builder</div> 185 + <Tooltip> 186 + <TooltipTrigger asChild> 187 + <Button 188 + variant="ghost" 189 + size="icon" 190 + className="size-7" 191 + onClick={() => setNewTheme(THEMES[t])} 192 + > 193 + <span className="sr-only">Reset</span> 194 + <RotateCcw /> 195 + </Button> 196 + </TooltipTrigger> 197 + <TooltipContent side="left"> 198 + <p>Reset theme</p> 199 + </TooltipContent> 200 + </Tooltip> 201 + </div> 202 + </SidebarHeader> 203 + <SidebarContent> 204 + <Collapsible key="info" defaultOpen className="group/collapsible"> 205 + <SidebarGroup className="pt-2 pb-0"> 206 + <SidebarGroupLabel asChild> 207 + <CollapsibleTrigger> 208 + Information 209 + <ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" /> 210 + </CollapsibleTrigger> 211 + </SidebarGroupLabel> 212 + <CollapsibleContent> 213 + <SidebarGroupContent> 214 + <SidebarMenu> 215 + {Object.entries(THEME_BUILDER_INFO).map(([key, config]) => ( 216 + <SidebarMenuItem key={key}> 217 + <SidebarMenuButton asChild> 218 + <div> 219 + <ButtonGroup className="w-full"> 220 + <ButtonGroupText className="w-24"> 221 + <Label htmlFor={config.id}>{config.label}</Label> 222 + </ButtonGroupText> 223 + <InputGroup className="h-7"> 224 + <InputGroupInput 225 + id={config.id} 226 + defaultValue={ 227 + newTheme 228 + ? getNestedValue(newTheme, config.id) 229 + : "" 230 + } 231 + /> 232 + </InputGroup> 233 + </ButtonGroup> 234 + </div> 235 + </SidebarMenuButton> 236 + </SidebarMenuItem> 237 + ))} 238 + </SidebarMenu> 239 + </SidebarGroupContent> 240 + </CollapsibleContent> 241 + </SidebarGroup> 242 + </Collapsible> 243 + <SidebarGroup className="py-0"> 244 + <SidebarGroupLabel>Theme Mode</SidebarGroupLabel> 245 + <SidebarGroupContent> 246 + <SidebarMenu> 247 + <SidebarMenuItem> 248 + <SidebarMenuButton asChild> 249 + <div> 250 + {!isMounted ? ( 251 + <Skeleton className="h-8 w-full" /> 252 + ) : ( 253 + <Tabs 254 + value={resolvedTheme} 255 + onValueChange={(value) => 256 + setTheme(value as "light" | "dark") 257 + } 258 + className="w-full" 259 + > 260 + <TabsList className="h-8 w-full"> 261 + <TabsTrigger value="light">Light</TabsTrigger> 262 + <TabsTrigger value="dark">Dark</TabsTrigger> 263 + </TabsList> 264 + </Tabs> 265 + )} 266 + </div> 267 + </SidebarMenuButton> 268 + </SidebarMenuItem> 269 + </SidebarMenu> 270 + </SidebarGroupContent> 271 + </SidebarGroup> 272 + {Object.entries(THEME_STYLE_BUILDER).map(([key, config], index) => ( 273 + <Collapsible key={key} defaultOpen className="group/collapsible"> 274 + <SidebarGroup 275 + className={cn( 276 + index !== Object.entries(THEME_STYLE_BUILDER).length - 1 277 + ? "py-0" 278 + : "pt-0", 279 + )} 280 + > 281 + <SidebarGroupLabel asChild> 282 + <CollapsibleTrigger> 283 + {config.label} 284 + <ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" /> 285 + </CollapsibleTrigger> 286 + </SidebarGroupLabel> 287 + <CollapsibleContent> 288 + <SidebarGroupContent> 289 + <SidebarMenu> 290 + {config.values.map((value) => { 291 + return ( 292 + <SidebarMenuItem key={value.id}> 293 + <SidebarMenuButton asChild> 294 + <div> 295 + <span className="truncate">{value.label}</span> 296 + <ThemeValueSelector 297 + config={config} 298 + id={value.id} 299 + theme={newTheme} 300 + setTheme={setNewTheme} 301 + isMounted={isMounted} 302 + /> 303 + </div> 304 + </SidebarMenuButton> 305 + </SidebarMenuItem> 306 + ); 307 + })} 308 + </SidebarMenu> 309 + </SidebarGroupContent> 310 + </CollapsibleContent> 311 + </SidebarGroup> 312 + </Collapsible> 313 + ))} 314 + </SidebarContent> 315 + <SidebarFooter className="border-border border-t"> 316 + <Button 317 + size="sm" 318 + onClick={() => copy(JSON.stringify(newTheme), { withToast: false })} 319 + > 320 + {isCopied ? "Configuration Copied!" : "Copy Configuration"} 321 + {isCopied ? ( 322 + <Check className="size-4" /> 323 + ) : ( 324 + <Copy className="size-4" /> 325 + )} 326 + </Button> 327 + </SidebarFooter> 328 + </Sidebar> 329 + ); 330 + } 331 + 332 + function ThemeValueSelector(props: { 333 + config: ThemeBuilderColor | ThemeBuilderCheckbox; 334 + id: ThemeVarName; 335 + theme: Theme; 336 + setTheme: (theme: Theme) => void; 337 + isMounted: boolean; 338 + }) { 339 + const { resolvedTheme } = useTheme(); 340 + 341 + if (!props.isMounted || !resolvedTheme) 342 + return <Skeleton className="ml-auto size-4 border border-foreground/70" />; 343 + 344 + const value = props.theme[resolvedTheme as "light" | "dark"][props.id]; 345 + 346 + if (props.config.type === "color") { 347 + return ( 348 + <label 349 + className="ml-auto size-4 rounded-full border border-foreground/70" 350 + style={{ backgroundColor: value }} 351 + htmlFor={props.id} 352 + > 353 + <input 354 + type="color" 355 + id={props.id} 356 + name={props.id} 357 + value={value} 358 + className="sr-only" 359 + onChange={(e) => 360 + props.setTheme({ 361 + ...props.theme, 362 + [resolvedTheme as "light" | "dark"]: { 363 + ...props.theme[resolvedTheme as "light" | "dark"], 364 + [props.id]: e.target.value, 365 + }, 366 + }) 367 + } 368 + /> 369 + </label> 370 + ); 371 + } 372 + 373 + if (props.config.type === "checkbox") { 374 + const { options } = props.config; 375 + const checked = 376 + options.find((option) => option.value === value)?.label ?? false; 377 + return ( 378 + <label htmlFor={props.id} className="ml-auto flex items-center gap-2"> 379 + <span className="font-mono text-muted-foreground text-xs"> 380 + {value ?? 381 + options.find((option) => option.label === false)?.value ?? 382 + ""} 383 + </span> 384 + <Checkbox 385 + id={props.id} 386 + name={props.id} 387 + checked={checked} 388 + className="size-4 bg-background" 389 + onCheckedChange={(checked) => 390 + props.setTheme({ 391 + ...props.theme, 392 + [resolvedTheme as "light" | "dark"]: { 393 + ...props.theme[resolvedTheme as "light" | "dark"], 394 + [props.id]: checked 395 + ? options.find((option) => option.label === true)?.value ?? "" 396 + : options.find((option) => option.label === false)?.value ?? 397 + "", 398 + }, 399 + }) 400 + } 401 + /> 402 + </label> 403 + ); 404 + } 405 + 406 + return ( 407 + <span className="ml-auto font-mono text-muted-foreground text-xs"> 408 + {value} 409 + </span> 410 + ); 411 + } 412 + 413 + export function SidebarTrigger({ 414 + className, 415 + onClick, 416 + ...props 417 + }: React.ComponentProps<typeof Button>) { 418 + const { toggleSidebar } = useSidebar(); 419 + 420 + return ( 421 + <Tooltip> 422 + <TooltipTrigger asChild> 423 + <Button 424 + data-sidebar="trigger" 425 + data-slot="sidebar-trigger" 426 + variant="ghost" 427 + size="icon" 428 + className={cn("size-7", className)} 429 + onClick={(event) => { 430 + onClick?.(event); 431 + toggleSidebar(); 432 + }} 433 + {...props} 434 + > 435 + <PanelRightIcon /> 436 + <span className="sr-only">Toggle Sidebar</span> 437 + </Button> 438 + </TooltipTrigger> 439 + <TooltipContent side="left" className="flex items-center gap-2"> 440 + Toggle Sidebar{" "} 441 + <KbdGroup> 442 + <Kbd>⌘</Kbd> 443 + <span>+</span> 444 + <Kbd>B</Kbd> 445 + </KbdGroup> 446 + </TooltipContent> 447 + </Tooltip> 448 + ); 449 + }
+83
apps/status-page/src/components/ui/button-group.tsx
··· 1 + import { Slot } from "@radix-ui/react-slot"; 2 + import { type VariantProps, cva } from "class-variance-authority"; 3 + 4 + import { Separator } from "@/components/ui/separator"; 5 + import { cn } from "@/lib/utils"; 6 + 7 + const buttonGroupVariants = cva( 8 + "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2", 9 + { 10 + variants: { 11 + orientation: { 12 + horizontal: 13 + "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", 14 + vertical: 15 + "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none", 16 + }, 17 + }, 18 + defaultVariants: { 19 + orientation: "horizontal", 20 + }, 21 + }, 22 + ); 23 + 24 + function ButtonGroup({ 25 + className, 26 + orientation, 27 + ...props 28 + }: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) { 29 + return ( 30 + <div 31 + role="group" 32 + data-slot="button-group" 33 + data-orientation={orientation} 34 + className={cn(buttonGroupVariants({ orientation }), className)} 35 + {...props} 36 + /> 37 + ); 38 + } 39 + 40 + function ButtonGroupText({ 41 + className, 42 + asChild = false, 43 + ...props 44 + }: React.ComponentProps<"div"> & { 45 + asChild?: boolean; 46 + }) { 47 + const Comp = asChild ? Slot : "div"; 48 + 49 + return ( 50 + <Comp 51 + className={cn( 52 + "flex items-center gap-2 rounded-md border bg-muted px-4 font-medium text-sm shadow-xs [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none", 53 + className, 54 + )} 55 + {...props} 56 + /> 57 + ); 58 + } 59 + 60 + function ButtonGroupSeparator({ 61 + className, 62 + orientation = "vertical", 63 + ...props 64 + }: React.ComponentProps<typeof Separator>) { 65 + return ( 66 + <Separator 67 + data-slot="button-group-separator" 68 + orientation={orientation} 69 + className={cn( 70 + "!m-0 relative self-stretch bg-input data-[orientation=vertical]:h-auto", 71 + className, 72 + )} 73 + {...props} 74 + /> 75 + ); 76 + } 77 + 78 + export { 79 + ButtonGroup, 80 + ButtonGroupSeparator, 81 + ButtonGroupText, 82 + buttonGroupVariants, 83 + };
+1 -1
apps/status-page/src/components/ui/checkbox.tsx
··· 14 14 <CheckboxPrimitive.Root 15 15 data-slot="checkbox" 16 16 className={cn( 17 - "peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:data-[state=checked]:bg-primary dark:aria-invalid:ring-destructive/40", 17 + "peer size-4 shrink-0 rounded-xs border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:data-[state=checked]:bg-primary dark:aria-invalid:ring-destructive/40", 18 18 className, 19 19 )} 20 20 {...props}
+170
apps/status-page/src/components/ui/input-group.tsx
··· 1 + "use client"; 2 + 3 + import { type VariantProps, cva } from "class-variance-authority"; 4 + import type * as React from "react"; 5 + 6 + import { Button } from "@/components/ui/button"; 7 + import { Input } from "@/components/ui/input"; 8 + import { Textarea } from "@/components/ui/textarea"; 9 + import { cn } from "@/lib/utils"; 10 + 11 + function InputGroup({ className, ...props }: React.ComponentProps<"div">) { 12 + return ( 13 + <div 14 + data-slot="input-group" 15 + role="group" 16 + className={cn( 17 + "group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs outline-none transition-[color,box-shadow] dark:bg-input/30", 18 + "h-9 min-w-0 has-[>textarea]:h-auto", 19 + 20 + // Variants based on alignment. 21 + "has-[>[data-align=inline-start]]:[&>input]:pl-2", 22 + "has-[>[data-align=inline-end]]:[&>input]:pr-2", 23 + "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", 24 + "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", 25 + 26 + // Focus state. 27 + "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50", 28 + 29 + // Error state. 30 + "has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", 31 + 32 + className, 33 + )} 34 + {...props} 35 + /> 36 + ); 37 + } 38 + 39 + const inputGroupAddonVariants = cva( 40 + "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50", 41 + { 42 + variants: { 43 + align: { 44 + "inline-start": 45 + "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", 46 + "inline-end": 47 + "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", 48 + "block-start": 49 + "order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5", 50 + "block-end": 51 + "order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5", 52 + }, 53 + }, 54 + defaultVariants: { 55 + align: "inline-start", 56 + }, 57 + }, 58 + ); 59 + 60 + function InputGroupAddon({ 61 + className, 62 + align = "inline-start", 63 + ...props 64 + }: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) { 65 + return ( 66 + <div 67 + role="group" 68 + data-slot="input-group-addon" 69 + data-align={align} 70 + className={cn(inputGroupAddonVariants({ align }), className)} 71 + onClick={(e) => { 72 + if ((e.target as HTMLElement).closest("button")) { 73 + return; 74 + } 75 + e.currentTarget.parentElement?.querySelector("input")?.focus(); 76 + }} 77 + {...props} 78 + /> 79 + ); 80 + } 81 + 82 + const inputGroupButtonVariants = cva( 83 + "text-sm shadow-none flex gap-2 items-center", 84 + { 85 + variants: { 86 + size: { 87 + xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2", 88 + sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5", 89 + "icon-xs": 90 + "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", 91 + "icon-sm": "size-8 p-0 has-[>svg]:p-0", 92 + }, 93 + }, 94 + defaultVariants: { 95 + size: "xs", 96 + }, 97 + }, 98 + ); 99 + 100 + function InputGroupButton({ 101 + className, 102 + type = "button", 103 + variant = "ghost", 104 + size = "xs", 105 + ...props 106 + }: Omit<React.ComponentProps<typeof Button>, "size"> & 107 + VariantProps<typeof inputGroupButtonVariants>) { 108 + return ( 109 + <Button 110 + type={type} 111 + data-size={size} 112 + variant={variant} 113 + className={cn(inputGroupButtonVariants({ size }), className)} 114 + {...props} 115 + /> 116 + ); 117 + } 118 + 119 + function InputGroupText({ className, ...props }: React.ComponentProps<"span">) { 120 + return ( 121 + <span 122 + className={cn( 123 + "flex items-center gap-2 text-muted-foreground text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none", 124 + className, 125 + )} 126 + {...props} 127 + /> 128 + ); 129 + } 130 + 131 + function InputGroupInput({ 132 + className, 133 + ...props 134 + }: React.ComponentProps<"input">) { 135 + return ( 136 + <Input 137 + data-slot="input-group-control" 138 + className={cn( 139 + "flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent", 140 + className, 141 + )} 142 + {...props} 143 + /> 144 + ); 145 + } 146 + 147 + function InputGroupTextarea({ 148 + className, 149 + ...props 150 + }: React.ComponentProps<"textarea">) { 151 + return ( 152 + <Textarea 153 + data-slot="input-group-control" 154 + className={cn( 155 + "flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent", 156 + className, 157 + )} 158 + {...props} 159 + /> 160 + ); 161 + } 162 + 163 + export { 164 + InputGroup, 165 + InputGroupAddon, 166 + InputGroupButton, 167 + InputGroupText, 168 + InputGroupInput, 169 + InputGroupTextarea, 170 + };
+28
apps/status-page/src/components/ui/kbd.tsx
··· 1 + import { cn } from "@/lib/utils"; 2 + 3 + function Kbd({ className, ...props }: React.ComponentProps<"kbd">) { 4 + return ( 5 + <kbd 6 + data-slot="kbd" 7 + className={cn( 8 + "pointer-events-none inline-flex h-5 w-fit min-w-5 select-none items-center justify-center gap-1 rounded-sm bg-muted px-1 font-medium font-sans text-muted-foreground text-xs", 9 + "[&_svg:not([class*='size-'])]:size-3", 10 + "[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10", 11 + className, 12 + )} 13 + {...props} 14 + /> 15 + ); 16 + } 17 + 18 + function KbdGroup({ className, ...props }: React.ComponentProps<"div">) { 19 + return ( 20 + <kbd 21 + data-slot="kbd-group" 22 + className={cn("inline-flex items-center gap-1", className)} 23 + {...props} 24 + /> 25 + ); 26 + } 27 + 28 + export { Kbd, KbdGroup };
+88
packages/api/src/router/statusPage.ts
··· 413 413 return _report; 414 414 }), 415 415 416 + getNoopReport: publicProcedure.query(async () => { 417 + const date = new Date(new Date().setDate(new Date().getDate() - 4)); 418 + 419 + const resolvedDate = new Date(date.setMinutes(date.getMinutes() - 81)); 420 + const monitoringDate = new Date(date.setMinutes(date.getMinutes() - 54)); 421 + const identifiedDate = new Date(date.setMinutes(date.getMinutes() - 32)); 422 + const investigatingDate = new Date(date.setMinutes(date.getMinutes() - 4)); 423 + 424 + return { 425 + id: 1, 426 + pageId: 1, 427 + status: "investigating" as const, 428 + title: "API Latency Issues", 429 + message: "We are currently investigating elevated API response times.", 430 + createdAt: new Date(new Date().setDate(new Date().getDate() - 2)), 431 + updatedAt: new Date(new Date().setDate(new Date().getDate() - 1)), 432 + monitorsToStatusReports: [ 433 + { 434 + monitorId: 1, 435 + statusReportId: 1, 436 + monitor: { 437 + id: 1, 438 + jobType: "http" as const, 439 + periodicity: "30s" as const, 440 + status: "active" as const, 441 + active: true, 442 + regions: ["ams", "fra"], 443 + url: "https://api.example.com", 444 + name: "API Monitor", 445 + description: "Main API endpoint", 446 + headers: null, 447 + body: null, 448 + method: "GET" as const, 449 + public: true, 450 + deletedAt: null, 451 + createdAt: new Date(new Date().setDate(new Date().getDate() - 30)), 452 + updatedAt: new Date(new Date().setDate(new Date().getDate() - 30)), 453 + workspaceId: 1, 454 + timeout: 30000, 455 + degradedAfter: null, 456 + assertions: null, 457 + }, 458 + }, 459 + ], 460 + statusReportUpdates: [ 461 + { 462 + id: 4, 463 + statusReportId: 1, 464 + status: "resolved" as const, 465 + message: 466 + "All systems are operating normally. The issue has been fully resolved.", 467 + date: resolvedDate, 468 + createdAt: resolvedDate, 469 + updatedAt: resolvedDate, 470 + }, 471 + { 472 + id: 3, 473 + statusReportId: 1, 474 + status: "monitoring" as const, 475 + message: 476 + "We are continuing to monitor the situation to ensure that the issue is resolved.", 477 + date: monitoringDate, 478 + createdAt: monitoringDate, 479 + updatedAt: monitoringDate, 480 + }, 481 + { 482 + id: 2, 483 + statusReportId: 1, 484 + status: "identified" as const, 485 + message: "The issue has been identified and a fix is being deployed.", 486 + date: identifiedDate, 487 + createdAt: identifiedDate, 488 + updatedAt: identifiedDate, 489 + }, 490 + { 491 + id: 1, 492 + statusReportId: 1, 493 + status: "investigating" as const, 494 + message: 495 + "We are investigating reports of increased latency on our API endpoints.", 496 + date: investigatingDate, 497 + createdAt: investigatingDate, 498 + updatedAt: investigatingDate, 499 + }, 500 + ], 501 + }; 502 + }), 503 + 416 504 getMonitors: publicProcedure 417 505 .input(z.object({ slug: z.string().toLowerCase() })) 418 506 .query(async (opts) => {
+5 -3
packages/theme-store/README.md
··· 21 21 22 22 ## Creating a New Theme 23 23 24 - > **Help us ship an epic palette picker!** 25 - > We are looking for contributions to build an epic [palette picker](https://github.com/openstatusHQ/openstatus/blob/main/apps/status-page/src/components/themes/theme-palette-picker.tsx) to help others generate and export their own themes. 24 + Want to contribute a theme? 26 25 27 - Want to contribute a theme? Follow these steps: 26 + We only support themes via GitHub contributions to keep a certain version control. You can: 27 + 28 + - Configure your [themes.openstatus.dev](https://themes.openstatus.dev/?b=true) and copy the configuration 29 + - Directly test by running it locally 28 30 29 31 ### 1. Run the project 30 32
+7 -4
packages/theme-store/src/index.ts
··· 2 2 import { GITHUB_HIGH_CONTRAST_THEME } from "./github"; 3 3 import { OPENSTATUS_ROUNDED_THEME, OPENSTATUS_THEME } from "./openstatus"; 4 4 import { SUPABASE_THEME } from "./supabase"; 5 - import type { Theme, ThemeMap } from "./types"; 5 + import type { Theme, ThemeDefinition, ThemeMap } from "./types"; 6 6 import { assertUniqueThemeIds } from "./utils"; 7 7 8 8 const THEMES_LIST = [ ··· 23 23 export const THEME_KEYS = THEMES_LIST.map((theme) => theme.id); 24 24 export type ThemeKey = (typeof THEME_KEYS)[number]; 25 25 26 - export function generateThemeStyles(themeKey?: string) { 26 + export function generateThemeStyles( 27 + themeKey?: string, 28 + overrides?: Partial<ThemeDefinition>, 29 + ) { 27 30 let theme = themeKey ? THEMES[themeKey] : undefined; 28 31 29 32 if (!theme) { ··· 31 34 theme = OPENSTATUS_THEME; 32 35 } 33 36 34 - const lightVars = Object.entries(theme.light) 37 + const lightVars = Object.entries({ ...theme.light, ...overrides?.light }) 35 38 .map(([key, value]) => `${key}: ${value};`) 36 39 .join("\n "); 37 - const darkVars = Object.entries(theme.dark) 40 + const darkVars = Object.entries({ ...theme.dark, ...overrides?.dark }) 38 41 .map(([key, value]) => `${key}: ${value};`) 39 42 .join("\n "); 40 43