Live Feed

Flux d'activite continu pour faire remonter les incidents, les deploiements et les signaux operationnels en temps reel. Installez Live Feed depuis le registre VLLNT UI avec la CLI shadcn.

Signaler un bug

Flux d'activite continu pour faire remonter les incidents, les deploiements et les signaux operationnels en temps reel. Fait partie de la famille data dans VLLNT UI, il est publie comme entree de registre lisible par machine — copiez la source directement dans votre application avec la CLI shadcn et possedez-la, sans dependance d'execution a une bibliotheque de composants.

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Installation

Ajoutez Live Feed a votre projet avec la CLI shadcn. La source arrive dans votre code, prete a etre adaptee :

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/live-feed.json

Source

"use client"; import * as React from "react"; import { normalizeDate } from "../../lib/format"; import { useLiveDate } from "../../lib/use-live-date"; import { cn } from "../../lib/utils"; import { Badge } from "../badge/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../card/card"; import { SeverityBadge, type SeverityBadgeLevel, } from "../severity-badge/severity-badge"; export type LiveFeedEvent = { id: string; message?: string; severity: SeverityBadgeLevel; source?: string; timestamp: Date | number | string; title: string; }; export type LiveFeedProps = React.ComponentPropsWithoutRef<"div"> & { description?: string; emptyLabel?: string; events: LiveFeedEvent[]; maxItems?: number; now?: Date | number | string; tickMs?: number; title?: string; }; const SECOND_MS = 1000; const MINUTE_MS = 60 * SECOND_MS; const HOUR_MS = 60 * MINUTE_MS; const DAY_MS = 24 * HOUR_MS; function formatRelative(eventDate: Date, now: Date): string { const deltaMs = now.getTime() - eventDate.getTime(); if (deltaMs < 5 * SECOND_MS) { return "just now"; } if (deltaMs < MINUTE_MS) { return `${Math.floor(deltaMs / SECOND_MS)}s ago`; } if (deltaMs < HOUR_MS) { return `${Math.floor(deltaMs / MINUTE_MS)}m ago`; } if (deltaMs < DAY_MS) { return `${Math.floor(deltaMs / HOUR_MS)}h ago`; } if (deltaMs < 7 * DAY_MS) { return `${Math.floor(deltaMs / DAY_MS)}d ago`; } return SHORT_DATE_FORMATTER.format(eventDate); } const SHORT_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "short", }); const ABSOLUTE_FORMATTER = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", month: "short", second: "2-digit", }); function formatAbsolute(eventDate: Date): string { return ABSOLUTE_FORMATTER.format(eventDate); } function sortEventsDesc(events: LiveFeedEvent[]): LiveFeedEvent[] { return [...events].sort( (a, b) => normalizeDate(b.timestamp).getTime() - normalizeDate(a.timestamp).getTime(), ); } function LiveFeedRow({ event, isLatest, now, }: { event: LiveFeedEvent; isLatest: boolean; now: Date; }) { const eventDate = normalizeDate(event.timestamp); return ( <li className="flex gap-3 border-b border-border/60 py-3 last:border-b-0"> <div className="pt-1"> <SeverityBadge level={event.severity} pulse={isLatest ? event.severity === "critical" : undefined} tone="soft" /> </div> <div className="min-w-0 flex-1"> <div className="flex items-baseline justify-between gap-2"> <p className="truncate text-sm font-medium">{event.title}</p> <time className="whitespace-nowrap text-xs text-muted-foreground" dateTime={eventDate.toISOString()} title={formatAbsolute(eventDate)} > {formatRelative(eventDate, now)} </time> </div> {event.message ? ( <p className="mt-0.5 text-sm text-muted-foreground"> {event.message} </p> ) : null} {event.source ? ( <p className="mt-1 text-xs uppercase tracking-[0.14em] text-muted-foreground"> {event.source} </p> ) : null} </div> </li> ); } export const LiveFeed = ({ className, description, emptyLabel = "No events yet", events, maxItems = 50, now, ref, tickMs = 30_000, title = "Live feed", ...props }: LiveFeedProps & { ref?: React.Ref<HTMLDivElement> }) => { const liveNow = useLiveDate(now, tickMs); const visibleEvents = React.useMemo( () => sortEventsDesc(events).slice(0, maxItems), [events, maxItems], ); return ( <Card className={cn("shadow-sm", className)} ref={ref} {...props}> <CardHeader className="flex flex-row items-start justify-between gap-3 gap-y-0 pb-3"> <div className="space-y-1"> <CardTitle className="text-base">{title}</CardTitle> {description ? ( <CardDescription>{description}</CardDescription> ) : null} </div> <Badge variant="outline"> <span aria-hidden="true" className="relative mr-1.5 inline-flex size-2" > <span className="absolute inline-flex size-2 animate-ping rounded-full bg-emerald-500 opacity-70" /> <span className="relative inline-flex size-2 rounded-full bg-emerald-500" /> </span>{" "} Live </Badge> </CardHeader> <CardContent className="pt-0"> {visibleEvents.length === 0 ? ( <div className="py-8 text-center text-sm text-muted-foreground"> {emptyLabel} </div> ) : ( <ul className="max-h-[360px] divide-y divide-border/60 overflow-y-auto"> {visibleEvents.map((event, index) => ( <LiveFeedRow event={event} isLatest={index === 0} key={event.id} now={liveNow} /> ))} </ul> )} </CardContent> </Card> ); }; LiveFeed.displayName = "LiveFeed";

Stories

Explorez chaque variante et etat dans le Storybook interactif :

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Dependances

  • @vllnt/ui@^0.3.0