
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
Online lecture video player with playback speed selector, interactive timestamped transcript, and note-taking drawer.
Also available for Vue ->$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/lesson-video-player.json$npx shadcn@latest add https://uipkge.dev/r/react/lesson-video-player.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/lesson-video-player.json$bunx shadcn@latest add https://uipkge.dev/r/react/lesson-video-player.jsonnpx shadcn@latest add @uipkge-react/lesson-video-playerInstalls to:components/blocks/Type aliases exported from this item's source. Use these to shape the data you pass in.
TranscriptIteminterface TranscriptItem {
id: string
start: number
end: number
speaker: string
text: string
}NoteIteminterface NoteItem {
id: string
timestamp: number
text: string
createdAt: string
}QuestionIteminterface QuestionItem {
id: string
timestamp: number
author: string
avatar: string
question: string
upvotes: number
hasUpvoted?: boolean
answer?: {
author: string
role: string
text: string
isInstructor?: boolean
}
}'use client'
import * as React from 'react'
import {
ArrowRight,
Bookmark,
BookmarkCheck,
Check,
CheckCircle2,
ChevronRight,
Clock,
Download,
FileCode2,
FileText,
Layers,
Lightbulb,
Maximize2,
MessageSquare,
MessageSquareQuote,
Minimize2,
Pause,
Play,
Plus,
RotateCcw,
RotateCw,
Search,
Subtitles,
ThumbsUp,
Trash2,
Volume1,
Volume2,
VolumeX,
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
export interface TranscriptItem {
id: string
start: number
end: number
speaker: string
text: string
}
export interface NoteItem {
id: string
timestamp: number
text: string
createdAt: string
}
export interface QuestionItem {
id: string
timestamp: number
author: string
avatar: string
question: string
upvotes: number
hasUpvoted?: boolean
answer?: {
author: string
role: string
text: string
isInstructor?: boolean
}
}
const initialTranscript: TranscriptItem[] = [
{
id: 't-1',
start: 0,
end: 75,
speaker: 'Sarah Connor',
text: 'Welcome back! In this lesson, we are tackling one of the trickiest parts of design systems: building resilient dark-mode token palettes using the OKLCH color space.',
},
{
id: 't-2',
start: 75,
end: 220,
speaker: 'Sarah Connor',
text: 'Why do traditional HSL and sRGB color models fall short? Because perceptual brightness is non-uniform — pure blue at 50% lightness looks vastly darker to the human eye than pure yellow at 50% lightness.',
},
{
id: 't-3',
start: 220,
end: 384,
speaker: 'Sarah Connor',
text: 'OKLCH solves this by decoupling perceived lightness (L) from chroma (C) and hue (H). When you step lightness down by 10%, the perceived luminance decreases identically regardless of hue angle.',
},
{
id: 't-4',
start: 384,
end: 555,
speaker: 'Sarah Connor',
text: 'When calculating contrast in OKLCH, lightness is perceptually uniform across hues, which prevents dark-mode contrast crushing. Notice our formula on the slide.',
},
{
id: 't-5',
start: 555,
end: 750,
speaker: 'Sarah Connor',
text: 'Next, let us discuss wide-gamut Display P3 displays. While P3 provides 25% richer saturation, we must ensure automatic fallback clamping for legacy sRGB monitors.',
},
{
id: 't-6',
start: 750,
end: 945,
speaker: 'Sarah Connor',
text: 'In dark mode, human retinas are more sensitive to intense chroma saturation on dark surfaces. A solid heuristic is to reduce chroma by 15% to 20% on OLED dark backgrounds.',
},
{
id: 't-7',
start: 945,
end: 1120,
speaker: 'Sarah Connor',
text: 'To wrap up, download the starter configuration below. In the next lesson, we will wire these token variables directly into our Tailwind CSS v4 @theme inline directives.',
},
]
const initialNotes: NoteItem[] = [
{
id: 'n-1',
timestamp: 102, // 01:42
text: 'Pure blue vs pure yellow in HSL: huge luminance mismatch. OKLCH fixes this at the root.',
createdAt: '2 hours ago',
},
{
id: 'n-2',
timestamp: 252, // 04:12
text: 'Review color-mix in oklab formulas — can generate surface elevation tints with a single token!',
createdAt: '1 hour ago',
},
{
id: 'n-3',
timestamp: 384, // 06:24
text: 'Lightness rule: Keep L >= 0.70 for primary brand text on dark surfaces to guarantee APCA Lc 60 minimum.',
createdAt: 'Just now',
},
]
const initialQuestions: QuestionItem[] = [
{
id: 'q-1',
timestamp: 225, // 03:45
author: 'David K.',
avatar: 'DK',
question: 'Is browser support for OKLCH color-mix and raw oklch() ready for enterprise SaaS applications?',
upvotes: 18,
hasUpvoted: false,
answer: {
author: 'Sarah Connor',
role: 'Instructor',
isInstructor: true,
text: 'Yes! OKLCH has 98%+ global browser support across all evergreen browsers. Tailwind CSS v4 uses it as the default token format natively.',
},
},
{
id: 'q-2',
timestamp: 380, // 06:20
author: 'Elena R.',
avatar: 'ER',
question: 'How do you prevent high chroma vibration on dark OLED zinc-950 surfaces?',
upvotes: 12,
hasUpvoted: false,
answer: {
author: 'Sarah Connor',
role: 'Instructor',
isInstructor: true,
text: 'Scale down chroma by multiplying C * 0.82 on dark tokens, while boosting lightness by +0.08 to preserve legibility without glare.',
},
},
]
const speedOptions = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
export function LessonVideoPlayer() {
// Video Playback State
const [isPlaying, setIsPlaying] = React.useState(false)
const [currentTime, setCurrentTime] = React.useState(384) // 06:24
const [totalDuration] = React.useState(1120) // 18:40
const [bufferedPercent] = React.useState(78)
const [volume] = React.useState(85)
const [isMuted, setIsMuted] = React.useState(false)
const [playbackSpeed, setPlaybackSpeed] = React.useState(1.25)
const [showCaptions, setShowCaptions] = React.useState(true)
const [isFullscreen, setIsFullscreen] = React.useState(false)
const [activeTab, setActiveTab] = React.useState('transcript')
const [transcriptSearch, setTranscriptSearch] = React.useState('')
const [isCompleted, setIsCompleted] = React.useState(false)
const [isBookmarked, setIsBookmarked] = React.useState(false)
const [copiedAsset, setCopiedAsset] = React.useState<string | null>(null)
// Notes and Q&A state
const [notesList, setNotesList] = React.useState<NoteItem[]>(initialNotes)
const [newNoteText, setNewNoteText] = React.useState('')
const [questionsList, setQuestionsList] = React.useState<QuestionItem[]>(initialQuestions)
const [newQuestionText, setNewQuestionText] = React.useState('')
// Video interval timer
React.useEffect(() => {
let interval: NodeJS.Timeout | null = null
if (isPlaying) {
interval = setInterval(() => {
setCurrentTime((prev) => {
if (prev < totalDuration) {
return prev + 1
}
setIsPlaying(false)
return prev
})
}, 1000 / playbackSpeed)
}
return () => {
if (interval) clearInterval(interval)
}
}, [isPlaying, playbackSpeed, totalDuration])
const formattedCurrentTime = formatTime(currentTime)
const formattedTotalTime = formatTime(totalDuration)
const progressPercent = (currentTime / totalDuration) * 100
const seekTo = (seconds: number) => {
setCurrentTime(Math.max(0, Math.min(seconds, totalDuration)))
}
const handleSeekClick = (event: React.MouseEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect()
const clickX = event.clientX - rect.left
const ratio = Math.max(0, Math.min(1, clickX / rect.width))
seekTo(Math.round(ratio * totalDuration))
}
const skipSeconds = (delta: number) => {
seekTo(currentTime + delta)
}
const cycleSpeed = () => {
const currentIndex = speedOptions.indexOf(playbackSpeed)
const nextIndex = (currentIndex + 1) % speedOptions.length
setPlaybackSpeed(speedOptions[nextIndex])
}
const toggleMute = () => {
setIsMuted((prev) => !prev)
}
const activeTranscriptItem =
initialTranscript.find((item) => currentTime >= item.start && currentTime < item.end) || initialTranscript[0]
const filteredTranscript = initialTranscript.filter((item) => {
const query = transcriptSearch.trim().toLowerCase()
if (!query) return true
return item.text.toLowerCase().includes(query) || item.speaker.toLowerCase().includes(query)
})
const handleAddNote = () => {
const text = newNoteText.trim()
if (!text) return
setNotesList([
{
id: `note-${Date.now()}`,
timestamp: currentTime,
text,
createdAt: 'Just now',
},
...notesList,
])
setNewNoteText('')
}
const handleDeleteNote = (id: string) => {
setNotesList((prev) => prev.filter((n) => n.id !== id))
}
const handleAddQuestion = () => {
const text = newQuestionText.trim()
if (!text) return
setQuestionsList([
{
id: `q-${Date.now()}`,
timestamp: currentTime,
author: 'You (Student)',
avatar: 'ME',
question: text,
upvotes: 1,
hasUpvoted: true,
},
...questionsList,
])
setNewQuestionText('')
}
const toggleUpvote = (id: string) => {
setQuestionsList((prev) =>
prev.map((q) => {
if (q.id === id) {
const hasUpvoted = !q.hasUpvoted
return {
...q,
hasUpvoted,
upvotes: hasUpvoted ? q.upvotes + 1 : q.upvotes - 1,
}
}
return q
}),
)
}
const handleCopyAsset = (name: string) => {
setCopiedAsset(name)
setTimeout(() => {
setCopiedAsset(null)
}, 2000)
}
return (
<div data-slot="lesson-video-player" className="bg-background text-foreground w-full space-y-5">
{/* Top Lesson Navigation & Course Header Bar */}
<header className="bg-card rounded-xl border p-4 shadow-xs sm:px-6 sm:py-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
{/* Left: Course Context & Lesson Title */}
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="text-muted-foreground font-medium">Design Systems with Tailwind CSS v4</span>
<ChevronRight className="text-muted-foreground size-3.5" />
<Badge variant="outline" className="gap-1 text-xs font-semibold">
<Layers className="text-primary size-3" />
<span>Module 1: Design Tokens Architecture</span>
</Badge>
<span className="text-muted-foreground hidden font-medium sm:inline">Lesson 4 of 12</span>
</div>
<h1 className="text-foreground text-lg font-bold tracking-tight sm:text-xl">
Lesson 4: Building Resilient OKLCH Dark-Mode Token Palettes
</h1>
</div>
{/* Right: Progress Indicator & Navigation Buttons */}
<div className="flex flex-wrap items-center gap-2.5">
{/* Course Progress Pill */}
<div className="bg-muted/40 hidden items-center gap-2 rounded-lg border px-3 py-1.5 sm:flex">
<div className="space-y-1 text-right">
<div className="text-muted-foreground text-xs font-medium">Module Progress</div>
<div className="font-mono text-xs font-bold tabular-nums">4/12 (33%)</div>
</div>
<div className="w-12">
<Progress value={33} className="h-1.5" />
</div>
</div>
{/* Bookmark Button */}
<Button
variant="outline"
size="icon"
className="size-8.5"
aria-label={isBookmarked ? 'Remove bookmark' : 'Bookmark lesson'}
onClick={() => setIsBookmarked(!isBookmarked)}
>
{isBookmarked ? (
<BookmarkCheck className="text-primary size-4" />
) : (
<Bookmark className="text-muted-foreground size-4" />
)}
</Button>
{/* Complete Lesson Button */}
<Button
variant={isCompleted ? 'default' : 'outline'}
size="sm"
className="h-8.5 gap-1.5 text-xs font-semibold shadow-xs"
onClick={() => setIsCompleted(!isCompleted)}
>
{isCompleted ? <CheckCircle2 className="size-3.5" /> : <Check className="size-3.5" />}
<span>{isCompleted ? 'Completed' : 'Mark Complete'}</span>
</Button>
{/* Next Lesson Button */}
<Button variant="default" size="sm" className="h-8.5 gap-1.5 text-xs font-semibold shadow-xs">
<span>Next Lesson</span>
<ArrowRight className="size-3.5" />
</Button>
</div>
</div>
</header>
{/* 2-Column Classroom Layout */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12">
{/* Left Column: Video Player Canvas + Takeaways & Downloads (8 cols) */}
<section className="space-y-5 lg:col-span-8">
{/* Video Screen Container (Dark Video Canvas) */}
<div className="group relative flex min-h-[260px] w-full flex-col justify-between overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950 text-white shadow-lg select-none">
{/* Top Video Overlay Info Bar */}
<div className="z-20 flex items-center justify-between p-3.5 sm:p-4">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="border-zinc-700 bg-zinc-900/80 px-2 py-0.5 font-mono text-xs font-medium text-zinc-300 backdrop-blur-md"
>
1080p 60fps HD
</Badge>
<Badge
variant="outline"
className="border-primary/40 bg-primary/20 text-primary-foreground px-2 py-0.5 text-xs font-semibold backdrop-blur-md"
>
Lesson 4 / 12
</Badge>
</div>
{/* Instructor Watermark */}
<div className="flex items-center gap-2 rounded-full border border-zinc-800 bg-zinc-900/80 px-3 py-1 text-xs text-zinc-300 backdrop-blur-md">
<span className="bg-success size-2 rounded-full" />
<span className="font-medium">Sarah Connor</span>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">Principal Design Engineer</span>
</div>
</div>
{/* Video Presentation Slide & Diagram Visual */}
<div className="relative my-auto flex flex-col items-center justify-center px-6 py-4 text-center">
{/* Simulated Code & Color Token Slide Matrix */}
<div className="relative w-full max-w-xl space-y-3 rounded-lg border border-zinc-800/80 bg-zinc-900/85 p-4 text-left shadow-sm backdrop-blur-md">
<div className="flex items-center justify-between border-b border-zinc-800 pb-2">
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<span className="bg-destructive/80 size-2.5 rounded-full" />
<span className="bg-warning/80 size-2.5 rounded-full" />
<span className="bg-success/80 size-2.5 rounded-full" />
</div>
<span className="text-muted-foreground font-mono text-xs">
tokens.config.css — OKLCH Palette Engine
</span>
</div>
<Badge variant="outline" className="border-success/40 bg-success/10 text-success font-mono text-xs">
APCA Lc 74 (AAA)
</Badge>
</div>
{/* Code Syntax Sample */}
<pre className="overflow-x-auto font-mono text-xs leading-relaxed text-zinc-300">
<code>
<span className="text-muted-foreground">/* OKLCH Perceptual Uniformity Matrix */</span>
{'\n'}
<span className="text-chart-1">@theme inline</span> {'{\n'}{' '}
<span className="text-info">--color-primary</span>:{' '}
<span className="text-warning">oklch(0.62 0.19 259.8)</span>;{' '}
<span className="text-muted-foreground">/* Brand Base */</span>
{'\n'} <span className="text-info">--color-surface-dark</span>:{' '}
<span className="text-warning">oklch(0.18 0.02 260.0)</span>;{' '}
<span className="text-muted-foreground">/* OLED Safe */</span>
{'\n'}
{'}'}
</code>
</pre>
{/* Color Palette Swatch Strip */}
<div className="flex items-center gap-2 pt-1">
<span className="text-muted-foreground text-xs font-medium">Lightness Ramp:</span>
<div className="flex flex-1 items-center gap-1.5">
<div className="h-5 flex-1 rounded bg-[oklch(0.95_0.04_259.8)] shadow-xs" title="95% L" />
<div className="h-5 flex-1 rounded bg-[oklch(0.80_0.10_259.8)] shadow-xs" title="80% L" />
<div
className="h-5 flex-1 rounded bg-[oklch(0.62_0.19_259.8)] shadow-xs ring-1 ring-white/60"
title="62% L (Base)"
/>
<div className="h-5 flex-1 rounded bg-[oklch(0.40_0.15_259.8)] shadow-xs" title="40% L" />
<div className="h-5 flex-1 rounded bg-[oklch(0.18_0.02_259.8)] shadow-xs" title="18% L (Dark)" />
</div>
</div>
</div>
{/* Big Center Play / Pause Floating Button */}
<button
type="button"
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring absolute inset-0 m-auto flex size-14 cursor-pointer items-center justify-center rounded-full shadow-lg transition-colors focus-visible:ring-2 focus-visible:outline-none"
aria-label={isPlaying ? 'Pause video' : 'Play video'}
onClick={() => setIsPlaying(!isPlaying)}
>
{isPlaying ? (
<Pause className="size-6 fill-current" />
) : (
<Play className="ml-0.5 size-6 fill-current" />
)}
</button>
</div>
{/* Captions Subtitles Overlay Box */}
{showCaptions && activeTranscriptItem && (
<div className="z-20 mx-auto mb-1 max-w-xl rounded-lg border border-white/10 bg-black/80 px-4 py-1.5 text-center text-xs font-medium text-zinc-200 shadow-md backdrop-blur-md sm:text-sm">
<span className="text-muted-foreground">[{formatTime(activeTranscriptItem.start)}]</span>{' '}
{activeTranscriptItem.text}
</div>
)}
{/* Custom Player Controls Bar (Bottom Overlay) */}
<div className="z-20 space-y-2 bg-gradient-to-t from-black/95 via-black/85 to-transparent p-3 sm:px-4 sm:pt-4 sm:pb-3.5">
{/* Scrubber Timeline Bar */}
<div
className="group/bar relative flex h-4 w-full cursor-pointer items-center"
role="slider"
aria-label="Video Timeline Scrubber"
aria-valuenow={currentTime}
aria-valuemin={0}
aria-valuemax={totalDuration}
onClick={handleSeekClick}
>
{/* Background Full Track */}
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-zinc-800 transition-[height] group-hover/bar:h-2.5">
{/* Buffer Progress */}
<div
className="absolute top-0 bottom-0 left-0 bg-zinc-700 transition-colors"
style={{ width: `${bufferedPercent}%` }}
/>
{/* Played Progress */}
<div
className="bg-primary absolute top-0 bottom-0 left-0 transition-colors"
style={{ width: `${progressPercent}%` }}
/>
</div>
{/* Scrubber Dot Handle */}
<div
className="bg-primary absolute size-3.5 -translate-x-1/2 rounded-full shadow-md ring-2 ring-white transition-transform group-hover/bar:scale-125"
style={{ left: `${progressPercent}%` }}
/>
</div>
{/* Controls Row: Play, Skip, Timestamps, Speed, Audio, Screen */}
<div className="flex items-center justify-between gap-2 pt-0.5">
{/* Left Controls */}
<div className="flex items-center gap-1 sm:gap-2">
{/* Play / Pause */}
<Button
variant="ghost"
size="icon"
className="size-8 text-white hover:bg-white/15"
aria-label={isPlaying ? 'Pause' : 'Play'}
onClick={() => setIsPlaying(!isPlaying)}
>
{isPlaying ? (
<Pause className="size-4.5 fill-current" />
) : (
<Play className="size-4.5 fill-current" />
)}
</Button>
{/* 15s Rewind */}
<Button
variant="ghost"
size="icon"
className="size-8 text-zinc-300 hover:bg-white/15 hover:text-white"
aria-label="Rewind 15 seconds"
onClick={() => skipSeconds(-15)}
>
<RotateCcw className="size-4" />
</Button>
{/* 15s Fast Forward */}
<Button
variant="ghost"
size="icon"
className="size-8 text-zinc-300 hover:bg-white/15 hover:text-white"
aria-label="Fast forward 15 seconds"
onClick={() => skipSeconds(15)}
>
<RotateCw className="size-4" />
</Button>
{/* Volume Toggle */}
<Button
variant="ghost"
size="icon"
className="size-8 text-zinc-300 hover:bg-white/15 hover:text-white"
aria-label={isMuted ? 'Unmute' : 'Mute'}
onClick={toggleMute}
>
{isMuted || volume === 0 ? (
<VolumeX className="text-destructive size-4" />
) : volume < 50 ? (
<Volume1 className="size-4" />
) : (
<Volume2 className="size-4" />
)}
</Button>
{/* Timestamp Display */}
<div className="flex items-center gap-1 font-mono text-xs font-semibold text-zinc-300 tabular-nums sm:ml-1">
<span className="text-white">{formattedCurrentTime}</span>
<span className="text-muted-foreground">/</span>
<span className="text-muted-foreground">{formattedTotalTime}</span>
</div>
</div>
{/* Right Controls */}
<div className="flex items-center gap-1 sm:gap-2">
{/* Speed Selector Button */}
<Button
variant="ghost"
size="sm"
className="h-7 px-2 font-mono text-xs font-bold text-zinc-300 hover:bg-white/15 hover:text-white"
aria-label="Cycle Playback Speed"
onClick={cycleSpeed}
>
{playbackSpeed}x
</Button>
{/* Captions Subtitles Toggle */}
<Button
variant="ghost"
size="icon"
className={`size-8 ${
showCaptions
? 'text-primary hover:bg-white/15'
: 'text-muted-foreground hover:bg-white/15 hover:text-white'
}`}
aria-label={showCaptions ? 'Hide Subtitles' : 'Show Subtitles'}
onClick={() => setShowCaptions(!showCaptions)}
>
<Subtitles className="size-4" />
</Button>
{/* Fullscreen Toggle */}
<Button
variant="ghost"
size="icon"
className="size-8 text-zinc-300 hover:bg-white/15 hover:text-white"
aria-label={isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'}
onClick={() => setIsFullscreen(!isFullscreen)}
>
{isFullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</Button>
</div>
</div>
</div>
</div>
{/* Lesson Key Takeaways & Downloadable Assets Row */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* Key Takeaways Card */}
<Card className="shadow-xs">
<CardHeader className="pb-2.5">
<div className="flex items-center justify-between">
<CardTitle className="text-foreground flex items-center gap-1.5 text-xs font-bold tracking-tight uppercase">
<Lightbulb className="text-primary size-3.5" />
Key Takeaways
</CardTitle>
<Badge variant="outline" className="font-mono text-xs">
OKLCH v4
</Badge>
</div>
<CardDescription className="text-xs">
Essential architectural concepts taught in this lecture
</CardDescription>
</CardHeader>
<CardContent className="space-y-2.5 text-xs">
<div className="bg-muted/30 flex items-start gap-2.5 rounded-lg border p-2.5">
<div className="bg-primary/10 text-primary mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full text-xs font-bold">
1
</div>
<div>
<span className="text-foreground font-semibold">Perceptual Uniformity:</span>
<p className="text-muted-foreground mt-0.5 leading-relaxed">
OKLCH lightness remains constant across all hues, maintaining predictable contrast ratios across
both dark and light palettes.
</p>
</div>
</div>
<div className="bg-muted/30 flex items-start gap-2.5 rounded-lg border p-2.5">
<div className="bg-primary/10 text-primary mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full text-xs font-bold">
2
</div>
<div>
<span className="text-foreground font-semibold">Display P3 Gamut Clamping:</span>
<p className="text-muted-foreground mt-0.5 leading-relaxed">
Leverage vibrant P3 color gamuts on supported displays while automatically fallback-clamping for
sRGB displays.
</p>
</div>
</div>
<div className="bg-muted/30 flex items-start gap-2.5 rounded-lg border p-2.5">
<div className="bg-primary/10 text-primary mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full text-xs font-bold">
3
</div>
<div>
<span className="text-foreground font-semibold">Chroma Scaling in Dark Mode:</span>
<p className="text-muted-foreground mt-0.5 leading-relaxed">
Scale down chroma by 15-20% on deep dark backgrounds to prevent ocular fatigue and visual
vibration.
</p>
</div>
</div>
</CardContent>
</Card>
{/* Downloadable Source Code & Assets Card */}
<Card className="shadow-xs">
<CardHeader className="pb-2.5">
<div className="flex items-center justify-between">
<CardTitle className="text-foreground flex items-center gap-1.5 text-xs font-bold tracking-tight uppercase">
<FileCode2 className="text-primary size-3.5" />
Lesson Assets & Code
</CardTitle>
<Badge variant="secondary" className="text-xs">
3 Files
</Badge>
</div>
<CardDescription className="text-xs">Starter boilerplate and token configuration files</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-xs">
{/* Asset 1 */}
<div className="bg-muted/30 hover:bg-muted/50 flex items-center justify-between rounded-lg border p-2.5 transition-colors">
<div className="flex min-w-0 items-center gap-2.5">
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-md font-mono text-xs font-bold">
TS
</div>
<div className="min-w-0">
<p className="text-foreground truncate text-xs font-semibold">tokens.config.ts</p>
<p className="text-muted-foreground font-mono text-xs">14.2 KB · Tailwind v4 Theme</p>
</div>
</div>
<Button
aria-label="Download attachment"
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-xs"
onClick={() => handleCopyAsset('tokens.config.ts')}
>
{copiedAsset === 'tokens.config.ts' ? (
<Check className="text-success size-3" />
) : (
<Download className="size-3" />
)}
<span>{copiedAsset === 'tokens.config.ts' ? 'Saved' : 'Download'}</span>
</Button>
</div>
{/* Asset 2 */}
<div className="bg-muted/30 hover:bg-muted/50 flex items-center justify-between rounded-lg border p-2.5 transition-colors">
<div className="flex min-w-0 items-center gap-2.5">
<div className="bg-chart-2/10 text-chart-2 flex size-8 shrink-0 items-center justify-center rounded-md font-mono text-xs font-bold">
FIG
</div>
<div className="min-w-0">
<p className="text-foreground truncate text-xs font-semibold">palette-matrix.fig</p>
<p className="text-muted-foreground font-mono text-xs">4.8 MB · Token Library</p>
</div>
</div>
<Button
aria-label="Download attachment"
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-xs"
onClick={() => handleCopyAsset('palette-matrix.fig')}
>
{copiedAsset === 'palette-matrix.fig' ? (
<Check className="text-success size-3" />
) : (
<Download className="size-3" />
)}
<span>{copiedAsset === 'palette-matrix.fig' ? 'Saved' : 'Download'}</span>
</Button>
</div>
{/* Asset 3 */}
<div className="bg-muted/30 hover:bg-muted/50 flex items-center justify-between rounded-lg border p-2.5 transition-colors">
<div className="flex min-w-0 items-center gap-2.5">
<div className="bg-destructive/10 text-destructive flex size-8 shrink-0 items-center justify-center rounded-md font-mono text-xs font-bold">
PDF
</div>
<div className="min-w-0">
<p className="text-foreground truncate text-xs font-semibold">apca-contrast-guide.pdf</p>
<p className="text-muted-foreground font-mono text-xs">1.1 MB · Cheat Sheet</p>
</div>
</div>
<Button
aria-label="Download attachment"
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-xs"
onClick={() => handleCopyAsset('apca-contrast-guide.pdf')}
>
{copiedAsset === 'apca-contrast-guide.pdf' ? (
<Check className="text-success size-3" />
) : (
<Download className="size-3" />
)}
<span>{copiedAsset === 'apca-contrast-guide.pdf' ? 'Saved' : 'Download'}</span>
</Button>
</div>
</CardContent>
</Card>
</div>
</section>
{/* Right Column: Interactive Transcript & Notes Workspace (4 cols) */}
<aside className="flex flex-col lg:col-span-4">
<Card className="flex h-full flex-col overflow-hidden shadow-xs">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex h-full flex-col">
{/* Tabs Navigation Header */}
<div className="bg-muted/30 border-b p-2.5">
<TabsList className="grid h-8.5 w-full grid-cols-3">
<TabsTrigger value="transcript" className="gap-1 text-xs font-medium">
<FileText className="size-3" />
<span>Transcript</span>
</TabsTrigger>
<TabsTrigger value="notes" className="gap-1 text-xs font-medium">
<BookmarkCheck className="size-3" />
<span>Notes</span>
<Badge variant="secondary" className="ml-0.5 px-1 py-0 text-xs">
{notesList.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="qa" className="gap-1 text-xs font-medium">
<MessageSquareQuote className="size-3" />
<span>Q&A</span>
<Badge variant="secondary" className="ml-0.5 px-1 py-0 text-xs">
{questionsList.length}
</Badge>
</TabsTrigger>
</TabsList>
</div>
{/* TAB 1: Interactive Transcript */}
<TabsContent value="transcript" className="m-0 flex flex-1 flex-col focus-visible:outline-none">
{/* Search Transcript Filter */}
<div className="border-b p-3">
<div className="relative">
<Search className="text-muted-foreground absolute top-2.5 left-2.5 size-3.5" />
<Input
value={transcriptSearch}
onChange={(e) => setTranscriptSearch(e.target.value)}
placeholder="Search lecture transcript..."
className="h-8 pl-8 text-xs"
/>
</div>
</div>
{/* Transcript Segments List */}
<div className="max-h-[580px] flex-1 space-y-2.5 overflow-y-auto p-3.5">
{filteredTranscript.map((item) => {
const isActive = currentTime >= item.start && currentTime < item.end
return (
<div
key={item.id}
className={`group cursor-pointer rounded-lg border p-3 text-xs transition-colors duration-200 ${
isActive
? 'border-primary/50 bg-primary/10 ring-primary/20 shadow-xs ring-1'
: 'border-border/70 bg-card hover:bg-muted/40'
}`}
onClick={() => seekTo(item.start)}
>
<div className="mb-1.5 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Button
variant="secondary"
size="sm"
className="h-6 gap-1 rounded-md px-1.5 font-mono text-xs font-bold tabular-nums"
onClick={(e) => {
e.stopPropagation()
seekTo(item.start)
}}
>
<Play className="size-2.5 fill-current" />
<span>{formatTime(item.start)}</span>
</Button>
<span className="text-muted-foreground text-xs font-medium">{item.speaker}</span>
</div>
{isActive && (
<Badge variant="default" className="gap-1 px-1.5 py-0 text-xs font-semibold">
<span className="size-1.5 animate-pulse rounded-full bg-white" />
<span>Playing</span>
</Badge>
)}
</div>
<p
className={`leading-relaxed ${
isActive
? 'text-foreground font-medium'
: 'text-muted-foreground group-hover:text-foreground'
}`}
>
{item.text}
</p>
</div>
)
})}
</div>
{/* Transcript Footer Notice */}
<div className="bg-muted/30 text-muted-foreground border-t p-2.5 text-center text-xs">
Click any line to jump to that timestamp in the lecture.
</div>
</TabsContent>
{/* TAB 2: Timestamped Notes Workspace */}
<TabsContent value="notes" className="m-0 flex flex-1 flex-col focus-visible:outline-none">
{/* Note Composer */}
<div className="bg-muted/20 space-y-2 border-b p-3">
<div className="flex items-center justify-between">
<label className="text-foreground flex items-center gap-1.5 text-xs font-bold tracking-tight">
<Clock className="text-primary size-3.5" />
<span>Add Note at {formattedCurrentTime}</span>
</label>
<span className="text-muted-foreground font-mono text-xs">Captures current playback time</span>
</div>
<Textarea
value={newNoteText}
onValueChange={(v) => setNewNoteText(v)}
rows={2}
className="resize-y text-xs leading-relaxed"
placeholder={`Write your thoughts or takeaway at ${formattedCurrentTime}...`}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
handleAddNote()
}
}}
/>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">Press ⌘+Enter to save</span>
<Button
size="sm"
className="h-7.5 gap-1 px-3 text-xs font-semibold"
disabled={!newNoteText.trim()}
onClick={handleAddNote}
>
<Plus className="size-3.5" />
<span>Save Note</span>
</Button>
</div>
</div>
{/* Saved Notes List */}
<div className="max-h-[500px] flex-1 space-y-2.5 overflow-y-auto p-3.5">
{notesList.map((note) => (
<div
key={note.id}
className="group bg-card hover:border-border rounded-lg border p-3 text-xs shadow-xs transition-colors"
>
<div className="mb-1.5 flex items-center justify-between">
<Button
variant="outline"
size="sm"
className="text-primary h-5 gap-1 rounded px-1.5 font-mono text-xs font-bold tabular-nums"
onClick={() => seekTo(note.timestamp)}
>
<Play className="size-2.5 fill-current" />
<span>{formatTime(note.timestamp)}</span>
</Button>
<div className="flex items-center gap-1">
<span className="text-muted-foreground text-xs">{note.createdAt}</span>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive size-5 opacity-0 group-hover:opacity-100"
aria-label="Delete note"
onClick={() => handleDeleteNote(note.id)}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
<p className="text-foreground leading-relaxed">{note.text}</p>
</div>
))}
{notesList.length === 0 && (
<div className="text-muted-foreground py-8 text-center text-xs">
No personal notes yet. Add your first note above!
</div>
)}
</div>
</TabsContent>
{/* TAB 3: Q&A Community Discussion */}
<TabsContent value="qa" className="m-0 flex flex-1 flex-col focus-visible:outline-none">
{/* Question Composer */}
<div className="bg-muted/20 space-y-2 border-b p-3">
<div className="flex items-center justify-between">
<label className="text-foreground flex items-center gap-1.5 text-xs font-bold tracking-tight">
<MessageSquare className="text-primary size-3.5" />
<span>Ask Instructor at {formattedCurrentTime}</span>
</label>
<span className="text-muted-foreground font-mono text-xs">Tagged to this video time</span>
</div>
<Input
value={newQuestionText}
onChange={(e) => setNewQuestionText(e.target.value)}
placeholder="Ask a question about this topic..."
className="h-8 text-xs"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddQuestion()
}
}}
/>
<div className="flex items-center justify-end">
<Button
size="sm"
className="h-7 gap-1 px-3 text-xs font-semibold"
disabled={!newQuestionText.trim()}
onClick={handleAddQuestion}
>
<span>Post Question</span>
</Button>
</div>
</div>
{/* Questions Thread List */}
<div className="max-h-[500px] flex-1 space-y-3 overflow-y-auto p-3.5">
{questionsList.map((q) => (
<div key={q.id} className="bg-card space-y-2.5 rounded-lg border p-3 text-xs shadow-xs">
{/* Question Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="bg-primary/10 text-primary flex size-6 items-center justify-center rounded-full text-xs font-bold">
{q.avatar}
</div>
<div>
<span className="text-foreground font-semibold">{q.author}</span>
<Button
variant="link"
size="sm"
className="text-muted-foreground ml-1.5 h-auto p-0 font-mono text-xs"
onClick={() => seekTo(q.timestamp)}
>
[{formatTime(q.timestamp)}]
</Button>
</div>
</div>
{/* Upvote Button */}
<Button
variant={q.hasUpvoted ? 'default' : 'outline'}
size="sm"
className="h-6 gap-1 px-1.5 text-xs"
onClick={() => toggleUpvote(q.id)}
>
<ThumbsUp className="size-2.5" />
<span className="font-mono tabular-nums">{q.upvotes}</span>
</Button>
</div>
{/* Question Text */}
<p className="text-foreground leading-relaxed">{q.question}</p>
{/* Instructor Answer if available */}
{q.answer && (
<div className="bg-muted/40 border-primary/20 space-y-1 rounded-md border p-2.5">
<div className="flex items-center gap-1.5">
<span className="text-foreground text-xs font-semibold">{q.answer.author}</span>
<Badge variant="default" className="bg-success px-1 py-0 text-xs font-normal">
Instructor Verified
</Badge>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">{q.answer.text}</p>
</div>
)}
</div>
))}
</div>
</TabsContent>
</Tabs>
</Card>
</aside>
</div>
</div>
)
}
export default LessonVideoPlayer
Raw manifest:https://uipkge.dev/r/react/lesson-video-player.json