Activity Log

Flux d'activite pagine pour l'historique d'audit et les changements analytiques. Installez Activity Log depuis le registre VLLNT UI avec la CLI shadcn.

Signaler un bug

Flux d'activite pagine pour l'historique d'audit et les changements analytiques. 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 Activity Log 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/activity-log.json

Source

"use client"; import { useMemo, useState } from "react"; import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react"; import { cn } from "../../lib/utils"; import { Avatar, AvatarFallback } from "../avatar/avatar"; import { Badge } from "../badge/badge"; import { Button } from "../button/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../card/card"; import { ScrollArea } from "../scroll-area/scroll-area"; import { Separator } from "../separator/separator"; export type ActivityLogTone = "danger" | "default" | "success" | "warning"; export type ActivityLogItem = { action: string; actor: string; description?: string; id: string; scope?: string; target?: string; timestamp: string; tone?: ActivityLogTone; }; export type ActivityLogProps = React.ComponentPropsWithoutRef<typeof Card> & { defaultPage?: number; description?: string; emptyMessage?: string; items: ActivityLogItem[]; onPageChange?: (page: number) => void; page?: number; pageSize?: number; title?: string; }; type ActivityToneConfig = { badgeClassName: string; markerClassName: string; }; type ActivityRowProps = { item: ActivityLogItem; }; type PaginationControlsProps = { currentPage: number; onPageChange: (page: number) => void; pageNumbers: number[]; totalPages: number; }; const toneConfig: Record<ActivityLogTone, ActivityToneConfig> = { danger: { badgeClassName: "border-destructive/20 bg-destructive/10 text-destructive dark:text-destructive", markerClassName: "bg-destructive", }, default: { badgeClassName: "border-border bg-muted text-muted-foreground", markerClassName: "bg-primary", }, success: { badgeClassName: "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", markerClassName: "bg-emerald-500", }, warning: { badgeClassName: "border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300", markerClassName: "bg-amber-500", }, }; function getInitials(name: string): string { return name .split(" ") .map((segment) => segment[0]) .join("") .slice(0, 2) .toUpperCase(); } function buildPageNumbers(currentPage: number, totalPages: number): number[] { if (totalPages <= 1) return [1]; const start = Math.max(1, currentPage - 1); const end = Math.min(totalPages, start + 2); const normalizedStart = Math.max(1, end - 2); return Array.from( { length: end - normalizedStart + 1 }, (_, index) => normalizedStart + index, ); } function ActivityLogHeader({ currentPage, description, title, totalPages, }: { currentPage: number; description?: string; title: string; totalPages: number; }) { return ( <CardHeader> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div> <CardTitle>{title}</CardTitle> {description ? ( <CardDescription>{description}</CardDescription> ) : null} </div> <Badge className="w-fit" variant="outline"> Page {currentPage} of {totalPages} </Badge> </div> </CardHeader> ); } function PaginationControls({ currentPage, onPageChange, pageNumbers, totalPages, }: PaginationControlsProps) { return ( <div className="flex flex-wrap items-center gap-2"> <Button disabled={currentPage === 1} onClick={() => { onPageChange(currentPage - 1); }} size="sm" variant="outline" > <ChevronLeft className="size-4" /> Previous </Button> {pageNumbers.map((pageNumber) => ( <Button aria-label={`Go to page ${pageNumber}`} key={pageNumber} onClick={() => { onPageChange(pageNumber); }} size="sm" variant={pageNumber === currentPage ? "default" : "outline"} > {pageNumber} </Button> ))} <Button disabled={currentPage === totalPages} onClick={() => { onPageChange(currentPage + 1); }} size="sm" variant="outline" > Next <ChevronRight className="size-4" /> </Button> </div> ); } function ActivityRow({ item }: ActivityRowProps) { const palette = toneConfig[item.tone ?? "default"]; return ( <li className="relative pl-12"> <span aria-hidden="true" className="absolute bottom-[-1.5rem] left-[18px] top-11 w-px bg-border last:hidden" /> <span aria-hidden="true" className={cn( "absolute left-4 top-3 size-3 rounded-full ring-4 ring-background", palette.markerClassName, )} /> <div className="rounded-lg border bg-background/70 p-4"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="flex min-w-0 items-start gap-3"> <Avatar className="size-9 border bg-muted"> <AvatarFallback>{getInitials(item.actor)}</AvatarFallback> </Avatar> <div className="min-w-0 space-y-1"> <div className="flex flex-wrap items-center gap-2"> <span className="font-medium text-foreground"> {item.actor} </span> <ArrowRight className="size-3.5 text-muted-foreground" /> <span className="text-sm text-muted-foreground"> {item.action} </span> {item.target ? ( <span className="truncate text-sm font-medium text-foreground"> {item.target} </span> ) : null} </div> {item.description ? ( <p className="text-sm text-muted-foreground"> {item.description} </p> ) : null} </div> </div> <div className="flex shrink-0 flex-wrap items-center gap-2 sm:justify-end"> {item.scope ? ( <Badge className={palette.badgeClassName} variant="outline"> {item.scope} </Badge> ) : null} <span className="text-xs text-muted-foreground"> {item.timestamp} </span> </div> </div> </div> </li> ); } function ActivityLogBody({ currentPage, emptyMessage, items, onPageChange, pageNumbers, pageSize, totalPages, }: { currentPage: number; emptyMessage: string; items: ActivityLogItem[]; onPageChange: (page: number) => void; pageNumbers: number[]; pageSize: number; totalPages: number; }) { const visibleItems = useMemo(() => { const start = (currentPage - 1) * pageSize; return items.slice(start, start + pageSize); }, [currentPage, items, pageSize]); if (items.length === 0) { return ( <div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground"> {emptyMessage} </div> ); } return ( <> <ScrollArea className="max-h-[26rem] pr-4"> <ol className="space-y-4 pb-2"> {visibleItems.map((item) => ( <ActivityRow item={item} key={item.id} /> ))} </ol> </ScrollArea> <Separator /> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <p className="text-sm text-muted-foreground"> Showing {(currentPage - 1) * pageSize + 1} {" - "} {(currentPage - 1) * pageSize + visibleItems.length} of {items.length} </p> <PaginationControls currentPage={currentPage} onPageChange={onPageChange} pageNumbers={pageNumbers} totalPages={totalPages} /> </div> </> ); } const ActivityLog = ({ className, defaultPage = 1, description, emptyMessage = "No activity recorded yet.", items, onPageChange, page, pageSize = 5, ref, title = "Activity log", ...props }: ActivityLogProps & { ref?: React.Ref<HTMLDivElement> }) => { const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); const [uncontrolledPage, setUncontrolledPage] = useState(defaultPage); const currentPage = Math.min( Math.max(page ?? uncontrolledPage, 1), totalPages, ); const pageNumbers = useMemo( () => buildPageNumbers(currentPage, totalPages), [currentPage, totalPages], ); function handlePageChange(nextPage: number) { if (page === undefined) { setUncontrolledPage(nextPage); } onPageChange?.(nextPage); } return ( <Card className={cn("w-full", className)} ref={ref} {...props}> <ActivityLogHeader currentPage={currentPage} description={description} title={title} totalPages={totalPages} /> <CardContent className="space-y-4"> <ActivityLogBody currentPage={currentPage} emptyMessage={emptyMessage} items={items} onPageChange={handlePageChange} pageNumbers={pageNumbers} pageSize={pageSize} totalPages={totalPages} /> </CardContent> </Card> ); }; ActivityLog.displayName = "ActivityLog"; export { ActivityLog };

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