Openstatus www.openstatus.dev

chore(status-page): style updates (#1438)

* wip:

* refactor: theme-toggle

* chore: footer

* chore: status-event

* wip: components

* fix: status report order

* fix: style

authored by

Maximilian Kaske and committed by
GitHub
89c4e8f6 650a03d4

+314 -217
+1 -1
apps/status-page/src/app/(public)/layout.tsx
··· 1 1 import { Link } from "@/components/common/link"; 2 - import { ThemeProvider } from "@/components/theme-provider"; 2 + import { ThemeProvider } from "@/components/themes/theme-provider"; 3 3 import { Toaster } from "@/components/ui/sonner"; 4 4 import type { Metadata } from "next"; 5 5 import PlausibleProvider from "next-plausible";
+119 -121
apps/status-page/src/app/(status-page)/[domain]/(public)/events/(list)/page.tsx
··· 10 10 import { 11 11 StatusEvent, 12 12 StatusEventAffected, 13 + StatusEventAffectedBadge, 13 14 StatusEventAside, 14 15 StatusEventContent, 16 + StatusEventDate, 17 + StatusEventGroup, 15 18 StatusEventTimelineMaintenance, 16 19 StatusEventTimelineReport, 17 20 StatusEventTitle, 18 21 StatusEventTitleCheck, 19 22 } from "@/components/status-page/status-events"; 20 - import { Badge } from "@/components/ui/badge"; 21 23 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 22 - import { formatDate } from "@/lib/formatter"; 23 24 import { useTRPC } from "@/lib/trpc/client"; 24 25 import { useQuery } from "@tanstack/react-query"; 25 26 import Link from "next/link"; ··· 51 52 <TabsTrigger value="reports">Reports</TabsTrigger> 52 53 <TabsTrigger value="maintenances">Maintenances</TabsTrigger> 53 54 </TabsList> 54 - <TabsContent value="reports" className="flex flex-col gap-4"> 55 - {statusReports.length > 0 ? ( 56 - statusReports.map((report) => { 57 - const updates = report.statusReportUpdates.sort( 58 - (a, b) => b.date.getTime() - a.date.getTime(), 59 - ); 60 - const firstUpdate = updates[updates.length - 1]; 61 - const lastUpdate = updates[0]; 62 - // NOTE: updates are sorted descending by date 63 - const startedAt = firstUpdate.date; 64 - // HACKY: LEGACY: only resolved via report and not via report update 65 - const isReportResolvedOnly = 66 - report.status === "resolved" && lastUpdate.status !== "resolved"; 67 - return ( 68 - <StatusEvent key={report.id}> 69 - <StatusEventAside> 70 - <span className="font-medium text-foreground/80"> 71 - {formatDate(startedAt, { month: "short" })} 72 - </span> 73 - </StatusEventAside> 74 - <Link 75 - href={`./events/report/${report.id}`} 76 - className="rounded-lg" 77 - > 78 - <StatusEventContent> 79 - <StatusEventTitle className="inline-flex gap-1"> 80 - {report.title} 81 - {isReportResolvedOnly ? <StatusEventTitleCheck /> : null} 82 - </StatusEventTitle> 83 - {report.monitorsToStatusReports.length > 0 ? ( 84 - <StatusEventAffected className="flex flex-wrap gap-1"> 85 - {report.monitorsToStatusReports.map((affected) => ( 86 - <Badge 87 - key={affected.monitor.id} 88 - variant="outline" 89 - className="text-[10px]" 90 - > 91 - {affected.monitor.name} 92 - </Badge> 93 - ))} 94 - </StatusEventAffected> 95 - ) : null} 96 - <StatusEventTimelineReport 97 - updates={report.statusReportUpdates} 98 - /> 99 - </StatusEventContent> 100 - </Link> 101 - </StatusEvent> 102 - ); 103 - }) 104 - ) : ( 105 - <StatusBlankContainer> 106 - <div className="relative mt-8 flex w-full flex-col items-center justify-center"> 107 - <StatusBlankReport className="-top-16 absolute scale-60 opacity-50" /> 108 - <StatusBlankReport className="-top-8 absolute scale-80 opacity-80" /> 109 - <StatusBlankReport /> 110 - </div> 111 - <StatusBlankContent> 112 - <StatusBlankTitle>No reports found</StatusBlankTitle> 113 - <StatusBlankDescription> 114 - No reports found for this status page. 115 - </StatusBlankDescription> 116 - </StatusBlankContent> 117 - </StatusBlankContainer> 118 - )} 55 + <TabsContent value="reports"> 56 + <StatusEventGroup> 57 + {statusReports.length > 0 ? ( 58 + statusReports.map((report) => { 59 + const updates = report.statusReportUpdates.sort( 60 + (a, b) => b.date.getTime() - a.date.getTime(), 61 + ); 62 + const firstUpdate = updates[updates.length - 1]; 63 + const lastUpdate = updates[0]; 64 + // NOTE: updates are sorted descending by date 65 + const startedAt = firstUpdate.date; 66 + // HACKY: LEGACY: only resolved via report and not via report update 67 + const isReportResolvedOnly = 68 + report.status === "resolved" && 69 + lastUpdate.status !== "resolved"; 70 + return ( 71 + <StatusEvent key={report.id}> 72 + <StatusEventAside> 73 + <StatusEventDate date={startedAt} /> 74 + </StatusEventAside> 75 + <Link 76 + href={`./events/report/${report.id}`} 77 + className="rounded-lg" 78 + > 79 + <StatusEventContent> 80 + <StatusEventTitle className="inline-flex gap-1"> 81 + {report.title} 82 + {isReportResolvedOnly ? ( 83 + <StatusEventTitleCheck /> 84 + ) : null} 85 + </StatusEventTitle> 86 + {report.monitorsToStatusReports.length > 0 ? ( 87 + <StatusEventAffected> 88 + {report.monitorsToStatusReports.map((affected) => ( 89 + <StatusEventAffectedBadge key={affected.monitor.id}> 90 + {affected.monitor.name} 91 + </StatusEventAffectedBadge> 92 + ))} 93 + </StatusEventAffected> 94 + ) : null} 95 + <StatusEventTimelineReport 96 + updates={report.statusReportUpdates} 97 + /> 98 + </StatusEventContent> 99 + </Link> 100 + </StatusEvent> 101 + ); 102 + }) 103 + ) : ( 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> 117 + )} 118 + </StatusEventGroup> 119 119 </TabsContent> 120 - <TabsContent value="maintenances" className="flex flex-col gap-4"> 121 - {maintenances.length > 0 ? ( 122 - maintenances.map((maintenance) => { 123 - const isFuture = maintenance.from > new Date(); 124 - return ( 125 - <StatusEvent key={maintenance.id}> 126 - <StatusEventAside> 127 - <span className="font-medium text-foreground/80"> 128 - {formatDate(maintenance.from, { month: "short" })} 129 - </span> 130 - {isFuture ? ( 131 - <span className="text-info text-sm">Upcoming</span> 132 - ) : null} 133 - </StatusEventAside> 134 - <Link 135 - href={`./events/maintenance/${maintenance.id}`} 136 - className="rounded-lg" 137 - > 138 - <StatusEventContent> 139 - <StatusEventTitle>{maintenance.title}</StatusEventTitle> 140 - {maintenance.maintenancesToMonitors.length > 0 ? ( 141 - <StatusEventAffected className="flex flex-wrap gap-1"> 142 - {maintenance.maintenancesToMonitors.map((affected) => ( 143 - <Badge 144 - key={affected.monitor.id} 145 - variant="outline" 146 - className="text-[10px]" 147 - > 148 - {affected.monitor.name} 149 - </Badge> 150 - ))} 151 - </StatusEventAffected> 152 - ) : null} 153 - <StatusEventTimelineMaintenance maintenance={maintenance} /> 154 - </StatusEventContent> 155 - </Link> 156 - </StatusEvent> 157 - ); 158 - }) 159 - ) : ( 160 - <StatusBlankContainer> 161 - <div className="relative mt-8 flex w-full flex-col items-center justify-center"> 162 - <StatusBlankReport className="-top-16 absolute scale-60 opacity-50" /> 163 - <StatusBlankReport className="-top-8 absolute scale-80 opacity-80" /> 164 - <StatusBlankReport /> 165 - </div> 166 - <StatusBlankContent> 167 - <StatusBlankTitle>No maintenances found</StatusBlankTitle> 168 - <StatusBlankDescription> 169 - No maintenances found for this status page. 170 - </StatusBlankDescription> 171 - </StatusBlankContent> 172 - </StatusBlankContainer> 173 - )} 120 + <TabsContent value="maintenances"> 121 + <StatusEventGroup> 122 + {maintenances.length > 0 ? ( 123 + maintenances.map((maintenance) => { 124 + return ( 125 + <StatusEvent key={maintenance.id}> 126 + <StatusEventAside> 127 + <StatusEventDate date={maintenance.from} /> 128 + </StatusEventAside> 129 + <Link 130 + href={`./events/maintenance/${maintenance.id}`} 131 + className="rounded-lg" 132 + > 133 + <StatusEventContent> 134 + <StatusEventTitle>{maintenance.title}</StatusEventTitle> 135 + {maintenance.maintenancesToMonitors.length > 0 ? ( 136 + <StatusEventAffected> 137 + {maintenance.maintenancesToMonitors.map( 138 + (affected) => ( 139 + <StatusEventAffectedBadge 140 + key={affected.monitor.id} 141 + > 142 + {affected.monitor.name} 143 + </StatusEventAffectedBadge> 144 + ), 145 + )} 146 + </StatusEventAffected> 147 + ) : null} 148 + <StatusEventTimelineMaintenance 149 + maintenance={maintenance} 150 + /> 151 + </StatusEventContent> 152 + </Link> 153 + </StatusEvent> 154 + ); 155 + }) 156 + ) : ( 157 + <StatusBlankContainer> 158 + <div className="relative mt-8 flex w-full flex-col items-center justify-center"> 159 + <StatusBlankReport className="-top-16 absolute scale-60 opacity-50" /> 160 + <StatusBlankReport className="-top-8 absolute scale-80 opacity-80" /> 161 + <StatusBlankReport /> 162 + </div> 163 + <StatusBlankContent> 164 + <StatusBlankTitle>No maintenances found</StatusBlankTitle> 165 + <StatusBlankDescription> 166 + No maintenances found for this status page. 167 + </StatusBlankDescription> 168 + </StatusBlankContent> 169 + </StatusBlankContainer> 170 + )} 171 + </StatusEventGroup> 174 172 </TabsContent> 175 173 </Tabs> 176 174 );
+6 -17
apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/maintenance/[id]/page.tsx
··· 3 3 import { useTRPC } from "@/lib/trpc/client"; 4 4 import { useQuery } from "@tanstack/react-query"; 5 5 6 - import { formatDate } from "@/lib/formatter"; 7 - 8 6 import { ButtonBack } from "@/components/button/button-back"; 9 7 import { ButtonCopyLink } from "@/components/button/button-copy-link"; 10 8 import { 11 9 StatusEvent, 12 10 StatusEventAffected, 11 + StatusEventAffectedBadge, 13 12 StatusEventAside, 14 13 StatusEventContent, 14 + StatusEventDate, 15 15 StatusEventTimelineMaintenance, 16 16 StatusEventTitle, 17 17 } from "@/components/status-page/status-events"; 18 - import { Badge } from "@/components/ui/badge"; 19 18 import { useParams } from "next/navigation"; 20 19 21 20 export default function MaintenancePage() { ··· 30 29 31 30 if (!maintenance) return null; 32 31 33 - const isFuture = maintenance.from > new Date(); 34 32 return ( 35 33 <div className="flex flex-col gap-4"> 36 34 <div className="flex w-full flex-row items-center justify-between gap-2 py-0.5"> ··· 39 37 </div> 40 38 <StatusEvent> 41 39 <StatusEventAside> 42 - <span className="font-medium text-foreground/80"> 43 - {formatDate(maintenance.from, { month: "short" })} 44 - </span> 45 - {isFuture ? ( 46 - <span className="text-info text-sm">Upcoming</span> 47 - ) : null} 40 + <StatusEventDate date={maintenance.from} /> 48 41 </StatusEventAside> 49 42 <StatusEventContent hoverable={false}> 50 43 <StatusEventTitle>{maintenance.title}</StatusEventTitle> 51 - <StatusEventAffected className="flex flex-wrap gap-1"> 44 + <StatusEventAffected> 52 45 {maintenance.maintenancesToMonitors.map((affected) => ( 53 - <Badge 54 - key={affected.monitor.id} 55 - variant="outline" 56 - className="text-[10px]" 57 - > 46 + <StatusEventAffectedBadge key={affected.monitor.id}> 58 47 {affected.monitor.name} 59 - </Badge> 48 + </StatusEventAffectedBadge> 60 49 ))} 61 50 </StatusEventAffected> 62 51 <StatusEventTimelineMaintenance maintenance={maintenance} />
+6 -13
apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/report/[id]/page.tsx
··· 1 1 "use client"; 2 2 3 - import { formatDate } from "@/lib/formatter"; 4 - 5 3 import { ButtonBack } from "@/components/button/button-back"; 6 4 import { ButtonCopyLink } from "@/components/button/button-copy-link"; 7 5 import { 8 6 StatusEvent, 9 7 StatusEventAffected, 8 + StatusEventAffectedBadge, 10 9 StatusEventAside, 11 10 StatusEventContent, 11 + StatusEventDate, 12 12 StatusEventTimelineReport, 13 13 StatusEventTitle, 14 14 StatusEventTitleCheck, 15 15 } from "@/components/status-page/status-events"; 16 - import { Badge } from "@/components/ui/badge"; 17 16 import { useTRPC } from "@/lib/trpc/client"; 18 17 import { useQuery } from "@tanstack/react-query"; 19 18 import { useParams } from "next/navigation"; ··· 45 44 </div> 46 45 <StatusEvent> 47 46 <StatusEventAside> 48 - <span className="font-medium text-foreground/80"> 49 - {formatDate(firstUpdate.date, { month: "short" })} 50 - </span> 47 + <StatusEventDate date={firstUpdate.date} /> 51 48 </StatusEventAside> 52 49 <StatusEventContent hoverable={false}> 53 50 <StatusEventTitle className="inline-flex gap-1"> ··· 55 52 {isReportResolvedOnly ? <StatusEventTitleCheck /> : null} 56 53 </StatusEventTitle> 57 54 {report.monitorsToStatusReports.length > 0 ? ( 58 - <StatusEventAffected className="flex flex-wrap gap-1"> 55 + <StatusEventAffected> 59 56 {report.monitorsToStatusReports.map((affected) => ( 60 - <Badge 61 - key={affected.monitor.id} 62 - variant="outline" 63 - className="text-[10px]" 64 - > 57 + <StatusEventAffectedBadge key={affected.monitor.id}> 65 58 {affected.monitor.name} 66 - </Badge> 59 + </StatusEventAffectedBadge> 67 60 ))} 68 61 </StatusEventAffected> 69 62 ) : null}
+1 -1
apps/status-page/src/app/(status-page)/[domain]/layout.tsx
··· 4 4 StatusPageProvider, 5 5 } from "@/components/status-page/floating-button"; 6 6 import { FloatingTheme } from "@/components/status-page/floating-theme"; 7 - import { ThemeProvider } from "@/components/theme-provider"; 7 + import { ThemeProvider } from "@/components/themes/theme-provider"; 8 8 import { Toaster } from "@/components/ui/sonner"; 9 9 import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; 10 10 import type { Metadata } from "next";
+23 -9
apps/status-page/src/components/nav/footer.tsx
··· 2 2 3 3 import { Link } from "@/components/common/link"; 4 4 import { TimestampHoverCard } from "@/components/content/timestamp-hover-card"; 5 - import { ThemeToggle } from "@/components/theme-toggle"; 5 + import { ThemeDropdown } from "@/components/themes/theme-dropdown"; 6 6 import { useTRPC } from "@/lib/trpc/client"; 7 7 import { useQuery } from "@tanstack/react-query"; 8 - import { format } from "date-fns"; 8 + import { Clock } from "lucide-react"; 9 9 import { useParams } from "next/navigation"; 10 10 11 11 export function Footer(props: React.ComponentProps<"footer">) { ··· 14 14 const { data: page, dataUpdatedAt } = useQuery( 15 15 trpc.statusPage.get.queryOptions({ slug: domain }), 16 16 ); 17 + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 17 18 18 19 if (!page) return null; 19 20 ··· 21 22 <footer {...props}> 22 23 <div className="mx-auto flex max-w-2xl items-center justify-between gap-4 px-3 py-2"> 23 24 <div> 24 - <p className="font-mono text-muted-foreground text-sm leading-none"> 25 - powered by <Link href="https://openstatus.dev">openstatus</Link> 25 + <p className="font-mono text-muted-foreground text-xs leading-none sm:text-sm"> 26 + powered by{" "} 27 + <Link 28 + href="https://openstatus.dev" 29 + target="_blank" 30 + rel="noreferrer" 31 + > 32 + openstatus.dev 33 + </Link> 26 34 </p> 27 - <TimestampHoverCard date={new Date(dataUpdatedAt)} side="top"> 28 - <span className="text-muted-foreground/70 text-xs"> 29 - {format(new Date(dataUpdatedAt), "LLL dd, y HH:mm:ss")} 30 - </span> 35 + </div> 36 + <div className="flex items-center gap-4"> 37 + <TimestampHoverCard 38 + date={new Date(dataUpdatedAt)} 39 + side="top" 40 + align="end" 41 + className="flex items-center gap-1.5 text-muted-foreground/70" 42 + > 43 + <Clock className="size-3" /> 44 + <span className="font-mono text-xs">{timezone}</span> 31 45 </TimestampHoverCard> 46 + <ThemeDropdown /> 32 47 </div> 33 - <ThemeToggle className="w-[140px]" /> 34 48 </div> 35 49 </footer> 36 50 );
+5 -1
apps/status-page/src/components/nav/header.tsx
··· 130 130 function NavDesktop({ className, ...props }: React.ComponentProps<"ul">) { 131 131 const nav = useNav(); 132 132 return ( 133 - <ul className={cn("flex flex-row gap-2", className)} {...props}> 133 + <ul className={cn("flex flex-row gap-0.5", className)} {...props}> 134 134 {nav.map((item) => { 135 135 return ( 136 136 <li key={item.label}> 137 137 <Button 138 138 variant={item.isActive ? "secondary" : "ghost"} 139 + className={cn( 140 + "border", 141 + item.isActive ? "border-input" : "border-transparent", 142 + )} 139 143 size="sm" 140 144 asChild 141 145 >
+2 -2
apps/status-page/src/components/status-page/floating-button.tsx
··· 1 1 "use client"; 2 2 3 - import { ThemeToggle } from "@/components/theme-toggle"; 3 + import { ThemeSelect } from "@/components/themes/theme-select"; 4 4 import { Button } from "@/components/ui/button"; 5 5 import { Label } from "@/components/ui/label"; 6 6 import { ··· 310 310 {IS_DEV ? ( 311 311 <div className="space-y-2"> 312 312 <Label htmlFor="theme">Theme</Label> 313 - <ThemeToggle id="theme" className="w-full" /> 313 + <ThemeSelect id="theme" className="w-full" /> 314 314 </div> 315 315 ) : null} 316 316 <div className="space-y-2">
+2 -2
apps/status-page/src/components/status-page/floating-theme.tsx
··· 1 1 "use client"; 2 2 3 - import { ThemeToggle } from "@/components/theme-toggle"; 3 + import { ThemeSelect } from "@/components/themes/theme-select"; 4 4 import { Button } from "@/components/ui/button"; 5 5 import { Label } from "@/components/ui/label"; 6 6 import { ··· 66 66 </div> 67 67 <div className="space-y-2"> 68 68 <Label htmlFor="theme">Theme</Label> 69 - <ThemeToggle id="theme" className="w-full" /> 69 + <ThemeSelect id="theme" className="w-full" /> 70 70 </div> 71 71 <div className="space-y-2"> 72 72 <Label htmlFor="community-theme">Community Theme</Label>
+60 -16
apps/status-page/src/components/status-page/status-events.tsx
··· 1 1 import { ProcessMessage } from "@/components/content/process-message"; 2 2 import { TimestampHoverCard } from "@/components/content/timestamp-hover-card"; 3 + import { Badge } from "@/components/ui/badge"; 3 4 import { Separator } from "@/components/ui/separator"; 4 5 import { 5 6 Tooltip, ··· 7 8 TooltipProvider, 8 9 TooltipTrigger, 9 10 } from "@/components/ui/tooltip"; 10 - import { formatDateRange, formatDateTime } from "@/lib/formatter"; 11 + import { formatDate, formatDateRange, formatDateTime } from "@/lib/formatter"; 11 12 import { cn } from "@/lib/utils"; 12 13 import { formatDistanceStrict } from "date-fns"; 13 14 import { Check } from "lucide-react"; 14 15 import { status } from "./messages"; 16 + 17 + export function StatusEventGroup({ 18 + className, 19 + children, 20 + ...props 21 + }: React.ComponentProps<"div">) { 22 + return ( 23 + <div className={cn("flex flex-col gap-4", className)} {...props}> 24 + {children} 25 + </div> 26 + ); 27 + } 15 28 16 29 export function StatusEvent({ 17 30 className, ··· 67 80 ...props 68 81 }: React.ComponentProps<"div">) { 69 82 return ( 70 - <div className={cn("mt-1 ml-1.5", className)} {...props}> 83 + <div className={cn("flex items-center pl-1", className)} {...props}> 71 84 <TooltipProvider> 72 85 <Tooltip> 73 86 <TooltipTrigger> ··· 91 104 ...props 92 105 }: React.ComponentProps<"div">) { 93 106 return ( 94 - <div className={cn("text-muted-foreground text-sm", className)} {...props}> 107 + <div className={cn("flex flex-wrap gap-0.5", className)} {...props}> 95 108 {children} 96 109 </div> 97 110 ); 98 111 } 99 112 113 + export function StatusEventAffectedBadge({ 114 + className, 115 + children, 116 + ...props 117 + }: React.ComponentProps<"div">) { 118 + return ( 119 + <Badge 120 + variant="secondary" 121 + className={cn("text-[10px]", className)} 122 + {...props} 123 + > 124 + {children} 125 + </Badge> 126 + ); 127 + } 128 + 129 + export function StatusEventDate({ 130 + className, 131 + date, 132 + ...props 133 + }: React.ComponentProps<"div"> & { 134 + date: Date; 135 + }) { 136 + const isFuture = date > new Date(); 137 + return ( 138 + <div className={cn("flex gap-2 lg:flex-col", className)} {...props}> 139 + <div className="font-medium text-foreground"> 140 + {formatDate(date, { month: "short" })} 141 + </div>{" "} 142 + {isFuture ? ( 143 + <Badge 144 + variant="secondary" 145 + className="bg-info text-[10px] text-background dark:text-foreground" 146 + > 147 + Upcoming 148 + </Badge> 149 + ) : null} 150 + </div> 151 + ); 152 + } 153 + 100 154 export function StatusEventAside({ 101 155 className, 102 156 children, 103 157 ...props 104 158 }: React.ComponentProps<"div">) { 105 159 return ( 106 - <div className="lg:-left-32 lg:absolute lg:top-0 lg:h-full"> 107 - <div 108 - className={cn( 109 - "flex flex-col gap-1 lg:sticky lg:top-0 lg:left-0", 110 - className, 111 - )} 112 - {...props} 113 - > 160 + <div className="lg:-left-32 border border-transparent lg:absolute lg:top-0 lg:h-full"> 161 + <div className={cn("lg:sticky lg:top-0 lg:left-0", className)} {...props}> 114 162 {children} 115 163 </div> 116 164 </div> ··· 203 251 <div className={cn(isLast ? "mb-0" : "mb-2")}> 204 252 <StatusEventTimelineTitle> 205 253 <span>{status[report.status]}</span>{" "} 206 - {/* underline decoration-dashed underline-offset-2 decoration-muted-foreground/30 */} 207 254 <span className="font-mono text-muted-foreground text-xs"> 208 255 <TimestampHoverCard date={new Date(report.date)} asChild> 209 256 <span>{formatDateTime(report.date)}</span> ··· 311 358 }: React.ComponentProps<"div">) { 312 359 return ( 313 360 <div 314 - className={cn( 315 - "py-1.5 font-mono text-foreground/90 text-sm/relaxed", 316 - className, 317 - )} 361 + className={cn("py-1.5 font-mono text-muted-foreground", className)} 318 362 {...props} 319 363 > 320 364 {children}
+13 -31
apps/status-page/src/components/status-page/status-feed.tsx
··· 1 1 "use client"; 2 - 3 - import { Badge } from "@/components/ui/badge"; 4 2 import { usePathnamePrefix } from "@/hooks/use-pathname-prefix"; 5 - import { formatDate } from "@/lib/formatter"; 6 - import { cn } from "@/lib/utils"; 7 3 import Link from "next/link"; 8 4 import { 9 5 StatusBlankContainer, ··· 16 12 import { 17 13 StatusEvent, 18 14 StatusEventAffected, 15 + StatusEventAffectedBadge, 19 16 StatusEventAside, 20 17 StatusEventContent, 18 + StatusEventDate, 19 + StatusEventGroup, 21 20 StatusEventTimelineMaintenance, 22 21 StatusEventTimelineReport, 23 22 StatusEventTitle, ··· 52 51 }; 53 52 54 53 export function StatusFeed({ 55 - className, 56 54 statusReports = [], 57 55 maintenances = [], 58 56 ...props ··· 102 100 } 103 101 104 102 return ( 105 - <div className={cn("flex flex-col gap-4", className)} {...props}> 103 + <StatusEventGroup {...props}> 106 104 {unifiedEvents.map((event) => { 107 105 if (event.type === "report") { 108 106 const report = event.data as StatusReport; 109 107 return ( 110 108 <StatusEvent key={`report-${event.id}`}> 111 109 <StatusEventAside> 112 - <span className="font-medium text-foreground/80"> 113 - {formatDate(event.startDate, { month: "short" })} 114 - </span> 110 + <StatusEventDate date={event.startDate} /> 115 111 </StatusEventAside> 116 112 <Link 117 113 href={`${prefix ? `/${prefix}` : ""}/events/report/${ ··· 122 118 <StatusEventContent> 123 119 <StatusEventTitle>{report.title}</StatusEventTitle> 124 120 {report.affected.length > 0 && ( 125 - <StatusEventAffected className="flex flex-wrap gap-1"> 121 + <StatusEventAffected> 126 122 {report.affected.map((affected, index) => ( 127 - <Badge 128 - key={index} 129 - variant="outline" 130 - className="text-[10px]" 131 - > 123 + <StatusEventAffectedBadge key={index}> 132 124 {affected} 133 - </Badge> 125 + </StatusEventAffectedBadge> 134 126 ))} 135 127 </StatusEventAffected> 136 128 )} ··· 143 135 144 136 if (event.type === "maintenance") { 145 137 const maintenance = event.data as Maintenance; 146 - const isFuture = maintenance.from > new Date(); 147 138 return ( 148 139 <StatusEvent key={`maintenance-${event.id}`}> 149 140 <StatusEventAside> 150 - <span className="font-medium text-foreground/80"> 151 - {formatDate(event.startDate, { month: "short" })} 152 - </span> 153 - {isFuture ? ( 154 - <span className="text-info text-sm">Upcoming</span> 155 - ) : null} 141 + <StatusEventDate date={event.startDate} /> 156 142 </StatusEventAside> 157 143 <Link 158 144 href={`${prefix ? `/${prefix}` : ""}/events/maintenance/${ ··· 163 149 <StatusEventContent> 164 150 <StatusEventTitle>{maintenance.title}</StatusEventTitle> 165 151 {maintenance.affected.length > 0 && ( 166 - <StatusEventAffected className="flex flex-wrap gap-1"> 152 + <StatusEventAffected> 167 153 {maintenance.affected.map((affected, index) => ( 168 - <Badge 169 - key={index} 170 - variant="outline" 171 - className="text-[10px]" 172 - > 154 + <StatusEventAffectedBadge key={index}> 173 155 {affected} 174 - </Badge> 156 + </StatusEventAffectedBadge> 175 157 ))} 176 158 </StatusEventAffected> 177 159 )} ··· 196 178 > 197 179 View events history 198 180 </StatusBlankLink> 199 - </div> 181 + </StatusEventGroup> 200 182 ); 201 183 }
apps/status-page/src/components/theme-provider.tsx apps/status-page/src/components/themes/theme-provider.tsx
+1 -1
apps/status-page/src/components/theme-toggle.tsx apps/status-page/src/components/themes/theme-select.tsx
··· 15 15 import { useState } from "react"; 16 16 import { useEffect } from "react"; 17 17 18 - export function ThemeToggle({ 18 + export function ThemeSelect({ 19 19 className, 20 20 ...props 21 21 }: React.ComponentProps<typeof SelectTrigger>) {
+59
apps/status-page/src/components/themes/theme-dropdown.tsx
··· 1 + "use client"; 2 + 3 + import { useTheme } from "next-themes"; 4 + import type * as React from "react"; 5 + 6 + import { Button } from "@/components/ui/button"; 7 + import { 8 + DropdownMenu, 9 + DropdownMenuContent, 10 + DropdownMenuItem, 11 + DropdownMenuTrigger, 12 + } from "@/components/ui/dropdown-menu"; 13 + import { cn } from "@/lib/utils"; 14 + import { Laptop, Moon, Sun } from "lucide-react"; 15 + import { useState } from "react"; 16 + import { useEffect } from "react"; 17 + import { Skeleton } from "../ui/skeleton"; 18 + 19 + function getThemeIcon(theme?: string | null) { 20 + if (theme === "light") return <Sun className="h-4 w-4" />; 21 + if (theme === "dark") return <Moon className="h-4 w-4" />; 22 + if (theme === "system") return <Laptop className="h-4 w-4" />; 23 + return null; 24 + } 25 + 26 + export function ThemeDropdown({ 27 + className, 28 + ...props 29 + }: React.ComponentProps<typeof DropdownMenuTrigger>) { 30 + const { setTheme, theme } = useTheme(); 31 + const [mounted, setMounted] = useState(false); 32 + 33 + useEffect(() => { 34 + setMounted(true); 35 + }, []); 36 + 37 + if (!mounted) { 38 + return <Skeleton className={cn("size-9", className)} />; 39 + } 40 + 41 + return ( 42 + <DropdownMenu> 43 + <DropdownMenuTrigger className={cn(className)} asChild {...props}> 44 + <Button variant="outline" size="icon"> 45 + {getThemeIcon(theme ?? "system")} 46 + <span className="sr-only">{theme ?? "system"}</span> 47 + </Button> 48 + </DropdownMenuTrigger> 49 + <DropdownMenuContent align="end"> 50 + {["light", "dark", "system"].map((theme) => ( 51 + <DropdownMenuItem key={theme} onClick={() => setTheme(theme)}> 52 + {getThemeIcon(theme)} 53 + <span className="capitalize">{theme}</span> 54 + </DropdownMenuItem> 55 + ))} 56 + </DropdownMenuContent> 57 + </DropdownMenu> 58 + ); 59 + }
+16 -2
packages/api/src/router/statusPage.ts
··· 50 50 with: { 51 51 workspace: true, 52 52 statusReports: { 53 - orderBy: (reports, { desc }) => desc(reports.createdAt), 53 + // TODO: we need to order the based on statusReportUpdates instead 54 + // orderBy: (reports, { desc }) => desc(reports.createdAt), 54 55 with: { 55 56 statusReportUpdates: { 56 57 orderBy: (reports, { desc }) => desc(reports.date), ··· 150 151 ..._page, 151 152 monitors, 152 153 incidents: monitors.flatMap((m) => m.incidents) ?? [], 153 - statusReports: _page.statusReports ?? [], 154 + statusReports: 155 + // NOTE: we need to sort the status reports by the first update date 156 + _page.statusReports.sort((a, b) => { 157 + if (a.statusReportUpdates.length === 0) return -1; 158 + if (b.statusReportUpdates.length === 0) return -1; 159 + return ( 160 + b.statusReportUpdates[ 161 + b.statusReportUpdates.length - 1 162 + ].date.getTime() - 163 + a.statusReportUpdates[ 164 + a.statusReportUpdates.length - 1 165 + ].date.getTime() 166 + ); 167 + }) ?? [], 154 168 maintenances: _page.maintenances ?? [], 155 169 workspacePlan: _page.workspace.plan, 156 170 status,