Openstatus www.openstatus.dev

feat: theme explorer (#1468)

* chore: search-params

* wip:

* wip:

* wip:

* fix: build

* refactor: split client and page

* fix: rounded

* wip: mobile sticky

* chore: palette picker

* chore: add README quote

* wip:

* chore: default rounded

* fix: theme command

authored by

Maximilian Kaske and committed by
GitHub
741a2803 61077cfb

+624 -293
+70 -23
apps/dashboard/src/components/forms/status-page/form-configuration.tsx
··· 15 15 } from "@/components/forms/form-card"; 16 16 import { Button } from "@/components/ui/button"; 17 17 import { 18 + Command, 19 + CommandEmpty, 20 + CommandGroup, 21 + CommandInput, 22 + CommandItem, 23 + CommandList, 24 + } from "@/components/ui/command"; 25 + import { 18 26 Dialog, 19 27 DialogClose, 20 28 DialogContent, ··· 34 42 } from "@/components/ui/form"; 35 43 import { Input } from "@/components/ui/input"; 36 44 import { 45 + Popover, 46 + PopoverContent, 47 + PopoverTrigger, 48 + } from "@/components/ui/popover"; 49 + import { 37 50 Select, 38 51 SelectContent, 39 52 SelectItem, ··· 41 54 SelectValue, 42 55 } from "@/components/ui/select"; 43 56 import { Switch } from "@/components/ui/switch"; 57 + import { cn } from "@/lib/utils"; 44 58 import { zodResolver } from "@hookform/resolvers/zod"; 45 - import { THEMES, THEME_KEYS } from "@openstatus/theme-store"; 59 + import { THEMES, THEME_KEYS, type ThemeKey } from "@openstatus/theme-store"; 46 60 import { isTRPCClientError } from "@trpc/client"; 47 - import { ArrowUpRight, Info } from "lucide-react"; 61 + import { ArrowUpRight, Check, ChevronsUpDown, Info } from "lucide-react"; 48 62 import { parseAsStringLiteral, useQueryStates } from "nuqs"; 49 63 import { useForm } from "react-hook-form"; 50 64 import { toast } from "sonner"; ··· 350 364 render={({ field }) => ( 351 365 <FormItem> 352 366 <FormLabel>Theme</FormLabel> 353 - <FormControl> 354 - <Select 355 - onValueChange={field.onChange} 356 - defaultValue={String(field.value) ?? "default"} 357 - > 367 + <Popover> 368 + <PopoverTrigger asChild> 358 369 <FormControl> 359 - <SelectTrigger className="w-full capitalize"> 360 - <SelectValue placeholder="Select a theme" /> 361 - </SelectTrigger> 370 + <Button 371 + id="community-theme" 372 + variant="outline" 373 + role="combobox" 374 + className={cn( 375 + "w-full justify-between", 376 + !field.value && "text-muted-foreground", 377 + )} 378 + > 379 + {THEMES[field.value as ThemeKey]?.name || 380 + "Select a theme"} 381 + <ChevronsUpDown className="opacity-50" /> 382 + </Button> 362 383 </FormControl> 363 - <SelectContent> 364 - {Object.values(THEMES).map((theme) => ( 365 - <SelectItem 366 - key={theme.id} 367 - value={theme.id} 368 - className="capitalize" 369 - > 370 - {theme.name} 371 - </SelectItem> 372 - ))} 373 - </SelectContent> 374 - </Select> 375 - </FormControl> 384 + </PopoverTrigger> 385 + <PopoverContent className="p-0" align="start"> 386 + <Command> 387 + <CommandInput 388 + placeholder="Search themes..." 389 + className="h-9" 390 + /> 391 + <CommandList> 392 + <CommandEmpty>No themes found.</CommandEmpty> 393 + <CommandGroup> 394 + {THEME_KEYS.map((theme) => { 395 + const { name, author } = THEMES[theme]; 396 + return ( 397 + <CommandItem 398 + value={theme} 399 + key={theme} 400 + keywords={[theme, name, author.name]} 401 + onSelect={(v) => field.onChange(v)} 402 + > 403 + <span className="truncate">{name}</span> 404 + <span className="text-muted-foreground text-xs truncate font-commit-mono"> 405 + by {author.name} 406 + </span> 407 + <Check 408 + className={cn( 409 + "ml-auto", 410 + theme === field.value 411 + ? "opacity-100" 412 + : "opacity-0", 413 + )} 414 + /> 415 + </CommandItem> 416 + ); 417 + })} 418 + </CommandGroup> 419 + </CommandList> 420 + </Command> 421 + </PopoverContent> 422 + </Popover> 376 423 </FormItem> 377 424 )} 378 425 />
-1
apps/dashboard/src/components/forms/status-page/form-monitors.tsx
··· 193 193 "w-[200px] justify-between", 194 194 !field.value && "text-muted-foreground", 195 195 )} 196 - size="sm" 197 196 > 198 197 {field.value.length > 0 199 198 ? `${field.value.length} monitors selected`
+303
apps/status-page/src/app/(public)/client.tsx
··· 1 + "use client"; 2 + 3 + import { Link } from "@/components/common/link"; 4 + import { 5 + Section, 6 + SectionDescription, 7 + SectionGroup, 8 + SectionGroupHeader, 9 + SectionHeader, 10 + SectionTitle, 11 + } from "@/components/content/section"; 12 + import { 13 + Status, 14 + StatusContent, 15 + StatusDescription, 16 + StatusHeader, 17 + StatusTitle, 18 + } from "@/components/status-page/status"; 19 + import { StatusBanner } from "@/components/status-page/status-banner"; 20 + import { StatusMonitor } from "@/components/status-page/status-monitor"; 21 + import { ThemePalettePicker } from "@/components/themes/theme-palette-picker"; 22 + import { ThemeSelect } from "@/components/themes/theme-select"; 23 + import { Button } from "@/components/ui/button"; 24 + import { Input } from "@/components/ui/input"; 25 + import { Separator } from "@/components/ui/separator"; 26 + import { 27 + Tooltip, 28 + TooltipContent, 29 + TooltipProvider, 30 + TooltipTrigger, 31 + } from "@/components/ui/tooltip"; 32 + import { monitors } from "@/data/monitors"; 33 + import { useTRPC } from "@/lib/trpc/client"; 34 + import { cn } from "@/lib/utils"; 35 + import { THEMES, THEME_KEYS } from "@openstatus/theme-store"; 36 + import { useQuery } from "@tanstack/react-query"; 37 + import { useTheme } from "next-themes"; 38 + import { useQueryStates } from "nuqs"; 39 + import { useEffect, useState } from "react"; 40 + import { searchParamsParsers } from "./search-params"; 41 + 42 + const MAIN_COLORS = [ 43 + { key: "--primary", label: "Primary" }, 44 + { key: "--success", label: "Operational" }, 45 + { key: "--destructive", label: "Error" }, 46 + { key: "--warning", label: "Degraded" }, 47 + { key: "--info", label: "Maintenance" }, 48 + ] as const; 49 + 50 + // TODO: add keyboard navigation for selection? 51 + 52 + export function Client() { 53 + const { resolvedTheme } = useTheme(); 54 + const [mounted, setMounted] = useState(false); 55 + const [searchParams, setSearchParams] = useQueryStates(searchParamsParsers); 56 + const { q, t } = searchParams; 57 + const theme = t ? THEMES[t as keyof typeof THEMES] : undefined; 58 + 59 + useEffect(() => { 60 + setMounted(true); 61 + }, []); 62 + 63 + useEffect(() => { 64 + const theme = resolvedTheme as "dark" | "light"; 65 + if (["dark", "light"].includes(theme)) { 66 + const element = document.documentElement; 67 + element.removeAttribute("style"); // reset the style 68 + Object.keys(THEMES[t][theme]).forEach((key) => { 69 + const value = 70 + THEMES[t][theme][ 71 + key as keyof (typeof THEMES)[typeof t][typeof theme] 72 + ]; 73 + if (value) { 74 + element.style.setProperty(key, value as string); 75 + } 76 + }); 77 + } 78 + }, [resolvedTheme, t]); 79 + 80 + return ( 81 + <SectionGroup> 82 + <SectionGroupHeader> 83 + <h1 className="font-bold text-2xl md:text-4xl"> 84 + Status Page Theme Explorer 85 + </h1> 86 + <h2 className="font-medium text-muted-foreground md:text-lg"> 87 + View all the openstatus themes and learn how to create your own. 88 + </h2> 89 + </SectionGroupHeader> 90 + <Section> 91 + <SectionHeader> 92 + <SectionTitle>Explorer</SectionTitle> 93 + <SectionDescription> 94 + Search for your favorite status page theme.{" "} 95 + <Link href="#contribute-theme">Contribute your own?</Link> 96 + </SectionDescription> 97 + </SectionHeader> 98 + <div className="sticky top-0 z-10 overflow-hidden rounded-lg border border-border bg-background outline-[3px] outline-background sm:relative"> 99 + <div className="relative"> 100 + <div className="absolute top-0 right-0 rounded-bl-lg border-border border-b border-l bg-muted/50 px-2 py-0.5 text-[10px]"> 101 + {theme?.name} 102 + </div> 103 + <div className="sm:p-8"> 104 + <ThemePlaygroundMonitor className="scale-80 sm:scale-100" /> 105 + </div> 106 + </div> 107 + </div> 108 + <div className="flex gap-3"> 109 + <ThemeSelect /> 110 + <Input 111 + placeholder={`Search from ${THEME_KEYS.length} themes`} 112 + value={q ?? ""} 113 + onChange={(e) => { 114 + if (e.target.value.length === 0) { 115 + setSearchParams({ q: null }); 116 + } 117 + setSearchParams({ q: e.target.value.trim().toLowerCase() }); 118 + }} 119 + /> 120 + <ThemePalettePicker /> 121 + </div> 122 + <ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3"> 123 + {THEME_KEYS.filter((k) => { 124 + const theme = THEMES[k]; 125 + return ( 126 + theme.author.name 127 + .toLowerCase() 128 + .includes(q?.toLowerCase() ?? "") || 129 + theme.name.toLowerCase().includes(q?.toLowerCase() ?? "") 130 + ); 131 + }).map((k) => { 132 + const theme = THEMES[k]; 133 + const style = mounted 134 + ? theme[resolvedTheme as "dark" | "light"] 135 + : undefined; 136 + return ( 137 + <li key={k} className="group/theme-card space-y-1.5"> 138 + <div 139 + data-active={k === t} 140 + data-slot="theme-card" 141 + data-theme={k} 142 + className="relative h-40 cursor-pointer overflow-hidden rounded-md border outline-none transition-all focus:outline-ring/50 focus:ring-2 focus:ring-ring/50 data-[active=true]:border-ring data-[active=true]:outline-[3px] data-[active=true]:outline-ring/50" 143 + onClick={() => setSearchParams({ t: k })} 144 + role="button" 145 + tabIndex={0} 146 + onKeyDown={(e) => { 147 + if (e.key === "Enter" || e.key === " ") { 148 + setSearchParams({ t: k }); 149 + } 150 + }} 151 + > 152 + <div 153 + className="absolute h-full w-full bg-background text-foreground" 154 + style={style as React.CSSProperties} 155 + inert 156 + > 157 + <ThemePlaygroundMonitor className="pointer-events-none scale-80" /> 158 + </div> 159 + </div> 160 + <div className="flex items-start justify-between gap-2"> 161 + <div className="space-y-0.5"> 162 + <div className="font-medium text-foreground text-sm leading-none"> 163 + {theme.name} 164 + </div> 165 + <div className="font-mono text-xs"> 166 + <Link 167 + href={theme.author.url} 168 + target="_blank" 169 + rel="noopener noreferrer" 170 + className="text-muted-foreground" 171 + > 172 + by {theme.author.name} 173 + </Link> 174 + </div> 175 + </div> 176 + <div className="flex gap-0.5"> 177 + {MAIN_COLORS.map((color) => { 178 + const backgroundColor = style 179 + ? style[color.key] 180 + : undefined; 181 + return ( 182 + <TooltipProvider key={color.key}> 183 + <Tooltip> 184 + <TooltipTrigger> 185 + <div 186 + className="size-3.5 rounded-sm border bg-muted-foreground" 187 + style={{ backgroundColor }} 188 + /> 189 + </TooltipTrigger> 190 + <TooltipContent>{color.label}</TooltipContent> 191 + </Tooltip> 192 + </TooltipProvider> 193 + ); 194 + })} 195 + </div> 196 + </div> 197 + </li> 198 + ); 199 + })} 200 + </ul> 201 + </Section> 202 + <Separator /> 203 + <Section> 204 + <SectionHeader id="contribute-theme"> 205 + <SectionTitle>Contribute Theme</SectionTitle> 206 + <SectionDescription> 207 + Contribute your own theme to the community. 208 + </SectionDescription> 209 + </SectionHeader> 210 + <div className="prose dark:prose-invert prose-sm max-w-none"> 211 + <p> 212 + You can contribute your own theme by creating a new file in the{" "} 213 + <code>@openstatus.theme-store</code> package. You&apos;ll only need 214 + to override css variables. Make sure your object is satisfiying the{" "} 215 + <code>Theme</code> interface. 216 + </p> 217 + <p> 218 + Go to the{" "} 219 + <Link href="https://github.com/openstatusHQ/openstatus/tree/main/packages/theme-store"> 220 + GitHub directory 221 + </Link>{" "} 222 + to see the existing themes and create a new one by forking and 223 + creating a pull request. 224 + </p> 225 + <Button 226 + onClick={() => { 227 + // NOTE: we use it to display the 'floating-theme' component 228 + sessionStorage.setItem("community-theme", "true"); 229 + window.location.href = "/status"; 230 + }} 231 + > 232 + Test it 233 + </Button> 234 + {/* TODO: OR go to the status-page config and click on the View and Configure button */} 235 + <p> 236 + Or use the{" "} 237 + <code>sessionStorage.setItem("community-theme", "true");</code> on 238 + your own status page. 239 + </p> 240 + </div> 241 + </Section> 242 + <Separator /> 243 + <Section> 244 + <div className="prose dark:prose-invert prose-sm max-w-none"> 245 + <blockquote> 246 + Ideally, we would allow you to customize your theme with a{" "} 247 + <code>ThemePalettePicker</code> component to easily test and export 248 + your theme. Contributions are welcome! 249 + </blockquote> 250 + <p> 251 + Why don't we allow custom css styles to be overridden and only 252 + support themes? 253 + </p> 254 + <ul> 255 + <li>Keep it simple for the user</li> 256 + <li>Don't end up with a xmas tree</li> 257 + <li>Keep the theme consistent</li> 258 + <li>Avoid conflicts with other styles</li> 259 + <li> 260 + Keep the theme maintainable (but this will also mean, a change 261 + will affect all users) 262 + </li> 263 + </ul> 264 + </div> 265 + </Section> 266 + </SectionGroup> 267 + ); 268 + } 269 + 270 + function ThemePlaygroundMonitor({ 271 + className, 272 + ...props 273 + }: React.ComponentProps<"div"> & {}) { 274 + const trpc = useTRPC(); 275 + const { data: uptimeData, isLoading } = useQuery( 276 + trpc.statusPage.getNoopUptime.queryOptions(), 277 + ); 278 + return ( 279 + // 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 280 + <div className={cn("h-full w-full", className)} {...props}> 281 + <Status variant="success"> 282 + <StatusHeader> 283 + <StatusTitle>Acme Inc.</StatusTitle> 284 + <StatusDescription> 285 + Get informed about our services. 286 + </StatusDescription> 287 + </StatusHeader> 288 + <StatusBanner status="success" /> 289 + <StatusContent> 290 + {/* TODO: create mock data */} 291 + <StatusMonitor 292 + status="success" 293 + data={uptimeData?.data || []} 294 + monitor={monitors[0]} 295 + showUptime={true} 296 + uptime={uptimeData?.uptime} 297 + isLoading={isLoading} 298 + /> 299 + </StatusContent> 300 + </Status> 301 + </div> 302 + ); 303 + }
+1 -6
apps/status-page/src/app/(public)/layout.tsx
··· 15 15 }) { 16 16 return ( 17 17 <PlausibleProvider domain="themes.openstatus.dev"> 18 - <ThemeProvider 19 - attribute="class" 20 - defaultTheme="light" 21 - enableSystem 22 - disableTransitionOnChange 23 - > 18 + <ThemeProvider attribute="class" enableSystem disableTransitionOnChange> 24 19 <main>{children}</main> 25 20 <footer className="flex items-center justify-center gap-4 p-4 text-center font-mono text-muted-foreground text-sm"> 26 21 <p>
+8 -195
apps/status-page/src/app/(public)/page.tsx
··· 1 - "use client"; 1 + import type { SearchParams } from "nuqs"; 2 + import { Client } from "./client"; 3 + import { searchParamsCache } from "./search-params"; 2 4 3 - import { Link } from "@/components/common/link"; 4 - import { 5 - Section, 6 - SectionDescription, 7 - SectionGroup, 8 - SectionHeader, 9 - SectionTitle, 10 - } from "@/components/content/section"; 11 - import { 12 - Status, 13 - StatusContent, 14 - StatusDescription, 15 - StatusHeader, 16 - StatusTitle, 17 - } from "@/components/status-page/status"; 18 - import { StatusBanner } from "@/components/status-page/status-banner"; 19 - import { StatusMonitor } from "@/components/status-page/status-monitor"; 20 - import { Button } from "@/components/ui/button"; 21 - import { monitors } from "@/data/monitors"; 22 - import { useTRPC } from "@/lib/trpc/client"; 23 - import { cn } from "@/lib/utils"; 24 - import { THEMES, THEME_KEYS } from "@openstatus/theme-store"; 25 - import { useQuery } from "@tanstack/react-query"; 26 - 27 - export default function Page() { 28 - return ( 29 - <SectionGroup> 30 - <Section> 31 - <SectionHeader> 32 - <SectionTitle>Status Page Themes</SectionTitle> 33 - <SectionDescription> 34 - View all the current themes you can use.{" "} 35 - <Link href="#contribute-theme">Contribute your own?</Link> 36 - </SectionDescription> 37 - </SectionHeader> 38 - <div className="flex flex-col gap-4"> 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>@openstatus.theme-store</code> package. You&apos;ll only need 76 - to override css variables. Make sure your object is satisfiying the{" "} 77 - <code>Theme</code> interface. 78 - </p> 79 - <p> 80 - Go to the{" "} 81 - <Link href="https://github.com/openstatusHQ/openstatus/tree/main/packages/theme-store"> 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 - <p> 97 - Or use the{" "} 98 - <code>sessionStorage.setItem("community-theme", "true");</code> on 99 - your own status page. 100 - </p> 101 - <hr /> 102 - <p> 103 - Why don't we allow custom css styles to be overridden and only 104 - support themes? 105 - </p> 106 - <ul> 107 - <li>Keep it simple for the user</li> 108 - <li>Don't end up with a xmas tree</li> 109 - <li>Keep the theme consistent</li> 110 - <li>Avoid conflicts with other styles</li> 111 - <li> 112 - Keep the theme maintainable (but this will also mean, a change 113 - will affect all users) 114 - </li> 115 - </ul> 116 - </div> 117 - </Section> 118 - </SectionGroup> 119 - ); 120 - } 121 - 122 - // TODO: the status-tracker hover card is mounted on the body and looses the theme style context 123 - 124 - function ThemeCard({ 125 - theme, 126 - mode, 5 + export default async function Page({ 6 + searchParams, 127 7 }: { 128 - theme: keyof typeof THEMES; 129 - mode: "dark" | "light"; 8 + searchParams: Promise<SearchParams>; 130 9 }) { 131 - const t = THEMES[theme][mode]; 132 - const trpc = useTRPC(); 133 - const { data: uptimeData, isLoading } = useQuery( 134 - trpc.statusPage.getNoopUptime.queryOptions(), 135 - ); 136 - return ( 137 - <div 138 - className={cn( 139 - "group/theme-card overflow-hidden rounded-lg border", 140 - mode === "dark" ? "dark" : "", 141 - )} 142 - > 143 - <div 144 - style={t as React.CSSProperties} 145 - className="h-full w-full bg-background" 146 - > 147 - {/* 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 */} 148 - <div className="pointer-events-none scale-85 bg-background text-foreground transition-all duration-300 group-hover/theme-card:scale-90"> 149 - <Status variant="success"> 150 - <StatusHeader> 151 - <StatusTitle>Acme Inc.</StatusTitle> 152 - <StatusDescription> 153 - Get informed about our services. 154 - </StatusDescription> 155 - </StatusHeader> 156 - <StatusBanner status="success" /> 157 - <StatusContent> 158 - {/* TODO: create mock data */} 159 - <StatusMonitor 160 - status="success" 161 - data={uptimeData?.data || []} 162 - monitor={monitors[0]} 163 - showUptime={true} 164 - uptime={uptimeData?.uptime} 165 - isLoading={isLoading} 166 - /> 167 - </StatusContent> 168 - </Status> 169 - </div> 170 - </div> 171 - </div> 172 - ); 173 - } 174 - 175 - function ThemeGroup({ children, className }: React.ComponentProps<"div">) { 176 - return ( 177 - <div className={cn("grid grid-cols-1 gap-4 sm:grid-cols-2", className)}> 178 - {children} 179 - </div> 180 - ); 181 - } 182 - 183 - function ThemeHeader({ children, className }: React.ComponentProps<"div">) { 184 - return <div className={cn("flex flex-col", className)}>{children}</div>; 185 - } 186 - 187 - function ThemeTitle({ children, className }: React.ComponentProps<"div">) { 188 - return ( 189 - <div className={cn("font-semibold text-base", className)}>{children}</div> 190 - ); 191 - } 192 - 193 - function ThemeAuthor({ children, className }: React.ComponentProps<"div">) { 194 - return ( 195 - <div className={cn("font-mono text-muted-foreground text-xs", className)}> 196 - {children} 197 - </div> 198 - ); 10 + await searchParamsCache.parse(searchParams); 11 + return <Client />; 199 12 }
+13
apps/status-page/src/app/(public)/search-params.ts
··· 1 + import { THEME_KEYS } from "@openstatus/theme-store"; 2 + import { 3 + createSearchParamsCache, 4 + parseAsString, 5 + parseAsStringEnum, 6 + } from "nuqs/server"; 7 + 8 + export const searchParamsParsers = { 9 + q: parseAsString, 10 + t: parseAsStringEnum(THEME_KEYS).withDefault("default"), 11 + }; 12 + 13 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+61 -23
apps/status-page/src/components/status-page/floating-button.tsx
··· 2 2 3 3 import { ThemeSelect } from "@/components/themes/theme-select"; 4 4 import { Button } from "@/components/ui/button"; 5 + import { 6 + CommandEmpty, 7 + CommandGroup, 8 + CommandInput, 9 + CommandItem, 10 + CommandList, 11 + } from "@/components/ui/command"; 5 12 import { Label } from "@/components/ui/label"; 6 13 import { 7 14 Popover, ··· 18 25 import { Separator } from "@/components/ui/separator"; 19 26 import { cn } from "@/lib/utils"; 20 27 import { THEMES, THEME_KEYS } from "@openstatus/theme-store"; 21 - import { Settings } from "lucide-react"; 28 + import { Check, ChevronsUpDown, Command, Settings } from "lucide-react"; 22 29 import { useTheme } from "next-themes"; 23 30 import { parseAsString, useQueryState } from "nuqs"; 24 31 import type React from "react"; ··· 90 97 useEffect(() => { 91 98 const theme = resolvedTheme as "dark" | "light"; 92 99 if (["dark", "light"].includes(theme)) { 100 + const element = document.documentElement; 101 + element.removeAttribute("style"); // reset the style 93 102 Object.keys(THEMES[communityTheme][theme]).forEach((key) => { 94 - const element = document.documentElement; 95 103 const value = 96 104 THEMES[communityTheme][theme][ 97 105 key as keyof (typeof THEMES)[typeof communityTheme][typeof theme] 98 106 ]; 107 + console.log(key, value); 99 108 if (value) { 100 109 element.style.setProperty(key, value as string); 101 110 } 102 111 }); 103 - } 104 - if (communityTheme === "default") { 105 - document.documentElement.removeAttribute("style"); 106 112 } 107 113 }, [resolvedTheme, communityTheme]); 108 114 ··· 315 321 ) : null} 316 322 <div className="space-y-2"> 317 323 <Label htmlFor="community-theme">Community Theme</Label> 318 - <Select 319 - value={communityTheme} 320 - onValueChange={(v) => setCommunityTheme(v as CommunityTheme)} 321 - > 322 - <SelectTrigger 323 - id="community-theme" 324 - className="w-full capitalize" 325 - > 326 - <SelectValue /> 327 - </SelectTrigger> 328 - <SelectContent> 329 - {COMMUNITY_THEME.map((v) => ( 330 - <SelectItem key={v} value={v} className="capitalize"> 331 - {v} 332 - </SelectItem> 333 - ))} 334 - </SelectContent> 335 - </Select> 324 + <Popover> 325 + <PopoverTrigger asChild> 326 + <Button 327 + id="community-theme" 328 + variant="outline" 329 + role="combobox" 330 + className="w-full justify-between font-normal" 331 + > 332 + {communityTheme} 333 + <ChevronsUpDown className="opacity-50" /> 334 + </Button> 335 + </PopoverTrigger> 336 + <PopoverContent className="p-0"> 337 + <Command> 338 + <CommandInput 339 + placeholder="Search themes..." 340 + className="h-9" 341 + /> 342 + <CommandList> 343 + <CommandEmpty>No themes found.</CommandEmpty> 344 + <CommandGroup> 345 + {COMMUNITY_THEME.map((theme) => ( 346 + <CommandItem 347 + value={theme} 348 + key={theme} 349 + onSelect={(v) => 350 + setCommunityTheme(v as CommunityTheme) 351 + } 352 + > 353 + <span className="truncate"> 354 + {THEMES[theme].name} 355 + </span> 356 + <span className="text-muted-foreground text-xs truncate font-commit-mono"> 357 + by {THEMES[theme].author.name} 358 + </span> 359 + <Check 360 + className={cn( 361 + "ml-auto", 362 + theme === communityTheme 363 + ? "opacity-100" 364 + : "opacity-0", 365 + )} 366 + /> 367 + </CommandItem> 368 + ))} 369 + </CommandGroup> 370 + </CommandList> 371 + </Command> 372 + </PopoverContent> 373 + </Popover> 336 374 </div> 337 375 </div> 338 376 </div>
+61 -32
apps/status-page/src/components/status-page/floating-theme.tsx
··· 2 2 3 3 import { ThemeSelect } from "@/components/themes/theme-select"; 4 4 import { Button } from "@/components/ui/button"; 5 + import { 6 + Command, 7 + CommandEmpty, 8 + CommandGroup, 9 + CommandInput, 10 + CommandItem, 11 + CommandList, 12 + } from "@/components/ui/command"; 5 13 import { Label } from "@/components/ui/label"; 6 14 import { 7 15 Popover, 8 16 PopoverContent, 9 17 PopoverTrigger, 10 18 } from "@/components/ui/popover"; 11 - import { 12 - Select, 13 - SelectContent, 14 - SelectItem, 15 - SelectTrigger, 16 - SelectValue, 17 - } from "@/components/ui/select"; 18 19 import { cn } from "@/lib/utils"; 19 20 import { THEMES, THEME_KEYS } from "@openstatus/theme-store"; 20 - import { Palette } from "lucide-react"; 21 + import { Check, ChevronsUpDown, Palette } from "lucide-react"; 21 22 import { useEffect } from "react"; 22 23 import { useState } from "react"; 23 24 import { useStatusPage } from "./floating-button"; ··· 61 62 <div className="space-y-2"> 62 63 <h4 className="font-medium leading-none">Theme Settings</h4> 63 64 <p className="text-muted-foreground text-sm"> 64 - Test the community themes on the status page. 65 + Test community themes on the status page. 65 66 </p> 66 67 </div> 67 68 <div className="space-y-2"> 68 - <Label htmlFor="theme">Theme</Label> 69 + <Label htmlFor="theme">Theme Mode</Label> 69 70 <ThemeSelect id="theme" className="w-full" /> 70 71 </div> 71 72 <div className="space-y-2"> 72 73 <Label htmlFor="community-theme">Community Theme</Label> 73 - <Select 74 - value={communityTheme} 75 - onValueChange={(v) => setCommunityTheme(v as CommunityTheme)} 76 - > 77 - <SelectTrigger 78 - id="community-theme" 79 - className="w-full capitalize" 80 - > 81 - <SelectValue /> 82 - </SelectTrigger> 83 - <SelectContent> 84 - {COMMUNITY_THEME.map((theme) => ( 85 - <SelectItem 86 - key={theme} 87 - value={theme} 88 - className="capitalize" 89 - > 90 - {THEMES[theme].name} 91 - </SelectItem> 92 - ))} 93 - </SelectContent> 94 - </Select> 74 + <Popover> 75 + <PopoverTrigger asChild> 76 + <Button 77 + id="community-theme" 78 + variant="outline" 79 + role="combobox" 80 + className="w-full justify-between font-normal" 81 + > 82 + {THEMES[communityTheme].name} 83 + <ChevronsUpDown className="opacity-50" /> 84 + </Button> 85 + </PopoverTrigger> 86 + <PopoverContent className="p-0"> 87 + <Command> 88 + <CommandInput 89 + placeholder="Search themes..." 90 + className="h-9" 91 + /> 92 + <CommandList> 93 + <CommandEmpty>No themes found.</CommandEmpty> 94 + <CommandGroup> 95 + {COMMUNITY_THEME.map((theme) => ( 96 + <CommandItem 97 + value={theme} 98 + key={theme} 99 + onSelect={(v) => 100 + setCommunityTheme(v as CommunityTheme) 101 + } 102 + > 103 + <span className="truncate"> 104 + {THEMES[theme].name} 105 + </span> 106 + <span className="text-muted-foreground text-xs truncate font-commit-mono"> 107 + by {THEMES[theme].author.name} 108 + </span> 109 + <Check 110 + className={cn( 111 + "ml-auto", 112 + theme === communityTheme 113 + ? "opacity-100" 114 + : "opacity-0", 115 + )} 116 + /> 117 + </CommandItem> 118 + ))} 119 + </CommandGroup> 120 + </CommandList> 121 + </Command> 122 + </PopoverContent> 123 + </Popover> 95 124 </div> 96 125 </div> 97 126 </PopoverContent>
+1 -1
apps/status-page/src/components/status-page/status-events.tsx
··· 361 361 return ( 362 362 <div 363 363 className={cn( 364 - "py-1.5 font-mono text-sm text-muted-foreground", 364 + "py-1.5 font-mono text-muted-foreground text-sm", 365 365 className, 366 366 )} 367 367 {...props}
+35
apps/status-page/src/components/themes/theme-palette-picker.tsx
··· 1 + import { 2 + Popover, 3 + PopoverContent, 4 + PopoverTrigger, 5 + } from "@/components/ui/popover"; 6 + 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 + 14 + export function ThemePalettePicker() { 15 + return ( 16 + <Popover> 17 + <PopoverTrigger asChild> 18 + <Button size="icon" variant="outline"> 19 + <Palette className="size-4" /> 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> 34 + ); 35 + }
+2 -2
apps/status-page/src/components/ui/tooltip.tsx
··· 46 46 data-slot="tooltip-content" 47 47 sideOffset={sideOffset} 48 48 className={cn( 49 - "fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in text-balance rounded-md bg-primary px-3 py-1.5 text-primary-foreground text-xs data-[state=closed]:animate-out", 49 + "fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in text-balance rounded-md bg-foreground px-3 py-1.5 text-background text-xs data-[state=closed]:animate-out", 50 50 className, 51 51 )} 52 52 {...props} 53 53 > 54 54 {children} 55 - <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-primary fill-primary" /> 55 + <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" /> 56 56 </TooltipPrimitive.Content> 57 57 </TooltipPrimitive.Portal> 58 58 );
+1 -1
packages/api/src/router/statusPage.ts
··· 341 341 type: "maintenance", 342 342 from: new Date(new Date().setDate(new Date().getDate() - 10)), 343 343 to: new Date(new Date().setDate(new Date().getDate() - 10)), 344 - name: "", 344 + name: "DB migration", 345 345 id: 1, 346 346 status: "info", 347 347 },
+3
packages/theme-store/README.md
··· 18 18 19 19 ## Creating a New Theme 20 20 21 + > **Help us ship an epic palette picker!** 22 + > 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. 23 + 21 24 Want to contribute a theme? Follow these steps: 22 25 23 26 ### 1. Fork the Repository
+61
packages/theme-store/src/default.ts
··· 1 + import type { Theme } from "./types"; 2 + 3 + export const DEFAULT_THEME = { 4 + id: "default" as const, 5 + name: "Default", 6 + author: { name: "@openstatus", url: "https://openstatus.dev" }, 7 + light: { 8 + "--background": "oklch(100% 0 0)", 9 + "--foreground": "oklch(14.5% 0 0)", 10 + "--border": "oklch(92.2% 0 0)", 11 + "--input": "oklch(92.2% 0 0)", 12 + 13 + "--primary": "oklch(20.5% 0 0)", 14 + "--primary-foreground": "oklch(98.5% 0 0)", 15 + "--secondary": "oklch(97% 0 0)", 16 + "--secondary-foreground": "oklch(20.5% 0 0)", 17 + "--muted": "oklch(97% 0 0)", 18 + "--muted-foreground": "oklch(55.6% 0 0)", 19 + "--accent": "oklch(97% 0 0)", 20 + "--accent-foreground": "oklch(20.5% 0 0)", 21 + 22 + "--success": "oklch(72% 0.19 150)", 23 + "--destructive": "oklch(57.7% 0.245 27.325)", 24 + "--warning": "oklch(77% 0.16 70)", 25 + "--info": "oklch(62% 0.19 260)", 26 + }, 27 + dark: { 28 + "--background": "oklch(14.5% 0 0)", 29 + "--foreground": "oklch(98.5% 0 0)", 30 + "--border": "oklch(100% 0 0 / 10%)", 31 + "--input": "oklch(100% 0 0 / 15%)", 32 + 33 + "--primary": "oklch(92.2% 0 0)", 34 + "--primary-foreground": "oklch(20.5% 0 0)", 35 + "--secondary": "oklch(26.9% 0 0)", 36 + "--secondary-foreground": "oklch(98.5% 0 0)", 37 + "--muted": "oklch(26.9% 0 0)", 38 + "--muted-foreground": "oklch(70.8% 0 0)", 39 + "--accent": "oklch(26.9% 0 0)", 40 + "--accent-foreground": "oklch(98.5% 0 0)", 41 + 42 + "--success": "oklch(72% 0.19 150)", 43 + "--destructive": "oklch(70.4% 0.191 22.216)", 44 + "--warning": "oklch(77% 0.16 70)", 45 + "--info": "oklch(62% 0.19 260)", 46 + }, 47 + } as const satisfies Theme; 48 + 49 + export const DEFAULT_ROUNDED_THEME = { 50 + id: "default-rounded" as const, 51 + name: "Default (Rounded)", 52 + author: DEFAULT_THEME.author, 53 + light: { 54 + ...DEFAULT_THEME.light, 55 + "--radius": "0.625rem", 56 + }, 57 + dark: { 58 + ...DEFAULT_THEME.dark, 59 + "--radius": "0.625rem", 60 + }, 61 + } as const satisfies Theme;
+4 -9
packages/theme-store/src/index.ts
··· 1 1 export * from "./types"; 2 + import { DEFAULT_ROUNDED_THEME, DEFAULT_THEME } from "./default"; 2 3 import { GITHUB_CONTRAST } from "./github-contrast"; 3 4 import { SUPABASE } from "./supabase"; 4 5 import type { Theme, ThemeMap } from "./types"; 5 6 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 7 // TODO: Add validation to ensure that the theme IDs are unique 15 8 const THEMES_LIST = [ 16 9 DEFAULT_THEME, 17 - GITHUB_CONTRAST, 10 + DEFAULT_ROUNDED_THEME, 18 11 SUPABASE, 12 + GITHUB_CONTRAST, 19 13 ] satisfies Theme[]; 20 14 21 15 export const THEMES = THEMES_LIST.reduce<ThemeMap>((acc, theme) => { ··· 24 18 }, {} as ThemeMap); 25 19 26 20 export const THEME_KEYS = THEMES_LIST.map((theme) => theme.id); 21 + export type ThemeKey = (typeof THEME_KEYS)[number];