Activity Log

Paginated activity feed for audit history and analytics changes.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/activity-log.json
bash

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

2 stories available:

Code

"use client";

import { forwardRef, useMemo, useState } from "react";

import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react";

import { cn } from "../../lib/utils";
import { Avatar, AvatarFallback } from "../avatar";
import { Badge } from "../badge";
import { Button } from "../button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "../card";
import { ScrollArea } from "../scroll-area";
import { Separator } from "../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 = forwardRef<HTMLDivElement, ActivityLogProps>(
  (
    {
      className,
      defaultPage = 1,
      description,
      emptyMessage = "No activity recorded yet.",
      items,
      onPageChange,
      page,
      pageSize = 5,
      title = "Activity log",
      ...props
    },
    ref,
  ) => {
    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 };
typescript

Dependencies

  • @vllnt/ui@^0.2.1