"use client"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; import { formatDistanceToNowStrict } from "date-fns"; import { HoverCardTimestamp } from "@/components/common/hover-card-timestamp"; import { UserAvatar } from "@/components/common/user-avatar"; import { useHydrated } from "@/hooks/use-hydrated"; /** * Two kinds of item share the list: a compact row (`TimelineIndicator` + * `TimelineHeader`) for state changes, and a `TimelineCard` for authored * content. The indicator and the card's avatar sit on the same column. */ export function Timeline({ children, className, ...props }: React.ComponentProps<"ol">) { return (
    {children}
); } // The rail runs up through the gap to the item above, on the column shared // by row icons and card avatars; the composer at the head is part of it. export function TimelineItem({ children, className, ...props }: React.ComponentProps<"li">) { return (
  • {children}
  • ); } // ml-4 matches the card's horizontal padding so the icon lines up with the // card avatar. export function TimelineIndicator({ children, className, ...props }: React.ComponentProps<"span">) { return ( ); } export function TimelineCard({ children, className, ...props }: React.ComponentProps<"div">) { return (
    {children}
    ); } // One sentence, muted throughout in a compact row; inside a card it doubles // as the card header and `TimelineActor`/`TimelineHighlight` lift to the // foreground. export function TimelineHeader({ children, className, ...props }: React.ComponentProps<"div">) { return (
    [data-slot=timeline-actions]]:pr-16", // compact rows separate the time with a middle dot, once it has rendered "[li>&>time:not(:empty)]:before:mr-1.5 [li>&>time:not(:empty)]:before:content-['ยท']", className, )} {...props} > {children}
    ); } export function TimelineHighlight({ children, className, ...props }: React.ComponentProps<"span">) { return ( {children} ); } type Actor = { name: string | null; email: string | null; photoUrl?: string | null; }; /** Where the actor acted from, when not the dashboard itself. */ export type ActorSource = { label: string; icon: React.ComponentType>; /** Deep link to the original, e.g. a Slack permalink. */ href?: string; }; // Renders nothing for a missing actor; the caller phrases the sentence // without one. Email on hover when the name is shown instead. A source // overlays the avatar's corner so the sentence keeps its own verb. export function TimelineActor({ actor, avatar = false, source, className, ...props }: Omit, "children"> & { actor: Actor | null; avatar?: boolean; source?: ActorSource; }) { if (!actor) return null; const label = actor.name ?? actor.email ?? "Unknown"; const name = {label}; return ( {avatar ? ( {source ? : null} ) : null} {!actor.email || actor.email === label ? ( name ) : ( {name} {actor.email} )} ); } function TimelineActorSource({ source }: { source: ActorSource }) { const className = cn( "text-foreground absolute -right-1 -bottom-1 flex size-3 items-center justify-center rounded-full ring-1", // fill and ring match the surface behind so the badge reads as a cut-out "bg-background ring-background in-data-[slot=timeline-card]:bg-card in-data-[slot=timeline-card]:ring-card", "focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px]", ); // brand icons carry an svg ; no pointer events keeps the native tooltip away const icon = <source.icon className="pointer-events-none size-2" />; return ( <Tooltip> <TooltipTrigger asChild> {source.href ? ( <a href={source.href} target="_blank" rel="noreferrer" aria-label={`Open in ${source.label}`} className={cn(className, "hover:bg-muted")} > {icon} </a> ) : ( <span tabIndex={0} aria-label={`via ${source.label}`} className={className} > {icon} </span> )} </TooltipTrigger> <TooltipContent side="top"> {source.href ? `Open in ${source.label}` : `via ${source.label}`} </TooltipContent> </Tooltip> ); } export function TimelineTime({ date, children, className, ...props }: Omit<React.ComponentProps<"time">, "dateTime"> & { date: Date }) { // local-time text differs between server and browser const hydrated = useHydrated(); return ( <HoverCardTimestamp date={date} side="top" align="start"> <time data-slot="timeline-time" dateTime={date.toISOString()} // focusable so the hover card opens from the keyboard tabIndex={0} className={cn( "text-muted-foreground focus-visible:ring-ring/50 shrink-0 rounded-sm outline-none focus-visible:ring-[3px]", className, )} {...props} > {hydrated ? (children ?? formatDistanceToNowStrict(date, { addSuffix: true })) : null} </time> </HoverCardTimestamp> ); } // Pinned to the card corner so a wrapping sentence never pushes it down. export function TimelineActions({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="timeline-actions" className={cn( "text-muted-foreground absolute top-2 right-3 flex items-center gap-1 font-mono text-xs", className, )} {...props} > {children} </div> ); } export function TimelineBody({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="timeline-body" className={cn( "prose prose-sm dark:prose-invert max-w-none px-4 pb-4 text-sm", className, )} {...props} > {children} </div> ); } export function TimelineFooter({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="timeline-footer" className={cn( "bg-muted/30 text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1 border-t px-4 py-2.5 font-mono text-xs", className, )} {...props} > {children} </div> ); }