UIPackage
Menu

Framework

Change language

Boilerplate repo

Video Playlist Sidebar

blockmedia

YouTube and Vimeo style video playlist drawer with active playing indicator, durations, auto-play next switch, loop/shuffle controls, and active lesson summary card.

Also available for Vue ->

Installation

$npx shadcn@latest add https://uipkge.dev/r/react/video-playlist-sidebar.json
Named registry:npx shadcn@latest add @uipkge-react/video-playlist-sidebarInstalls to:components/blocks/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

VideoResource
interface VideoResource {
  id: string
  name: string
  size: string
  type: 'code' | 'figma' | 'pdf'
}
PlaylistItem
interface PlaylistItem {
  id: string
  index: number
  title: string
  instructor: string
  instructorRole: string
  instructorAvatar: string
  duration: string
  durationSec: number
  category: string
  summary: string
  isCompleted: boolean
  thumbnailGradient: string
  resources: VideoResource[]
}

Files installed (1)

  • components/blocks/VideoPlaylistSidebar.tsx25.2 kB
    'use client'
    
    import * as React from 'react'
    import {
      Check,
      CheckCircle2,
      Clock,
      Download,
      FileCode2,
      ListVideo,
      Play,
      PlayCircle,
      Repeat,
      Repeat1,
      Search,
      Shuffle,
      SkipForward,
    } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import { Badge } from '@/components/ui/badge'
    import { Button } from '@/components/ui/button'
    import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
    import { Progress } from '@/components/ui/progress'
    import { Switch } from '@/components/ui/switch'
    
    export interface VideoResource {
      id: string
      name: string
      size: string
      type: 'code' | 'figma' | 'pdf'
    }
    
    export interface PlaylistItem {
      id: string
      index: number
      title: string
      instructor: string
      instructorRole: string
      instructorAvatar: string
      duration: string
      durationSec: number
      category: string
      summary: string
      isCompleted: boolean
      thumbnailGradient: string
      resources: VideoResource[]
    }
    
    const initialPlaylist: PlaylistItem[] = [
      {
        id: 'vid-01',
        index: 1,
        title: '01. Introduction to Component Registries',
        instructor: 'Marcus Vance',
        instructorRole: 'Principal Design Engineer',
        instructorAvatar: 'MV',
        duration: '14:20',
        durationSec: 860,
        category: 'Architecture',
        summary: 'Understanding unbundled component distribution, registry manifests, and own-your-code paradigms.',
        isCompleted: true,
        thumbnailGradient: 'from-slate-800 to-zinc-950',
        resources: [
          { id: 'res-1a', name: 'registry-schema-spec.pdf', size: '1.2 MB', type: 'pdf' },
          { id: 'res-1b', name: 'starter-registry.json', size: '48 KB', type: 'code' },
        ],
      },
      {
        id: 'vid-02',
        index: 2,
        title: '02. Headless Reka UI Primitives',
        instructor: 'Elena Rostova',
        instructorRole: 'Staff Frontend Engineer',
        instructorAvatar: 'ER',
        duration: '18:45',
        durationSec: 1125,
        category: 'Accessibility',
        summary: 'Composition with asChild, polymorphic slot mechanics, and ARIA state tree management.',
        isCompleted: true,
        thumbnailGradient: 'from-zinc-800 to-slate-950',
        resources: [
          { id: 'res-2a', name: 'reka-composition-cheatsheet.pdf', size: '2.4 MB', type: 'pdf' },
          { id: 'res-2b', name: 'accessible-dialog.vue', size: '14 KB', type: 'code' },
        ],
      },
      {
        id: 'vid-03',
        index: 3,
        title: '03. Building with OKLCH Tokens',
        instructor: 'Sarah Connor',
        instructorRole: 'Design Systems Architect',
        instructorAvatar: 'SC',
        duration: '22:10',
        durationSec: 1330,
        category: 'Color Systems',
        summary: 'Perceptually uniform color spaces, wide-gamut Display P3 gamut mapping, and dark mode contrast.',
        isCompleted: false,
        thumbnailGradient: 'from-slate-850 to-zinc-950',
        resources: [
          { id: 'res-3a', name: 'tokens.config.css', size: '14.2 KB', type: 'code' },
          { id: 'res-3b', name: 'oklch-palette-matrix.fig', size: '4.8 MB', type: 'figma' },
          { id: 'res-3c', name: 'apca-contrast-guide.pdf', size: '1.1 MB', type: 'pdf' },
        ],
      },
      {
        id: 'vid-04',
        index: 4,
        title: '04. Micro-Interactions & Spring Physics',
        instructor: 'Alex Chen',
        instructorRole: 'Motion Design Lead',
        instructorAvatar: 'AC',
        duration: '19:35',
        durationSec: 1175,
        category: 'Motion & Physics',
        summary: 'Crafting tactile UI states, gesture dampening, spring curves, and 120fps hardware acceleration.',
        isCompleted: false,
        thumbnailGradient: 'from-zinc-800 to-zinc-950',
        resources: [
          { id: 'res-4a', name: 'spring-curves-preset.ts', size: '8.4 KB', type: 'code' },
          { id: 'res-4b', name: 'gesture-lab.fig', size: '3.6 MB', type: 'figma' },
        ],
      },
      {
        id: 'vid-05',
        index: 5,
        title: '05. Multi-Framework Parity Testing',
        instructor: 'Marcus Vance',
        instructorRole: 'Principal Design Engineer',
        instructorAvatar: 'MV',
        duration: '16:50',
        durationSec: 1010,
        category: 'Testing & Quality',
        summary: 'Automating visual regression tests and unit tests across dual Vue 3 and React 19 packages.',
        isCompleted: false,
        thumbnailGradient: 'from-slate-800 to-slate-950',
        resources: [
          { id: 'res-5a', name: 'parity-test-matrix.ts', size: '22.0 KB', type: 'code' },
          { id: 'res-5b', name: 'playwright.config.ts', size: '6.5 KB', type: 'code' },
        ],
      },
      {
        id: 'vid-06',
        index: 6,
        title: '06. CI/CD Deployment to Cloudflare',
        instructor: 'David Kim',
        instructorRole: 'DevOps & Cloud Engineer',
        instructorAvatar: 'DK',
        duration: '25:15',
        durationSec: 1515,
        category: 'DevOps & Edge',
        summary: 'Automating multi-framework build chains, registry sync, and edge deployment via Wrangler.',
        isCompleted: false,
        thumbnailGradient: 'from-zinc-850 to-zinc-950',
        resources: [
          { id: 'res-6a', name: 'wrangler.toml', size: '3.4 KB', type: 'code' },
          { id: 'res-6b', name: 'deploy-pipeline.yml', size: '5.1 KB', type: 'code' },
        ],
      },
    ]
    
    export interface VideoPlaylistSidebarProps extends React.HTMLAttributes<HTMLDivElement> {}
    
    export function VideoPlaylistSidebar({ className, ...props }: VideoPlaylistSidebarProps) {
      const [playlist, setPlaylist] = React.useState<PlaylistItem[]>(initialPlaylist)
      const [activeVideoId, setActiveVideoId] = React.useState<string>('vid-03')
      const [autoPlay, setAutoPlay] = React.useState<boolean>(true)
      const [isLooping, setIsLooping] = React.useState<boolean>(false)
      const [isShuffled, setIsShuffled] = React.useState<boolean>(false)
      const [searchQuery, setSearchQuery] = React.useState<string>('')
      const [downloadedAssets, setDownloadedAssets] = React.useState<Record<string, boolean>>({})
    
      const activeVideo = React.useMemo(() => {
        return playlist.find((item) => item.id === activeVideoId) || playlist[0]
      }, [playlist, activeVideoId])
    
      const completedCount = React.useMemo(() => {
        return playlist.filter((item) => item.isCompleted).length
      }, [playlist])
    
      const totalCount = playlist.length
      const progressPercent = totalCount === 0 ? 0 : Math.round((completedCount / totalCount) * 100)
    
      const filteredVideos = React.useMemo(() => {
        const query = searchQuery.trim().toLowerCase()
        if (!query) return playlist
        return playlist.filter(
          (item) =>
            item.title.toLowerCase().includes(query) ||
            item.instructor.toLowerCase().includes(query) ||
            item.category.toLowerCase().includes(query),
        )
      }, [playlist, searchQuery])
    
      const selectVideo = (id: string) => {
        setActiveVideoId(id)
      }
    
      const toggleComplete = (id: string) => {
        setPlaylist((prev) => prev.map((item) => (item.id === id ? { ...item, isCompleted: !item.isCompleted } : item)))
      }
    
      const nextVideo = () => {
        const currentIndex = playlist.findIndex((item) => item.id === activeVideoId)
        if (currentIndex === -1) return
    
        if (isShuffled) {
          const remaining = playlist.filter((item) => item.id !== activeVideoId)
          if (remaining.length > 0) {
            const randomIndex = Math.floor(Math.random() * remaining.length)
            setActiveVideoId(remaining[randomIndex].id)
          }
          return
        }
    
        if (currentIndex < playlist.length - 1) {
          setActiveVideoId(playlist[currentIndex + 1].id)
        } else if (isLooping) {
          setActiveVideoId(playlist[0].id)
        }
      }
    
      const handleDownload = (assetId: string) => {
        setDownloadedAssets((prev) => ({ ...prev, [assetId]: true }))
        setTimeout(() => {
          setDownloadedAssets((prev) => ({ ...prev, [assetId]: false }))
        }, 2000)
      }
    
      return (
        <div
          data-slot="video-playlist-sidebar"
          className={cn('text-foreground mx-auto w-full max-w-2xl space-y-4', className)}
          {...props}
        >
          {/* Main Playlist Control Card */}
          <Card className="border-border bg-card shadow-xs">
            <CardHeader className="space-y-3 pb-3 sm:pb-4">
              {/* Badge row */}
              <div className="flex flex-wrap items-center justify-between gap-2">
                <div className="flex items-center gap-2">
                  <Badge variant="secondary" className="gap-1.5 text-xs font-semibold">
                    <ListVideo className="text-primary size-3.5" />
                    <span>Video Playlist</span>
                  </Badge>
                  <Badge variant="outline" className="text-muted-foreground gap-1 font-mono text-xs">
                    <Clock className="size-3" />
                    <span>1h 56m Total</span>
                  </Badge>
                </div>
                <span className="text-muted-foreground text-xs font-medium">UIPKGE Masterclass Series</span>
              </div>
    
              {/* Title & Subtitle */}
              <div className="space-y-1">
                <CardTitle className="text-foreground text-lg font-bold tracking-tight sm:text-xl">
                  Mastering Full-Stack UI Engineering
                </CardTitle>
                <CardDescription className="text-xs">
                  Curated playlist on headless primitives, OKLCH token systems, fluid motion physics, and registry CI
                  pipelines.
                </CardDescription>
              </div>
    
              {/* Progress bar and counter */}
              <div className="bg-muted/40 border-border/70 space-y-2 rounded-lg border p-3">
                <div className="flex items-center justify-between text-xs">
                  <span className="text-foreground font-semibold">
                    <span className="font-mono tabular-nums">{completedCount}</span> /{' '}
                    <span className="font-mono tabular-nums">{totalCount}</span> Videos Completed
                  </span>
                  <span className="text-primary font-mono font-bold tabular-nums">{progressPercent}%</span>
                </div>
                <Progress value={progressPercent} className="h-2" />
              </div>
    
              {/* Toolbar: Autoplay switch, Loop & Shuffle toggles */}
              <div className="flex flex-wrap items-center justify-between gap-3 pt-1">
                {/* Autoplay next switch */}
                <div className="flex items-center gap-2.5">
                  <Switch id="autoplay-switch-react" checked={autoPlay} onCheckedChange={setAutoPlay} size="sm" />
                  <label
                    htmlFor="autoplay-switch-react"
                    className="text-foreground flex cursor-pointer items-center gap-1.5 text-xs font-medium select-none"
                  >
                    <Play className="text-primary fill-primary size-3.5" />
                    <span>Autoplay Next Video</span>
                  </label>
                </div>
    
                {/* Loop and Shuffle Buttons */}
                <div className="flex items-center gap-1.5">
                  <Button
                    variant="ghost"
                    size="sm"
                    className={cn(
                      'h-7.5 gap-1.5 px-2.5 text-xs font-medium',
                      isLooping
                        ? 'border-primary/30 bg-primary/10 text-primary border font-semibold'
                        : 'text-muted-foreground hover:text-foreground',
                    )}
                    aria-pressed={isLooping}
                    aria-label="Toggle loop playlist"
                    onClick={() => setIsLooping(!isLooping)}
                  >
                    {isLooping ? <Repeat1 className="size-3.5" /> : <Repeat className="size-3.5" />}
                    <span>Loop</span>
                  </Button>
    
                  <Button
                    variant="ghost"
                    size="sm"
                    className={cn(
                      'h-7.5 gap-1.5 px-2.5 text-xs font-medium',
                      isShuffled
                        ? 'border-primary/30 bg-primary/10 text-primary border font-semibold'
                        : 'text-muted-foreground hover:text-foreground',
                    )}
                    aria-pressed={isShuffled}
                    aria-label="Toggle shuffle playlist"
                    onClick={() => setIsShuffled(!isShuffled)}
                  >
                    <Shuffle className="size-3.5" />
                    <span>Shuffle</span>
                  </Button>
                </div>
              </div>
            </CardHeader>
          </Card>
    
          {/* Currently Active Video Card */}
          <Card className="border-primary/40 from-primary/5 via-card to-card relative overflow-hidden bg-gradient-to-br shadow-xs">
            <div className="bg-primary absolute top-0 right-0 left-0 h-0.5" />
            <CardHeader className="pb-3">
              <div className="flex flex-wrap items-center justify-between gap-2">
                <div className="flex items-center gap-2">
                  <Badge variant="default" className="gap-1.5 text-xs font-semibold">
                    <span className="size-1.5 animate-pulse rounded-full bg-white" />
                    <span>Now Playing</span>
                  </Badge>
                  <Badge variant="outline" className="font-mono text-xs font-medium">
                    Lesson {activeVideo.index} of {totalCount}
                  </Badge>
                </div>
                <span className="text-muted-foreground font-mono text-xs font-semibold tabular-nums">
                  {activeVideo.duration}
                </span>
              </div>
    
              <div className="space-y-1 pt-1">
                <CardTitle className="text-foreground text-base font-bold tracking-tight sm:text-lg">
                  {activeVideo.title}
                </CardTitle>
                <CardDescription className="text-xs leading-relaxed">{activeVideo.summary}</CardDescription>
              </div>
    
              {/* Instructor and Metadata */}
              <div className="flex flex-wrap items-center justify-between gap-3 pt-2">
                <div className="flex items-center gap-2">
                  <div className="border-primary/30 bg-primary/15 text-primary flex size-7 items-center justify-center rounded-full border text-xs font-bold">
                    {activeVideo.instructorAvatar}
                  </div>
                  <div>
                    <p className="text-foreground text-xs font-semibold">{activeVideo.instructor}</p>
                    <p className="text-muted-foreground text-xs">
                      {activeVideo.instructorRole} · {activeVideo.category}
                    </p>
                  </div>
                </div>
    
                {/* Video status controls */}
                <div className="flex items-center gap-2">
                  <Button
                    variant={activeVideo.isCompleted ? 'secondary' : 'outline'}
                    size="sm"
                    className="h-7.5 gap-1.5 px-2.5 text-xs font-medium"
                    onClick={() => toggleComplete(activeVideo.id)}
                  >
                    {activeVideo.isCompleted ? (
                      <CheckCircle2 className="text-success size-3.5" />
                    ) : (
                      <Check className="size-3.5" />
                    )}
                    <span>{activeVideo.isCompleted ? 'Completed' : 'Mark as Watched'}</span>
                  </Button>
    
                  <Button size="sm" className="h-7.5 gap-1.5 px-2.5 text-xs font-semibold shadow-xs" onClick={nextVideo}>
                    <span>Next</span>
                    <SkipForward className="size-3.5" />
                  </Button>
                </div>
              </div>
            </CardHeader>
    
            {/* Downloadable Lesson Exercise Files */}
            <CardContent className="border-border/60 bg-muted/20 border-t pt-3 pb-3.5">
              <div className="space-y-2">
                <div className="flex items-center justify-between">
                  <p className="text-foreground flex items-center gap-1.5 text-xs font-bold tracking-tight uppercase">
                    <FileCode2 className="text-primary size-3.5" />
                    <span>Lesson Exercise Files</span>
                  </p>
                  <span className="text-muted-foreground font-mono text-xs">{activeVideo.resources.length} Files</span>
                </div>
    
                <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
                  {activeVideo.resources.map((res) => (
                    <div
                      key={res.id}
                      className="border-border/80 bg-card hover:bg-muted/40 flex items-center justify-between gap-2 rounded-lg border p-2 text-xs transition-colors"
                    >
                      <div className="flex min-w-0 items-center gap-2">
                        <span className="border-primary/20 bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded font-mono text-xs font-bold">
                          {res.type === 'code' ? 'TS' : res.type === 'figma' ? 'FIG' : 'PDF'}
                        </span>
                        <div className="min-w-0">
                          <p className="text-foreground truncate text-xs font-medium">{res.name}</p>
                          <p className="text-muted-foreground font-mono text-xs">{res.size}</p>
                        </div>
                      </div>
    
                      <Button
                        aria-label="Download attachment"
                        variant="outline"
                        size="sm"
                        className="h-6.5 shrink-0 gap-1 px-2 text-xs font-medium"
                        onClick={() => handleDownload(res.id)}
                      >
                        {downloadedAssets[res.id] ? (
                          <Check className="text-success size-3" />
                        ) : (
                          <Download className="size-3" />
                        )}
                        <span>{downloadedAssets[res.id] ? 'Saved' : 'Get'}</span>
                      </Button>
                    </div>
                  ))}
                </div>
              </div>
            </CardContent>
          </Card>
    
          {/* Playlist Queue & List Header */}
          <Card className="border-border bg-card shadow-xs">
            <CardHeader className="space-y-3 pb-3">
              <div className="flex flex-wrap items-center justify-between gap-2">
                <div className="flex items-center gap-2">
                  <CardTitle className="text-foreground text-sm font-bold tracking-tight">Playlist Queue</CardTitle>
                  <Badge variant="secondary" className="font-mono text-xs">
                    {totalCount} Videos
                  </Badge>
                </div>
                <span className="text-muted-foreground text-xs font-medium">Click any lesson to play</span>
              </div>
    
              {/* Search / Filter input */}
              <div className="relative">
                <Search className="text-muted-foreground absolute top-2.5 left-2.5 size-3.5" />
                <input
                  type="text"
                  value={searchQuery}
                  onChange={(e) => setSearchQuery(e.target.value)}
                  placeholder="Filter playlist by title, instructor, or topic..."
                  className="border-input bg-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 h-8 w-full rounded-md border pr-3 pl-8 text-xs shadow-xs outline-none focus-visible:ring-[3px]"
                />
              </div>
            </CardHeader>
    
            {/* Video Items List */}
            <CardContent className="divide-border/60 divide-y p-0">
              {filteredVideos.map((video) => {
                const isActive = activeVideoId === video.id
                return (
                  <div
                    key={video.id}
                    tabIndex={0}
                    role="button"
                    aria-label={`Play ${video.title}`}
                    aria-current={isActive ? 'true' : undefined}
                    className={cn(
                      'group relative flex cursor-pointer items-center justify-between gap-3 p-3 transition-colors duration-150',
                      isActive ? 'border-primary/50 bg-primary/10 ring-primary/20 shadow-xs ring-1' : 'hover:bg-muted/40',
                    )}
                    onClick={() => selectVideo(video.id)}
                    onKeyDown={(e) => {
                      if (e.key === 'Enter' || e.key === ' ') {
                        e.preventDefault()
                        selectVideo(video.id)
                      }
                    }}
                  >
                    {/* Left active highlight bar */}
                    {isActive && <div className="bg-primary absolute top-2 bottom-2 left-0 w-1 rounded-r shadow-xs" />}
    
                    {/* Left Section: Index / Status Indicator + Video Thumbnail */}
                    <div className="flex min-w-0 items-center gap-3">
                      {/* Play / Equalizer / Completed Status Icon */}
                      <div className="flex size-6 shrink-0 items-center justify-center">
                        {isActive ? (
                          /* Animated Equalizer Sound Bars */
                          <div className="flex h-3.5 w-3.5 items-end justify-center gap-0.5" title="Playing">
                            <span className="bg-primary h-3 w-0.5 animate-pulse rounded-full" />
                            <span
                              className="bg-primary h-4 w-0.5 animate-pulse rounded-full"
                              style={{ animationDelay: '150ms' }}
                            />
                            <span
                              className="bg-primary h-2 w-0.5 animate-pulse rounded-full"
                              style={{ animationDelay: '300ms' }}
                            />
                          </div>
                        ) : video.isCompleted ? (
                          <CheckCircle2 className="text-success size-4 shrink-0" aria-hidden="true" />
                        ) : (
                          <span className="text-muted-foreground font-mono text-xs font-semibold tabular-nums">
                            {video.index.toString().padStart(2, '0')}
                          </span>
                        )}
                      </div>
    
                      {/* 16:9 Video Thumbnail Preview */}
                      <div
                        className={cn(
                          'relative aspect-video w-24 shrink-0 overflow-hidden rounded-md border border-zinc-800 bg-zinc-950 bg-gradient-to-br shadow-xs sm:w-28',
                          video.thumbnailGradient,
                        )}
                      >
                        {/* Center Play Watermark */}
                        <div className="absolute inset-0 flex items-center justify-center">
                          <PlayCircle
                            className={cn(
                              'size-5 transition-transform group-hover:scale-110',
                              isActive ? 'text-primary fill-primary/20' : 'text-muted-foreground/80',
                            )}
                          />
                        </div>
    
                        {/* Duration Badge (Bottom-Right) */}
                        <div className="absolute right-1 bottom-1 rounded bg-black/85 px-1 py-0.5 font-mono text-xs font-semibold text-white tabular-nums backdrop-blur-xs">
                          {video.duration}
                        </div>
                      </div>
    
                      {/* Video Title & Instructor Info */}
                      <div className="min-w-0 space-y-0.5">
                        <p
                          className={cn(
                            'truncate text-xs font-semibold transition-colors sm:text-sm',
                            isActive ? 'text-primary font-bold' : 'text-foreground group-hover:text-primary',
                          )}
                        >
                          {video.title}
                        </p>
                        <p className="text-muted-foreground truncate text-xs">
                          <span>{video.instructor}</span>
                          <span className="mx-1.5">·</span>
                          <span>{video.category}</span>
                        </p>
                      </div>
                    </div>
    
                    {/* Right Section: Status Badge & Quick Watch Toggle */}
                    <div className="flex shrink-0 items-center gap-2">
                      {isActive ? (
                        <Badge variant="default" className="hidden px-2 py-0.5 text-xs font-semibold sm:inline-flex">
                          Playing
                        </Badge>
                      ) : video.isCompleted ? (
                        <Badge
                          variant="outline"
                          className="text-success text-success hidden gap-1 px-1.5 py-0.5 text-xs font-medium sm:inline-flex"
                        >
                          <Check className="size-3" />
                          <span>Watched</span>
                        </Badge>
                      ) : null}
    
                      {/* Toggle complete button */}
                      <Button
                        variant="ghost"
                        size="icon"
                        className="text-muted-foreground hover:text-foreground size-7 shrink-0"
                        aria-label={video.isCompleted ? 'Mark as unwatched' : 'Mark as watched'}
                        onClick={(e) => {
                          e.stopPropagation()
                          toggleComplete(video.id)
                        }}
                      >
                        {video.isCompleted ? (
                          <CheckCircle2 className="text-success size-4" />
                        ) : isActive ? (
                          <Play className="text-primary size-3.5 fill-current" />
                        ) : (
                          <Check className="text-muted-foreground/60 hover:text-foreground size-3.5" />
                        )}
                      </Button>
                    </div>
                  </div>
                )
              })}
    
              {filteredVideos.length === 0 && (
                <div className="text-muted-foreground p-8 text-center text-xs">No lessons match your search criteria.</div>
              )}
            </CardContent>
    
            <CardFooter className="border-border/60 bg-muted/20 flex items-center justify-between border-t p-3 text-xs">
              <span className="text-muted-foreground">Mastering Full-Stack UI Engineering</span>
              <span className="text-muted-foreground font-mono font-medium tabular-nums">
                {completedCount}/{totalCount} Completed ({progressPercent}%)
              </span>
            </CardFooter>
          </Card>
        </div>
      )
    }
    
    export default VideoPlaylistSidebar
    

Raw manifest:https://uipkge.dev/r/react/video-playlist-sidebar.json