Video Playlist Sidebar
blockmediaYouTube and Vimeo style video playlist drawer with active playing indicator, durations, auto-play next switch, loop/shuffle controls, and active lesson summary card.
Installation
$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/video-playlist-sidebar.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/video-playlist-sidebar.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/video-playlist-sidebar.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/video-playlist-sidebar.jsonNamed registry:
npx shadcn-vue@latest add @uipkge/video-playlist-sidebarInstalls to:app/components/blocks/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
class | HTMLAttributes['class'] | — | optional |
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
VideoResourceinterface VideoResource {
id: string
name: string
size: string
type: 'code' | 'figma' | 'pdf'
}PlaylistIteminterface 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)
app/components/blocks/VideoPlaylistSidebar.vue23.3 kB
<script setup lang="ts"> import { computed, ref } from 'vue' import type { HTMLAttributes } from 'vue' import { Check, CheckCircle2, Clock, Download, FileCode2, ListVideo, Play, PlayCircle, Repeat, Repeat1, Search, Shuffle, SkipForward, } from 'lucide-vue-next' 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 props = withDefaults( defineProps<{ class?: HTMLAttributes['class'] }>(), {}, ) const autoPlay = ref(true) const isLooping = ref(false) const isShuffled = ref(false) const searchQuery = ref('') const activeVideoId = ref('vid-03') const downloadedAssets = ref<Record<string, boolean>>({}) const playlistItems = ref<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' }, ], }, ]) const activeVideo = computed(() => { return playlistItems.value.find((item) => item.id === activeVideoId.value) || playlistItems.value[0] }) const completedCount = computed(() => { return playlistItems.value.filter((item) => item.isCompleted).length }) const totalCount = computed(() => playlistItems.value.length) const progressPercent = computed(() => { if (totalCount.value === 0) return 0 return Math.round((completedCount.value / totalCount.value) * 100) }) const filteredVideos = computed(() => { const query = searchQuery.value.trim().toLowerCase() if (!query) return playlistItems.value return playlistItems.value.filter( (item) => item.title.toLowerCase().includes(query) || item.instructor.toLowerCase().includes(query) || item.category.toLowerCase().includes(query), ) }) function selectVideo(id: string) { activeVideoId.value = id } function toggleComplete(id: string) { const video = playlistItems.value.find((item) => item.id === id) if (video) { video.isCompleted = !video.isCompleted } } function nextVideo() { const currentIndex = playlistItems.value.findIndex((item) => item.id === activeVideoId.value) if (currentIndex === -1) return if (isShuffled.value) { const remaining = playlistItems.value.filter((item) => item.id !== activeVideoId.value) if (remaining.length > 0) { const randomIndex = Math.floor(Math.random() * remaining.length) activeVideoId.value = remaining[randomIndex].id } return } if (currentIndex < playlistItems.value.length - 1) { activeVideoId.value = playlistItems.value[currentIndex + 1].id } else if (isLooping.value) { activeVideoId.value = playlistItems.value[0].id } } function handleDownload(assetId: string) { downloadedAssets.value[assetId] = true setTimeout(() => { downloadedAssets.value[assetId] = false }, 2000) } </script> <template> <div data-slot="video-playlist-sidebar" :class="cn('text-foreground mx-auto w-full max-w-2xl space-y-4', props.class)" > <!-- Main Playlist Control Card --> <Card class="border-border bg-card shadow-xs"> <CardHeader class="space-y-3 pb-3 sm:pb-4"> <!-- Badge row --> <div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex items-center gap-2"> <Badge variant="secondary" class="gap-1.5 text-xs font-semibold"> <ListVideo class="text-primary size-3.5" /> <span>Video Playlist</span> </Badge> <Badge variant="outline" class="text-muted-foreground gap-1 font-mono text-xs"> <Clock class="size-3" /> <span>1h 56m Total</span> </Badge> </div> <span class="text-muted-foreground text-xs font-medium">UIPKGE Masterclass Series</span> </div> <!-- Title & Subtitle --> <div class="space-y-1"> <CardTitle class="text-foreground text-lg font-bold tracking-tight sm:text-xl"> Mastering Full-Stack UI Engineering </CardTitle> <CardDescription class="text-xs"> Curated playlist on headless primitives, OKLCH token systems, fluid motion physics, and registry CI pipelines. </CardDescription> </div> <!-- Progress bar and counter --> <div class="bg-muted/40 border-border/70 space-y-2 rounded-lg border p-3"> <div class="flex items-center justify-between text-xs"> <span class="text-foreground font-semibold"> <span class="font-mono tabular-nums">{{ completedCount }}</span> / <span class="font-mono tabular-nums">{{ totalCount }}</span> Videos Completed </span> <span class="text-primary font-mono font-bold tabular-nums">{{ progressPercent }}%</span> </div> <Progress :model-value="progressPercent" class="h-2" /> </div> <!-- Toolbar: Autoplay switch, Loop & Shuffle toggles --> <div class="flex flex-wrap items-center justify-between gap-3 pt-1"> <!-- Autoplay next switch --> <div class="flex items-center gap-2.5"> <Switch id="autoplay-switch" :model-value="autoPlay" size="sm" @update:model-value="autoPlay = $event" /> <label for="autoplay-switch" class="text-foreground flex cursor-pointer items-center gap-1.5 text-xs font-medium select-none" > <Play class="text-primary fill-primary size-3.5" /> <span>Autoplay Next Video</span> </label> </div> <!-- Loop and Shuffle Buttons --> <div class="flex items-center gap-1.5"> <Button variant="ghost" size="sm" :class=" 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" @click="isLooping = !isLooping" > <Repeat1 v-if="isLooping" class="size-3.5" /> <Repeat v-else class="size-3.5" /> <span>Loop</span> </Button> <Button variant="ghost" size="sm" :class=" 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" @click="isShuffled = !isShuffled" > <Shuffle class="size-3.5" /> <span>Shuffle</span> </Button> </div> </div> </CardHeader> </Card> <!-- Currently Active Video Card --> <Card class="border-primary/40 from-primary/5 via-card to-card relative overflow-hidden bg-gradient-to-br shadow-xs" > <div class="bg-primary absolute top-0 right-0 left-0 h-0.5" /> <CardHeader class="pb-3"> <div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex items-center gap-2"> <Badge variant="default" class="gap-1.5 text-xs font-semibold"> <span class="size-1.5 animate-pulse rounded-full bg-white" /> <span>Now Playing</span> </Badge> <Badge variant="outline" class="font-mono text-xs font-medium"> Lesson {{ activeVideo.index }} of {{ totalCount }} </Badge> </div> <span class="text-muted-foreground font-mono text-xs font-semibold tabular-nums"> {{ activeVideo.duration }} </span> </div> <div class="space-y-1 pt-1"> <CardTitle class="text-foreground text-base font-bold tracking-tight sm:text-lg"> {{ activeVideo.title }} </CardTitle> <CardDescription class="text-xs leading-relaxed"> {{ activeVideo.summary }} </CardDescription> </div> <!-- Instructor and Metadata --> <div class="flex flex-wrap items-center justify-between gap-3 pt-2"> <div class="flex items-center gap-2"> <div class="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 class="text-foreground text-xs font-semibold">{{ activeVideo.instructor }}</p> <p class="text-muted-foreground text-xs">{{ activeVideo.instructorRole }} · {{ activeVideo.category }}</p> </div> </div> <!-- Video status controls --> <div class="flex items-center gap-2"> <Button :variant="activeVideo.isCompleted ? 'secondary' : 'outline'" size="sm" class="h-7.5 gap-1.5 px-2.5 text-xs font-medium" @click="toggleComplete(activeVideo.id)" > <CheckCircle2 v-if="activeVideo.isCompleted" class="text-success size-3.5" /> <Check v-else class="size-3.5" /> <span>{{ activeVideo.isCompleted ? 'Completed' : 'Mark as Watched' }}</span> </Button> <Button size="sm" class="h-7.5 gap-1.5 px-2.5 text-xs font-semibold shadow-xs" @click="nextVideo"> <span>Next</span> <SkipForward class="size-3.5" /> </Button> </div> </div> </CardHeader> <!-- Downloadable Lesson Exercise Files --> <CardContent class="border-border/60 bg-muted/20 border-t pt-3 pb-3.5"> <div class="space-y-2"> <div class="flex items-center justify-between"> <p class="text-foreground flex items-center gap-1.5 text-xs font-bold tracking-tight uppercase"> <FileCode2 class="text-primary size-3.5" /> <span>Lesson Exercise Files</span> </p> <span class="text-muted-foreground font-mono text-xs">{{ activeVideo.resources.length }} Files</span> </div> <div class="grid grid-cols-1 gap-2 sm:grid-cols-2"> <div v-for="res in activeVideo.resources" :key="res.id" class="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 class="flex min-w-0 items-center gap-2"> <span class="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 class="min-w-0"> <p class="text-foreground truncate text-xs font-medium">{{ res.name }}</p> <p class="text-muted-foreground font-mono text-xs">{{ res.size }}</p> </div> </div> <Button aria-label="Download attachment" variant="outline" size="sm" class="h-6.5 shrink-0 gap-1 px-2 text-xs font-medium" @click="handleDownload(res.id)" > <Check v-if="downloadedAssets[res.id]" class="text-success size-3" /> <Download v-else class="size-3" /> <span>{{ downloadedAssets[res.id] ? 'Saved' : 'Get' }}</span> </Button> </div> </div> </div> </CardContent> </Card> <!-- Playlist Queue & List Header --> <Card class="border-border bg-card shadow-xs"> <CardHeader class="space-y-3 pb-3"> <div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex items-center gap-2"> <CardTitle class="text-foreground text-sm font-bold tracking-tight">Playlist Queue</CardTitle> <Badge variant="secondary" class="font-mono text-xs">{{ totalCount }} Videos</Badge> </div> <span class="text-muted-foreground text-xs font-medium">Click any lesson to play</span> </div> <!-- Search / Filter input --> <div class="relative"> <Search class="text-muted-foreground absolute top-2.5 left-2.5 size-3.5" /> <input v-model="searchQuery" type="text" placeholder="Filter playlist by title, instructor, or topic..." class="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 class="divide-border/60 divide-y p-0"> <div v-for="video in filteredVideos" :key="video.id" :class=" cn( 'group relative flex cursor-pointer items-center justify-between gap-3 p-3 transition-colors duration-150', activeVideoId === video.id ? 'border-primary/50 bg-primary/10 ring-primary/20 shadow-xs ring-1' : 'hover:bg-muted/40', ) " tabindex="0" role="button" :aria-label="`Play ${video.title}`" :aria-current="activeVideoId === video.id ? 'true' : undefined" @click="selectVideo(video.id)" @keydown.enter="selectVideo(video.id)" @keydown.space.prevent="selectVideo(video.id)" > <!-- Left active highlight bar --> <div v-if="activeVideoId === video.id" class="bg-primary absolute top-2 bottom-2 left-0 w-1 rounded-r shadow-xs" /> <!-- Left Section: Index / Status Indicator + Video Thumbnail --> <div class="flex min-w-0 items-center gap-3"> <!-- Play / Equalizer / Completed Status Icon --> <div class="flex size-6 shrink-0 items-center justify-center"> <!-- If active: Animated Equalizer Sound Bars --> <div v-if="activeVideoId === video.id" class="flex h-3.5 w-3.5 items-end justify-center gap-0.5" title="Playing" > <span class="bg-primary h-3 w-0.5 animate-pulse rounded-full" /> <span class="bg-primary h-4 w-0.5 animate-pulse rounded-full" style="animation-delay: 150ms" /> <span class="bg-primary h-2 w-0.5 animate-pulse rounded-full" style="animation-delay: 300ms" /> </div> <!-- If completed: Checkmark --> <CheckCircle2 v-else-if="video.isCompleted" class="text-success size-4 shrink-0" aria-hidden="true" /> <!-- If unwatched: Index Number --> <span v-else class="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 :class=" 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 class="absolute inset-0 flex items-center justify-center"> <PlayCircle :class=" cn( 'size-5 transition-transform group-hover:scale-110', activeVideoId === video.id ? 'text-primary fill-primary/20' : 'text-muted-foreground/80', ) " /> </div> <!-- Duration Badge (Bottom-Right) --> <div class="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 class="min-w-0 space-y-0.5"> <p :class=" cn( 'truncate text-xs font-semibold transition-colors sm:text-sm', activeVideoId === video.id ? 'text-primary font-bold' : 'text-foreground group-hover:text-primary', ) " > {{ video.title }} </p> <p class="text-muted-foreground truncate text-xs"> <span>{{ video.instructor }}</span> <span class="mx-1.5">·</span> <span>{{ video.category }}</span> </p> </div> </div> <!-- Right Section: Status Badge & Quick Watch Toggle --> <div class="flex shrink-0 items-center gap-2"> <Badge v-if="activeVideoId === video.id" variant="default" class="hidden px-2 py-0.5 text-xs font-semibold sm:inline-flex" > Playing </Badge> <Badge v-else-if="video.isCompleted" variant="outline" class="text-success text-success hidden gap-1 px-1.5 py-0.5 text-xs font-medium sm:inline-flex" > <Check class="size-3" /> <span>Watched</span> </Badge> <!-- Toggle complete button --> <Button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-7 shrink-0" :aria-label="video.isCompleted ? 'Mark as unwatched' : 'Mark as watched'" @click.stop="toggleComplete(video.id)" > <CheckCircle2 v-if="video.isCompleted" class="text-success size-4" /> <Play v-else-if="activeVideoId === video.id" class="text-primary size-3.5 fill-current" /> <Check v-else class="text-muted-foreground/60 hover:text-foreground size-3.5" /> </Button> </div> </div> <div v-if="filteredVideos.length === 0" class="text-muted-foreground p-8 text-center text-xs"> No lessons match your search criteria. </div> </CardContent> <CardFooter class="border-border/60 bg-muted/20 flex items-center justify-between border-t p-3 text-xs"> <span class="text-muted-foreground">Mastering Full-Stack UI Engineering</span> <span class="text-muted-foreground font-mono font-medium tabular-nums"> {{ completedCount }}/{{ totalCount }} Completed ({{ progressPercent }}%) </span> </CardFooter> </Card> </div> </template>
Raw manifest:https://uipkge.dev/r/vue/video-playlist-sidebar.json