Progress Card

Card displaying progress metrics and status.

A card component with progress tracking.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.com/r/progress-card.json
bash

Code

"use client";

import { memo, useEffect, useState } from "react";

import type { ReactNode } from "react";

import { useMounted } from "../../lib/use-mounted";
import { Badge } from "../badge";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "../card";

export type ContentCardProgress = {
  completedCount: number;
  totalSections: number;
};

export type ContentCardProps = {
  /** Badge label for difficulty/category */
  badgeLabel: string;
  /** Badge variant */
  badgeVariant?: "default" | "destructive" | "outline" | "secondary";
  /** Card description */
  description: string;
  /** Function to get progress from storage */
  getProgress?: () => ContentCardProgress | null;
  /** Href for the card link */
  href: string;
  /** Link component to use (e.g., Next.js Link) */
  linkComponent?: React.ComponentType<{
    children: ReactNode;
    className?: string;
    href: string;
  }>;
  /** Metadata items (e.g., "30 min", "10 sections") */
  metadata?: string[];
  /** Progress completed label (e.g., "completed") */
  progressLabel?: string;
  /** Tags to display */
  tags?: string[];
  /** Card title */
  title: string;
};

function DefaultLink({
  children,
  className,
  href,
}: {
  children: ReactNode;
  className?: string;
  href: string;
}): React.ReactNode {
  return (
    <a className={className} href={href}>
      {children}
    </a>
  );
}

function ContentCardImpl({
  badgeLabel,
  badgeVariant = "default",
  description,
  getProgress,
  href,
  linkComponent: LinkComponent = DefaultLink,
  metadata = [],
  progressLabel = "completed",
  tags = [],
  title,
}: ContentCardProps): React.ReactNode {
  const [progress, setProgress] = useState<ContentCardProgress | null>(null);
  const isHydrated = useMounted();

  // Load progress after hydration
  useEffect(() => {
    if (getProgress) {
      const result = getProgress();
      requestAnimationFrame(() => {
        setProgress(result);
      });
    }
  }, [getProgress]);

  const showProgress = isHydrated && progress && progress.completedCount > 0;

  return (
    <LinkComponent className="block h-full" href={href}>
      <Card className="h-full flex flex-col hover:shadow-lg transition-shadow cursor-pointer">
        <CardHeader>
          {/* Badge and progress */}
          <div className="flex items-center gap-2 mb-2">
            <Badge className="text-xs capitalize" variant={badgeVariant}>
              {badgeLabel}
            </Badge>
            {showProgress ? (
              <span className="text-xs text-muted-foreground">
                {progress.completedCount}/{progress.totalSections}{" "}
                {progressLabel}
              </span>
            ) : null}
          </div>

          <CardTitle className="line-clamp-2 text-lg">{title}</CardTitle>
          <CardDescription className="line-clamp-3">
            {description}
          </CardDescription>
        </CardHeader>

        <CardContent className="mt-auto space-y-2">
          {/* Metadata */}
          {metadata.length > 0 ? (
            <div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
              {metadata.map((item, index) => (
                <span key={index}>
                  {index > 0 ? <span className="mr-2">•</span> : null}
                  {item}
                </span>
              ))}
            </div>
          ) : null}

          {/* Tags */}
          {tags.length > 0 ? (
            <div className="flex flex-wrap gap-1">
              {tags.map((tag) => (
                <Badge className="text-xs" key={tag} variant="outline">
                  {tag}
                </Badge>
              ))}
            </div>
          ) : null}
        </CardContent>
      </Card>
    </LinkComponent>
  );
}

export const ContentCard = memo(ContentCardImpl);
ContentCard.displayName = "ContentCard";
typescript