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.
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.jsonSource
"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.