initial commit

This commit is contained in:
Nice Try
2026-06-26 02:51:08 +00:00
commit ad5810fba9
133 changed files with 28275 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import {
Play,
Clock as ClockIcon
} from 'iconoir-react'
import { Card, CardContent } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { formatTime } from '@/lib/utils'
import { getCourseDisplayName } from '@/lib/course-display'
interface ContinueWatchingCardProps {
item: {
lesson: {
id: string
title: string
type: string
duration: number | null
thumbnail: string | null
}
progress: {
position: number
lastWatched: string
}
course: {
name: string
slug: string
}
module: {
name: string
}
}
index: number
}
export function ContinueWatchingCard({ item, index }: ContinueWatchingCardProps) {
const { lesson, progress, course, module } = item
const courseTitle = getCourseDisplayName(course)
const percentage = lesson.duration && progress.position
? Math.min(100, (progress.position / lesson.duration) * 100)
: 0
// Determine video thumbnail style based on course/module
const getThumbClass = () => {
const name = (courseTitle + ' ' + module.name).toLowerCase()
if (name.includes('linux') || name.includes('terraform') || name.includes('shell')) return 'video-thumb-linux'
if (name.includes('code') || name.includes('program') || name.includes('javascript') || name.includes('python')) return 'video-thumb-code'
if (name.includes('automat') || name.includes('workflow') || name.includes('n8n')) return 'video-thumb-flow'
return 'video-thumb-presenter'
}
return (
<Link
href={`/watch/${lesson.id}?course=${course.slug}`}
key={`${lesson.id}-${index}`}
className="flex-none w-72 sm:w-80 snap-start group"
>
<Card className="glass-card-elevated h-full overflow-hidden transition-all hover:shadow-[0_0_30px_-10px_hsl(160_100%_42%_/_0.25)] group">
{/* Video Thumbnail */}
<div className="relative aspect-video bg-muted overflow-hidden">
<div className={`video-thumb ${getThumbClass()} w-full h-full transition-transform duration-500 group-hover:scale-[1.02]`} />
{/* Duration badge */}
{lesson.duration && (
<div className="absolute bottom-2 right-2 px-2 py-1 bg-black/80 backdrop-blur-sm rounded text-xs font-medium text-white">
{formatTime(lesson.duration)}
</div>
)}
{/* Play overlay */}
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<button className="relative w-14 h-14 rounded-full bg-accent/90 flex items-center justify-center text-accent-foreground hover:scale-110 hover:bg-accent transition-all duration-300" aria-label="Resume">
<Play className="h-7 w-7 ml-1" />
<span className="absolute inset-0 rounded-full bg-accent/30 animate-pulse" />
</button>
</div>
{/* Progress bar overlay */}
{progress.position && lesson.duration && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/50">
<div
className="h-full bg-primary transition-all duration-500"
style={{ width: `${percentage}%` }}
/>
</div>
)}
</div>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-1 truncate">{courseTitle}</p>
<h3 className="font-medium text-sm mb-2 line-clamp-1 text-foreground group-hover:text-primary transition-colors">{lesson.title}</h3>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<ClockIcon className="h-3 w-3" />
{lesson.duration
? `${formatTime(progress.position || 0)} / ${formatTime(lesson.duration)}`
: formatTime(progress.position || 0)}
</span>
{lesson.duration && (
<Progress
value={percentage}
className="h-1.5 flex-1 gradient-progress"
/>
)}
</div>
</CardContent>
</Card>
</Link>
)
}
interface ContinueWatchingSectionProps {
items: Array<{
lesson: {
id: string
title: string
type: string
duration: number | null
thumbnail: string | null
}
progress: {
position: number
lastWatched: string
}
course: {
name: string
slug: string
}
module: {
name: string
}
}>
}
export function ContinueWatchingSection({ items }: ContinueWatchingSectionProps) {
if (items.length === 0) return null
return (
<section className="mb-10" aria-labelledby="continue-watching-heading">
<div className="flex items-center justify-between mb-6">
<h2 id="continue-watching-heading" className="text-xl font-semibold flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
<Play className="h-5 w-5 text-primary" />
</div>
Continue Watching
</h2>
</div>
<div className="flex gap-4 pb-4 -mx-4 px-4" style={{ scrollSnapType: 'x mandatory' }} role="list">
{items.map((item, index) => (
<ContinueWatchingCard key={`${item.lesson.id}-${index}`} item={item} index={index} />
))}
</div>
</section>
)
}
+512
View File
@@ -0,0 +1,512 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import {
Play,
Clock,
CheckCircle,
EllipsisVertical,
PencilLine,
Trash2,
Bomb,
Star,
Tags,
FolderPlus,
BrainCircuit,
Loader2,
} from 'lucide-react'
import {
VideoCamera,
ViewGrid,
BookStack,
Database,
Folder,
Code,
Computer,
Shield,
Network,
} from 'iconoir-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { getCourseThumbnailClient } from '@/lib/thumbnail-index'
import { getCourseDisplayName } from '@/lib/course-display'
import { getDeterministicGradient } from '@/lib/cover-gradient'
interface CourseCardProps {
course: {
id: string
name: string
slug: string
thumbnail: string | null
description: string | null
favorited?: boolean
tags?: Array<{ id: string; name: string; color?: string | null }>
_count?: { modules: number; lessons: number }
progress?: {
completedLessons: number
totalLessons: number
percentage: number
lastWatched: Date | string | null
}
}
onChanged?: () => void | Promise<void>
}
function coverGradient(title: string): string {
return getDeterministicGradient(title)
}
function getCourseIcon(name: string) {
const n = name.toLowerCase()
if (n.includes('hashicorp') || n.includes('terraform') || n.includes('vault') || n.includes('consul')) return <Shield className="h-5 w-5" />
if (n.includes('linux') || n.includes('shell') || n.includes('bash') || n.includes('terminal')) return <Computer className="h-5 w-5" />
if (n.includes('code') || n.includes('program') || n.includes('develop') || n.includes('python') || n.includes('javascript')) return <Code className="h-5 w-5" />
if (n.includes('automat') || n.includes('n8n') || n.includes('workflow') || n.includes('zapier')) return <Network className="h-5 w-5" />
if (n.includes('database') || n.includes('sql') || n.includes('db')) return <Database className="h-5 w-5" />
if (n.includes('folder') || n.includes('file') || n.includes('system')) return <Folder className="h-5 w-5" />
return <BookStack className="h-5 w-5" />
}
function getCourseIconColor(name: string) {
const n = name.toLowerCase()
if (n.includes('hashicorp') || n.includes('terraform') || n.includes('vault') || n.includes('consul')) return 'text-primary'
if (n.includes('linux') || n.includes('shell') || n.includes('bash') || n.includes('terminal')) return 'text-emerald-400'
if (n.includes('code') || n.includes('program') || n.includes('develop') || n.includes('python') || n.includes('javascript')) return 'text-amber-400'
if (n.includes('automat') || n.includes('n8n') || n.includes('workflow') || n.includes('zapier')) return 'text-cyan-400'
if (n.includes('database') || n.includes('sql') || n.includes('db')) return 'text-blue-400'
return 'text-primary'
}
function getCoverClass(name: string) {
const n = name.toLowerCase()
if (n.includes('hashicorp') || n.includes('terraform') || n.includes('vault') || n.includes('consul')) return 'course-cover-hashicorp'
if (n.includes('linux') || n.includes('shell') || n.includes('bash') || n.includes('terminal')) return 'course-cover-linux'
if (n.includes('code') || n.includes('program') || n.includes('develop') || n.includes('python') || n.includes('javascript')) return 'course-cover-code'
if (n.includes('automat') || n.includes('n8n') || n.includes('workflow') || n.includes('zapier')) return 'course-cover-automation'
return 'course-cover-generic'
}
export function CourseCard({ course, onChanged }: CourseCardProps) {
const router = useRouter()
const [quickAction, setQuickAction] = React.useState<'tag' | 'category' | null>(null)
const [quickActionValue, setQuickActionValue] = React.useState('')
const [quickActionError, setQuickActionError] = React.useState<string | null>(null)
const [quizStatus, setQuizStatus] = React.useState<'idle' | 'running' | 'success' | 'error'>('idle')
const [quizMessage, setQuizMessage] = React.useState<string | null>(null)
const { progress, _count } = course
const courseTitle = getCourseDisplayName(course)
const percentage = progress?.percentage || 0
const completed = progress?.completedLessons || 0
const total = progress?.totalLessons || _count?.lessons || 0
const lastWatched = progress?.lastWatched
const visibleTags = course.tags || []
const categoryTags = visibleTags.filter((tag) => tag.name.startsWith('category:'))
const plainTags = visibleTags.filter((tag) => !tag.name.startsWith('category:'))
const CourseIcon = getCourseIcon(course.name)
const iconColor = getCourseIconColor(course.name)
const coverClass = getCoverClass(course.name)
const thumbnailUrl = getCourseThumbnailClient(course)
const refreshLibrary = async () => {
await onChanged?.()
router.refresh()
}
const patchCourse = async (payload: Record<string, unknown>) => {
const response = await fetch(`/api/courses/${course.slug}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data?.error || 'Failed to update course')
}
await refreshLibrary()
}
const handleToggleFavorite = async () => {
try {
await patchCourse({ favorited: !course.favorited })
} catch (error) {
console.error('Toggle favorite failed:', error)
window.alert('Sorry, the pin did not stick. The favorite goblin dropped it.')
}
}
const startQuickAction = (mode: 'tag' | 'category') => {
setQuickAction(mode)
setQuickActionValue('')
setQuickActionError(null)
}
const submitQuickAction = async () => {
if (!quickAction) return
const trimmed = quickActionValue.trim().replace(/\s+/g, ' ')
if (!trimmed) {
setQuickActionError(quickAction === 'tag' ? 'Type a tag first.' : 'Type a category first.')
return
}
try {
await patchCourse({ tagId: quickAction === 'category' ? `category:${trimmed}` : trimmed })
setQuickAction(null)
setQuickActionValue('')
setQuickActionError(null)
} catch (error) {
console.error(`${quickAction} add failed:`, error)
setQuickActionError(quickAction === 'tag' ? 'Tag did not stick. Try again.' : 'Category did not stick. Try again.')
}
}
const handleRename = async () => {
const nextName = window.prompt('Rename course', courseTitle)
if (nextName === null) return
const trimmed = nextName.trim()
if (!trimmed || trimmed === courseTitle) return
try {
const response = await fetch(`/api/courses/${course.slug}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName: trimmed }),
})
if (!response.ok) {
throw new Error('Failed to rename course')
}
await refreshLibrary()
} catch (error) {
console.error('Rename course failed:', error)
window.alert('Sorry, the rename did not stick. The little gremlin in the API tripped.')
}
}
const handleDelete = async (scope: 'library' | 'disk') => {
const isDiskDelete = scope === 'disk'
const confirmationMessage = isDiskDelete
? `Delete "${courseTitle}" from disk?\n\nThis permanently removes the course folder and all library records. This cannot be undone.`
: `Delete "${courseTitle}" from the library?\n\nThis hides the course from the app but keeps its files on disk.`
const confirmed = window.confirm(confirmationMessage)
if (!confirmed) return
try {
const response = await fetch(`/api/courses/${course.slug}?scope=${scope}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error(`Failed to delete course (${scope})`)
}
await refreshLibrary()
} catch (error) {
console.error('Delete course failed:', error)
window.alert(
isDiskDelete
? 'Sorry, the disk delete did not stick. The server bumped into the filesystem goblin.'
: 'Sorry, the library delete did not stick. The server sneezed on the request.'
)
}
}
const clearQuiz = async () => {
const confirmed = window.confirm(`Clear all quizzes for "${courseTitle}"? This cannot be undone.`)
if (!confirmed) return
setQuizStatus('running')
setQuizMessage('Clearing quizzes...')
try {
const response = await fetch(`/api/courses/${course.slug}/quiz`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'clear', module: '__all__' }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data?.error || 'Failed to clear quizzes')
}
await onChanged?.()
router.refresh()
setQuizStatus('success')
setQuizMessage(`Cleared all quizzes for ${courseTitle}`)
} catch (error) {
console.error('Clear quiz failed:', error)
setQuizStatus('error')
setQuizMessage(error instanceof Error ? error.message : 'Failed to clear quizzes.')
}
}
const handleRegenerateQuiz = async () => {
setQuizStatus('running')
setQuizMessage('Regenerating quizzes with QuizAPI...')
try {
const response = await fetch(`/api/courses/${course.slug}/quiz`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'regenerate', source: 'quizapi', difficulty: 'medium' }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data?.error || 'Failed to regenerate quizzes')
}
const regenerated = data.results?.filter((item: { status: string }) => item.status === 'ok').length
await onChanged?.()
router.refresh()
setQuizStatus('success')
setQuizMessage(`QuizAPI regenerated ${regenerated ?? 'the'} module quiz set.`)
} catch (error) {
console.error('Regenerate quiz failed:', error)
setQuizStatus('error')
setQuizMessage(error instanceof Error ? error.message : 'Quiz regeneration failed.')
}
}
return (
<article className="block">
<Card className="glass-card-elevated group relative h-full flex flex-col overflow-hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-3 top-3 z-30 h-9 w-9 rounded-full bg-black/40 text-white opacity-0 shadow-lg backdrop-blur-sm transition-all hover:bg-black/60 group-hover:opacity-100 focus:opacity-100"
aria-label={`Course actions for ${courseTitle}`}
>
<EllipsisVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onSelect={handleToggleFavorite} className="gap-2">
<Star className={course.favorited ? 'h-4 w-4 fill-amber-400 text-amber-400' : 'h-4 w-4'} />
{course.favorited ? 'Unpin favorite' : 'Pin favorite'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={(event) => { event.preventDefault(); startQuickAction('tag') }} className="gap-2">
<Tags className="h-4 w-4" />
Add tag
</DropdownMenuItem>
<DropdownMenuItem onSelect={(event) => { event.preventDefault(); startQuickAction('category') }} className="gap-2">
<FolderPlus className="h-4 w-4" />
Add category
</DropdownMenuItem>
{quickAction && (
<div className="px-2 py-2" onClick={(event) => event.stopPropagation()}>
<label className="mb-1 block text-xs font-medium text-muted-foreground">
{quickAction === 'tag' ? 'New tag' : 'New category'}
</label>
<input
value={quickActionValue}
onChange={(event) => setQuickActionValue(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === 'Enter') submitQuickAction()
if (event.key === 'Escape') setQuickAction(null)
}}
placeholder={quickAction === 'tag' ? 'e.g. linux' : 'e.g. Cloud'}
className="mb-2 h-8 w-full rounded-md border border-input bg-background px-2 text-sm outline-none focus:ring-2 focus:ring-primary/40"
autoFocus
/>
{quickActionError && <p className="mb-2 text-xs text-destructive">{quickActionError}</p>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => setQuickAction(null)}>Cancel</Button>
<Button type="button" size="sm" onClick={submitQuickAction}>Save</Button>
</div>
</div>
)}
<DropdownMenuItem onSelect={clearQuiz} disabled={quizStatus === 'running'} className="gap-2">
{quizStatus === 'running' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
{quizStatus === 'running' ? 'Clearing...' : 'Clear Quiz'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleRegenerateQuiz} disabled={quizStatus === 'running'} className="gap-2">
{quizStatus === 'running' ? <Loader2 className="h-4 w-4 animate-spin" /> : <BrainCircuit className="h-4 w-4" />}
{quizStatus === 'running' ? 'Regenerating...' : 'Regenerate Quiz'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleRename} className="gap-2">
<PencilLine className="h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => handleDelete('library')} className="gap-2 text-destructive focus:text-destructive">
<Trash2 className="h-4 w-4" />
Delete from library
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleDelete('disk')} className="gap-2 text-destructive focus:text-destructive">
<Bomb className="h-4 w-4" />
Delete from disk
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link href={'/course/' + course.slug} className="absolute inset-0 z-10" aria-label={`Open ${courseTitle}`}>
<span className="sr-only">Open {courseTitle}</span>
</Link>
{/* Cover: local image -> deterministic gradient -> legacy CSS cover */}
<div className="relative aspect-video overflow-hidden rounded-t-xl bg-muted">
{course.thumbnail ? (
<img
src={course.thumbnail}
alt={courseTitle}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.02]"
onError={(event) => {
const target = event.currentTarget
target.style.display = 'none'
}}
/>
) : (
<div className={`flex h-full w-full items-center justify-center ${coverGradient(courseTitle)}`}>
<p className="px-4 text-center text-lg font-semibold text-white drop-shadow-md">{courseTitle}</p>
</div>
)}
{/* Play overlay */}
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<button
type="button"
className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-300 hover:scale-110 hover:bg-primary"
aria-label="Play"
>
<Play className="ml-1 h-7 w-7" />
</button>
</div>
{/* Quiz regeneration overlay */}
{quizStatus === 'running' && (
<div className="absolute inset-x-3 bottom-3 z-30 rounded-xl border border-primary/30 bg-background/90 p-3 shadow-lg backdrop-blur-md">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-primary">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Regenerating quiz with QuizAPI...
</div>
<Progress value={70} className="h-1.5 gradient-progress" />
</div>
)}
{/* Progress overlay */}
{percentage > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1.5 bg-black/50">
<Progress value={percentage} className="h-full gradient-progress bg-transparent" />
</div>
)}
{/* Completion / favorite badges */}
<div className="absolute right-3 top-3 z-20 flex flex-col items-end gap-2">
{course.favorited && (
<Badge variant="secondary" className="bg-amber-500/20 px-2.5 py-1 text-amber-300 border-amber-500/30 gap-1">
<Star className="h-3 w-3 fill-amber-300" />
Pinned
</Badge>
)}
{percentage === 100 && total > 0 && (
<Badge variant="success" className="px-2.5 py-1 gap-1">
<CheckCircle className="h-3 w-3" />
Complete
</Badge>
)}
</div>
{/* Course Type Icon */}
<div className="absolute left-3 top-3 z-20">
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-black/60 backdrop-blur-sm">
<span className={iconColor}>{CourseIcon}</span>
</div>
</div>
</div>
{/* Content */}
<CardContent className="relative z-0 flex flex-1 flex-col p-5">
<h3 className="mb-2 line-clamp-1 text-lg font-semibold transition-colors group-hover:text-primary">{courseTitle}</h3>
{course.description && (
<p className="mb-4 flex-1 line-clamp-2 text-sm text-muted-foreground">{course.description}</p>
)}
{visibleTags.length > 0 && (
<div className="mb-4 flex flex-wrap gap-1.5">
{categoryTags.slice(0, 2).map((tag) => (
<Badge key={tag.id} variant="secondary" className="bg-primary/10 text-primary border-primary/20">
{tag.name.replace(/^category:/, '')}
</Badge>
))}
{plainTags.slice(0, 3).map((tag) => (
<Badge key={tag.id} variant="outline" className="bg-white/5">
{tag.name}
</Badge>
))}
{visibleTags.length > 5 && (
<Badge variant="outline" className="bg-white/5">+{visibleTags.length - 5}</Badge>
)}
</div>
)}
{/* Stats with Iconoir icons */}
<div className="mb-4 flex flex-wrap items-center gap-2.5 text-xs text-muted-foreground">
{_count?.modules && (
<span className="flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1">
<ViewGrid className="h-3 w-3" />
{_count.modules} modules
</span>
)}
{total > 0 && (
<span className="flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1">
<VideoCamera className="h-3 w-3" />
{total} lessons
</span>
)}
</div>
{quizMessage && quizStatus !== 'idle' && (
<div className={`mb-4 rounded-lg border px-3 py-2 text-xs ${quizStatus === 'error' ? 'border-destructive/30 bg-destructive/10 text-destructive' : 'border-primary/30 bg-primary/10 text-primary'}`}>
{quizMessage}
</div>
)}
{/* Emerald Gradient Progress Bar */}
{total > 0 && (
<div className="mb-4">
<div className="mb-1.5 flex justify-between text-xs text-muted-foreground">
<span>{completed} / {total} lessons</span>
<span className="font-semibold text-primary">{Math.round(percentage)}%</span>
</div>
<Progress value={percentage} className="h-2.5 gradient-progress" />
</div>
)}
</CardContent>
{/* Footer */}
<CardFooter className="border-t border-white/5 px-5 pb-5 pt-0">
{lastWatched && (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
Last: {new Date(lastWatched).toLocaleDateString()}
</span>
)}
</CardFooter>
</Card>
</article>
)
}
+235
View File
@@ -0,0 +1,235 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import { Search, Settings, Plus, BookOpen, BarChart2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ThemeToggle } from '@/components/ThemeToggle'
interface HeaderProps {
showSearchFilter?: boolean
showScanButtons?: boolean
showBackButton?: boolean
backHref?: string
backLabel?: string
onSearchClick?: () => void
onFilterClick?: () => void
onQuickScan?: () => void
coffeeUrl?: string
}
export function Header({
showSearchFilter = true,
showScanButtons = false,
showBackButton = false,
backHref,
backLabel,
onSearchClick,
onFilterClick,
onQuickScan,
coffeeUrl,
}: HeaderProps) {
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false)
return (
<header className="sticky top-0 z-40 w-full border-b border-white/5 bg-background/80 backdrop-blur-xl">
<div className="container mx-auto px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
{/* Back button - only on course/watch pages */}
{showBackButton && backHref && (
<Link
href={backHref}
className="header-control hover:text-primary hover:bg-primary/10 px-3 py-2"
aria-label="Back"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m12 19-7-7 7-7"></path>
<path d="M19 12H5"></path>
</svg>
<span className="hidden sm:inline ml-1">{backLabel || 'Back'}</span>
</Link>
)}
{/* Logo - Text + Icon */}
<Link href="/" className="flex items-center gap-3" aria-label="OfflineAcademy Home">
<img
src="/logo.png"
alt="OfflineAcademy"
className="h-10 w-auto object-contain transition-all duration-300"
width="120"
height="40"
/>
<div>
<h1 className="text-xl font-bold bg-gradient-to-r from-white via-primary to-accent bg-clip-text text-transparent">
OfflineAcademy
</h1>
<p className="text-xs text-muted-foreground font-medium">Your Offline Learning Hub</p>
</div>
</Link>
</div>
{/* Desktop Controls */}
<div className="hidden md:flex items-center gap-1.5">
{showSearchFilter && (
<>
<Button
variant="ghost"
size="sm"
className="header-control"
aria-label="Search"
onClick={onSearchClick}
>
<svg className="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
<span className="hidden lg:inline ml-1">Search</span>
</Button>
</>
)}
{showScanButtons && (
<>
{onQuickScan && (
<Button
variant="default"
size="sm"
className="btn-premium-primary"
onClick={onQuickScan}
disabled={false}
>
<svg className="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 4v6h-6"></path>
<path d="M1 20v-6h6"></path>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
<span className="hidden lg:inline ml-1">Quick Scan</span>
</Button>
)}
<Link href="/scan">
<Button variant="ghost" size="sm" className="header-control" aria-label="Full Scan">
<svg className="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span className="hidden lg:inline ml-1">Full Scan</span>
</Button>
</Link>
</>
)}
<ThemeToggle />
<Link href="/settings">
<Button variant="ghost" size="icon" className="header-control" aria-label="Settings">
<svg className="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06-.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l-.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06-.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0-1.51 1H3a2 2 0 0 1-2 2 2 2 0 0 1-2-2V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09z"></path>
</svg>
</Button>
</Link>
<Link href="/analytics" className="header-control p-1.5">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center border border-primary/20">
<BarChart2 className="h-5 w-5 text-primary" />
</div>
</Link>
{coffeeUrl ? (
<Link href={coffeeUrl} target="_blank" rel="noreferrer noopener" className="header-control p-1.5">
<img src="/ko-fi-icon.gif" alt="Support on Ko-fi" className="h-8 w-8 object-contain" />
</Link>
) : null}
</div>
{/* Mobile Menu Button */}
<div className="md:hidden">
<Button
variant="ghost"
size="icon"
className="header-control"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Menu"
>
{mobileMenuOpen ? (
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
) : (
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="4" x2="20" y1="12" y2="12"></line>
<line x1="4" x2="20" y1="6" y2="6"></line>
<line x1="4" x2="20" y1="18" y2="18"></line>
</svg>
)}
</Button>
</div>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="md:hidden mt-4 pb-4 border-t border-white/5 pt-4">
<div className="flex flex-col gap-2">
{showSearchFilter && (
<>
<Button
variant="ghost"
className="justify-start w-full"
onClick={() => {
onSearchClick?.()
setMobileMenuOpen(false)
}}
>
<Search className="h-4 w-4 mr-2" />
Search
</Button>
</>
)}
{showScanButtons && (
<>
{onQuickScan && (
<Button
variant="default"
className="justify-start w-full btn-premium-primary"
onClick={() => {
onQuickScan()
setMobileMenuOpen(false)
}}
>
<svg className="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 4v6h-6"></path>
<path d="M1 20v-6h6"></path>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
Quick Scan
</Button>
)}
<Link href="/scan" className="w-full" onClick={() => setMobileMenuOpen(false)}>
<Button variant="ghost" className="justify-start w-full">
<svg className="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Full Scan
</Button>
</Link>
</>
)}
<Link href="/settings" className="w-full" onClick={() => setMobileMenuOpen(false)}>
<Button variant="ghost" className="justify-start w-full">
<Settings className="h-4 w-4 mr-2" />
Settings
</Button>
</Link>
<Link href="/analytics" className="w-full" onClick={() => setMobileMenuOpen(false)}>
<Button variant="ghost" className="justify-start w-full">
<BarChart2 className="h-4 w-4 mr-2" />
Analytics
</Button>
</Link>
</div>
</div>
)}
</div>
</header>
)
}
+211
View File
@@ -0,0 +1,211 @@
'use client'
import * as React from 'react'
import { BookOpen, Play, ArrowRight, CheckCircle, Download, Monitor, Shield, Database, Zap, Search, Filter } from 'lucide-react'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
export function HeroBanner() {
return (
<section className="relative overflow-hidden py-16 lg:py-24">
<div className="absolute inset-0 -z-10" aria-hidden="true">
<div className="absolute inset-0 opacity-30" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60' viewBox='0 0 60 60'%3E%3Cdefs%3E%3Cpattern id='hex' patternUnits='userSpaceOnUse' width='30' height='52'%3E%3Cpath d='M15 0 L30 10 L30 42 L15 52 L0 42 L0 10 Z' fill='none' stroke='%2310b981' stroke-width='0.5' stroke-opacity='0.15'/%3E%3C/pattern%3E%3C/defs%3E%3Crect width='100%25' height='100%25' fill='url(%23hex)'/%3E%3C/svg%3E")`,
backgroundSize: '60px 60px',
}} />
<div className="absolute top-0 right-0 w-96 h-96 bg-primary/10 rounded-full blur-3xl translate-x-1/2 -translate-y-1/2" />
<div className="absolute bottom-0 left-0 w-72 h-72 bg-accent/10 rounded-full blur-3xl -translate-x-1/2 translate-y-1/2" />
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-background/80" />
</div>
<div className="container mx-auto px-4 relative z-10">
<div className="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">
<div className="text-center lg:text-left">
<div className="inline-flex items-center justify-center mb-6">
<div className="relative w-24 h-24 lg:w-28 lg:h-28">
<div className="absolute inset-0 rounded-full bg-primary/20 blur-xl animate-pulse" />
<div className="relative w-full h-full rounded-2xl bg-background-elevated/80 backdrop-blur-xl border border-primary/20 flex items-center justify-center">
<div className="absolute inset-0 rounded-2xl opacity-10" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Cpath d='M10 0 L20 6.67 L20 26.67 L10 33.33 L0 26.67 L0 6.67 Z' fill='none' stroke='%2310b981' stroke-width='0.5'/%3E%3C/svg%3E")`,
backgroundSize: '20px 20px',
}} />
<svg className="w-14 h-14 lg:w-16 lg:h-16 text-primary" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bookHeroGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#5eead4"/>
<stop offset="100%" stop-color="#0d9488"/>
</linearGradient>
<linearGradient id="playHeroGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#fde047"/>
<stop offset="100%" stop-color="#f59e0b"/>
</linearGradient>
<filter id="heroGlow"><feGaussianBlur stdDeviation="2" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
</defs>
<path d="M25 20 L42 20 L42 80 L25 80 Z" stroke="url(#bookHeroGrad)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M58 20 L75 20 L75 80 L58 80 Z" stroke="url(#bookHeroGrad)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M25 20 Q50 12 75 20" stroke="url(#bookHeroGrad)" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<path d="M25 80 Q50 88 75 80" stroke="url(#bookHeroGrad)" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<path d="M50 80 L50 85" stroke="url(#bookHeroGrad)" stroke-width="2" stroke-linecap="round" fill="none"/>
<polygon points="43,38 43,62 63,50" fill="url(#playHeroGrad)" filter="url(#heroGlow)"/>
<polygon points="45,40 45,48 53,44" fill="#fde68a" opacity="0.5"/>
</svg>
</div>
</div>
</div>
<h1 className="text-4xl lg:text-6xl font-bold tracking-tight mb-4 bg-gradient-to-r from-white via-primary to-accent bg-clip-text text-transparent animate-fade-in">
OfflineAcademy
</h1>
<p className="text-lg lg:text-xl text-muted-foreground max-w-lg mx-auto lg:mx-0 mb-8 animate-slide-up">
Your Personal Offline Learning Center
</p>
<div className="flex flex-wrap items-center justify-center lg:justify-start gap-3 mb-8 animate-slide-up" style={{ animationDelay: '100ms' }}>
<span className="px-3 py-1.5 rounded-full border border-primary/30 bg-primary/10 text-primary text-sm font-medium flex items-center gap-1.5">
<Shield className="h-3.5 w-3.5" /> Privacy First
</span>
<span className="px-3 py-1.5 rounded-full border border-accent/30 bg-accent/10 text-accent text-sm font-medium flex items-center gap-1.5">
<Download className="h-3.5 w-3.5" /> Fully Offline
</span>
<span className="px-3 py-1.5 rounded-full border border-green-500/30 bg-green-500/10 text-green-500 text-sm font-medium flex items-center gap-1.5">
<Database className="h-3.5 w-3.5" /> Self-Hosted
</span>
</div>
<div className="flex flex-col sm:flex-row items-center justify-center lg:justify-start gap-4 animate-slide-up" style={{ animationDelay: '200ms' }}>
<Link href="/scan">
<Button size="lg" className="btn-premium-primary gap-2 px-8 py-3 text-base" style={{ minWidth: '180px' }}>
<Play className="h-5 w-5" /> Start Learning
</Button>
</Link>
<Link href="/settings">
<Button size="lg" variant="ghost" className="gap-2 px-8 py-3 text-base border-primary/30 hover:bg-primary/10" style={{ minWidth: '180px' }}>
<Monitor className="h-5 w-5" /> View Demo
</Button>
</Link>
</div>
<div className="mt-10 flex flex-wrap items-center justify-center lg:justify-start gap-6 text-xs text-muted-foreground animate-slide-up" style={{ animationDelay: '300ms' }}>
<div className="flex items-center gap-1.5"><CheckCircle className="h-3.5 w-3.5 text-primary" /><span>No tracking</span></div>
<div className="flex items-center gap-1.5"><CheckCircle className="h-3.5 w-3.5 text-primary" /><span>No accounts required</span></div>
<div className="flex items-center gap-1.5"><CheckCircle className="h-3.5 w-3.5 text-primary" /><span>Works offline</span></div>
</div>
</div>
<div className="relative animate-fade-in" style={{ animationDelay: '200ms' }}>
<div className="absolute -top-4 -right-4 w-64 h-64 bg-gradient-to-br from-primary/10 to-transparent rounded-full blur-2xl pointer-events-none" />
<div className="relative glass-card-elevated rounded-2xl overflow-hidden border-primary/20 max-w-lg mx-auto lg:mx-0">
<div className="flex items-center gap-2 px-4 py-3 bg-secondary/50 border-b border-secondary-border">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-500/80" />
<div className="w-3 h-3 rounded-full bg-yellow-500/80" />
<div className="w-3 h-3 rounded-full bg-green-500/80" />
</div>
<div className="flex-1 text-center text-xs text-muted-foreground font-mono">OfflineAcademy - Dashboard</div>
<div className="w-3 h-3 rounded-full bg-transparent" />
</div>
<div className="p-4 lg:p-6 space-y-6" style={{ minHeight: '420px' }}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-primary/15 flex items-center justify-center">
<BookOpen className="h-4 w-4 text-primary" />
</div>
<span className="font-semibold text-sm">OfflineAcademy</span>
</div>
<div className="flex items-center gap-2">
<div className="relative hidden sm:block">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input type="text" placeholder="Search courses..." className="w-48 pl-8 pr-3 py-1.5 bg-secondary border border-secondary-border rounded-lg text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50" readOnly />
</div>
<Button variant="ghost" size="sm" className="header-control"><Zap className="h-3.5 w-3.5" /><span className="hidden sm:inline">New</span></Button>
<Button variant="ghost" size="sm" className="header-control"><Download className="h-3.5 w-3.5" /><span className="hidden sm:inline">Add</span></Button>
<Button variant="ghost" size="sm" className="header-control"><ArrowRight className="h-3.5 w-3.5" /><span className="hidden sm:inline">Sort</span></Button>
<Button variant="ghost" size="sm" className="header-control"><Filter className="h-3.5 w-3.5" /><span className="hidden sm:inline">Filter</span></Button>
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center border border-primary/20 ml-1">
<Monitor className="h-4 w-4 text-primary" />
</div>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<Play className="h-4 w-4 text-primary" /> Continue Watching
</h3>
<div className="grid grid-cols-3 gap-3">
{[
{ title: 'Tutorial: JavaScript', duration: '1h 23m', bg: 'bg-gradient-to-br from-primary/20 to-primary/5' },
{ title: '1. Vercel is awesome', duration: '1h 15m', bg: 'bg-gradient-to-br from-gray-100/10 to-gray-200/5' },
{ title: '4. Automation Platforms', duration: '1h 30m', bg: 'bg-gradient-to-br from-primary/10 to-blue-500/10' },
].map((item, i) => (
<div key={i} className="group relative rounded-lg overflow-hidden">
<div className={`aspect-video ${item.bg} relative overflow-hidden`}>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-10 h-10 rounded-full bg-primary/90 flex items-center justify-center text-primary-foreground group-hover:scale-110 transition-transform opacity-0 group-hover:opacity-100">
<Play className="h-5 w-5 ml-1" />
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary/50" style={{ width: `${30 + i * 20}%` }} />
</div>
<p className="text-xs text-foreground mt-1.5 line-clamp-1">{item.title}</p>
<p className="text-xs text-muted-foreground">{item.duration}</p>
</div>
))}
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<BookOpen className="h-4 w-4 text-accent" /> Your Library
</h3>
<div className="grid grid-cols-2 gap-3">
{[
{ title: '[TutorialsHub] - Network Config', bg: 'bg-gradient-to-br from-blue-600/30 to-purple-600/20' },
{ title: 'FreeTutorials101: Linux Shell', bg: 'bg-gradient-to-br from-green-500/30 to-teal-500/20' },
{ title: 'FreeCoursesOnline: Code With Me', bg: 'bg-gradient-to-br from-purple-600/30 to-pink-500/20' },
{ title: 'FreeCoursesLab: Academy Lab', bg: 'bg-gradient-to-br from-teal-500/30 to-cyan-500/20' },
].map((item, i) => (
<div key={i} className="group relative rounded-lg overflow-hidden">
<div className={`aspect-video ${item.bg} relative overflow-hidden transition-all duration-300 group-hover:scale-[1.02]`}>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
<div className="absolute bottom-3 left-3 right-3 text-white text-xs font-medium line-clamp-2">{item.title}</div>
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="w-8 h-8 rounded-full bg-primary/90 flex items-center justify-center">
<Play className="h-4 w-4 ml-1 text-primary-foreground" />
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="absolute -bottom-6 -left-4 w-48 glass-card p-3 animate-float">
<div className="flex items-center gap-2 text-xs">
<div className="w-7 h-7 rounded-lg bg-primary/15 flex items-center justify-center">
<Shield className="h-3.5 w-3.5 text-primary" />
</div>
<div><p className="font-medium text-foreground">Secure</p><p className="text-muted-foreground">Local only</p></div>
</div>
</div>
<div className="absolute -bottom-6 right-4 w-48 glass-card p-3 animate-float" style={{ animationDelay: '1.5s' }}>
<div className="flex items-center gap-2 text-xs">
<div className="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center">
<Download className="h-3.5 w-3.5 text-accent" />
</div>
<div><p className="font-medium text-foreground">Offline</p><p className="text-muted-foreground">No internet needed</p></div>
</div>
</div>
</div>
</div>
</div>
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
<ArrowRight className="h-6 w-6 text-primary/50 rotate-90" />
</div>
</section>
)
}
+262
View File
@@ -0,0 +1,262 @@
'use client'
import * as React from 'react'
import { Download, Smartphone, Monitor, CheckCircle, XCircle, AlertCircle, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
export function InstallButton({
variant = 'ghost',
size = 'icon',
className = '',
...props
}: { variant?: 'default' | 'ghost' | 'outline'; size?: 'sm' | 'icon'; className?: string }) {
const [showInstructions, setShowInstructions] = React.useState(false)
const [platform, setPlatform] = React.useState<'android' | 'ios' | 'desktop' | 'unknown'>('unknown')
const [swDiagnostic, setSwDiagnostic] = React.useState<{
controlling: boolean
swVersion?: string
error?: string
} | null>(null)
const [checkingSW, setCheckingSW] = React.useState(false)
React.useEffect(() => {
const ua = navigator.userAgent
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
const isInWebAppiOS = (window.navigator as any).standalone === true
if (isStandalone || isInWebAppiOS) return
if (/iPad|iPhone|iPod/.test(ua)) setPlatform('ios')
else if (/Android/.test(ua)) setPlatform('android')
else setPlatform('desktop')
}, [])
const checkSW = async () => {
setCheckingSW(true)
try {
if (!('serviceWorker' in navigator)) {
setSwDiagnostic({ controlling: false, error: 'Service Workers not supported' })
return
}
// First, unregister all old SWs to force fresh registration
const regs = await navigator.serviceWorker.getRegistrations()
for (const reg of regs) {
if (reg.active?.scriptURL.includes('sw.js') && !reg.active.scriptURL.includes('v=3')) {
await reg.unregister()
console.log('Unregistered old SW:', reg.scope)
}
}
// Register fresh
const reg = await navigator.serviceWorker.register('/sw.js?v=3', { updateViaCache: 'none' })
await reg.update()
const controlling = !!navigator.serviceWorker.controller
let swVersion = 'unknown'
try {
const res = await fetch('/sw.js?v=3')
const text = await res.text()
const match = text.match(/version\s+(\d+)/i) || text.match(/v(\d+)/i)
if (match) swVersion = match[1]
} catch {}
setSwDiagnostic({ controlling, swVersion })
} catch (err) {
setSwDiagnostic({ controlling: false, error: String(err) })
} finally {
setCheckingSW(false)
}
}
React.useEffect(() => {
checkSW()
}, [])
const handleClick = () => setShowInstructions(true)
const isInstalled = window.matchMedia('(display-mode: standalone)').matches || (window.navigator as any).standalone === true
if (isInstalled) {
return (
<Button variant="ghost" size="icon" className={className} disabled {...props}>
<Download className="h-4 w-4 opacity-50" />
</Button>
)
}
return (
<div className="relative">
<div className="flex items-center gap-2">
<Button
variant={variant}
size={size}
className={className}
onClick={handleClick}
aria-label="Install OfflineAcademy"
{...props}
>
<Download className="h-4 w-4" />
</Button>
{/* SW Status Indicator */}
<Button
variant="ghost"
size="icon"
className="header-control"
onClick={checkSW}
disabled={checkingSW}
aria-label="Check Service Worker status"
>
{checkingSW ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : swDiagnostic?.controlling ? (
<CheckCircle className="h-4 w-4 text-green-400" />
) : (
<AlertCircle className="h-4 w-4 text-amber-400" />
)}
</Button>
</div>
{/* Diagnostic Tooltip */}
{swDiagnostic && (
<div className="absolute top-full right-0 mt-2 z-50 w-80 glass-card-elevated rounded-lg p-3 shadow-xl border border-white/10 animate-in slide-in-from-top-2">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">SW Diagnostic</h4>
<Button variant="ghost" size="icon" onClick={() => setSwDiagnostic(null)}>
<XCircle className="h-4 w-4" />
</Button>
</div>
<div className="space-y-1 text-xs">
<div className="flex items-center gap-2 text-green-400">
<CheckCircle className="h-3 w-3" />
<span>Service Worker: <strong>Registered</strong></span>
</div>
<div className={`flex items-center gap-2 ${swDiagnostic.controlling ? 'text-green-400' : 'text-red-400'}`}>
{swDiagnostic.controlling ? <CheckCircle className="h-3 w-3" /> : <XCircle className="h-3 w-3" />}
<span>Controlling page: <strong>{swDiagnostic.controlling ? 'YES ✓' : 'NO ✗'}</strong></span>
</div>
{swDiagnostic.swVersion && (
<div className="text-muted-foreground">Version: {swDiagnostic.swVersion}</div>
)}
{swDiagnostic.error && (
<div className="text-red-400">Error: {swDiagnostic.error}</div>
)}
{!swDiagnostic.controlling && (
<div className="text-amber-400 mt-2 text-xs">
If controlling=NO: refresh page, or check Brave Shields (lion icon OFF)
</div>
)}
</div>
</div>
)}
{showInstructions && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={() => setShowInstructions(false)} />
<div className="relative w-full max-w-sm glass-card-elevated rounded-xl p-6 shadow-2xl border border-white/10 animate-in zoom-in-95">
<div className="flex items-start justify-between mb-4">
<h3 className="font-semibold text-lg">Install OfflineAcademy</h3>
<Button variant="ghost" size="icon" onClick={() => setShowInstructions(false)}>
<XCircle className="h-4 w-4" />
</Button>
</div>
{platform === 'android' && (
<div className="space-y-4 text-sm">
<p className="text-muted-foreground">
On <strong>Android (Chrome/Brave/Edge)</strong>, use the browser menu:
</p>
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded bg-primary/20 flex items-center justify-center">
<Monitor className="h-4 w-4 text-primary" />
</div>
<div>
<p className="font-medium">Chrome / Edge</p>
<p className="text-xs text-muted-foreground">Menu ( top-right) <strong>"Install app"</strong> or <strong>"Add to Home screen"</strong></p>
</div>
</div>
<div className="flex items-center gap-3 border-t border-white/10 pt-3">
<div className="w-8 h-8 rounded bg-orange-500/20 flex items-center justify-center">
<Smartphone className="h-4 w-4 text-orange-500" />
</div>
<div>
<p className="font-medium">Brave Browser</p>
<p className="text-xs text-muted-foreground">Menu ( bottom-right) <strong>"Install app"</strong><br/>
<span className="text-xs text-amber-400">If missing: Shields OFF (lion icon) reload</span>
</p>
</div>
</div>
</div>
</div>
)}
{platform === 'ios' && (
<div className="space-y-4 text-sm">
<p className="text-muted-foreground">
On <strong>iOS (Safari)</strong>, use the Share button:
</p>
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded bg-primary/20 flex items-center justify-center">
<Smartphone className="h-4 w-4 text-primary" />
</div>
<ol className="space-y-1 text-xs">
<li>Tap <strong>Share button</strong> (square with arrow up) at bottom</li>
<li>Scroll down, tap <strong>"Add to Home Screen"</strong></li>
<li>Tap <strong>"Add"</strong> top-right</li>
</ol>
</div>
</div>
</div>
)}
{platform === 'desktop' && (
<div className="space-y-4 text-sm">
<p className="text-muted-foreground">
On <strong>Desktop (Chrome/Edge)</strong>:
</p>
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded bg-primary/20 flex items-center justify-center">
<Monitor className="h-4 w-4 text-primary" />
</div>
<div>
<p className="font-medium">Chrome</p>
<p className="text-xs text-muted-foreground">Menu ( top-right) <strong>"Install OfflineAcademy"</strong> or <strong>"Install app"</strong></p>
</div>
</div>
<div className="flex items-center gap-3 border-t border-white/10 pt-3">
<div className="w-8 h-8 rounded bg-blue-500/20 flex items-center justify-center">
<Monitor className="h-4 w-4 text-blue-500" />
</div>
<div>
<p className="font-medium">Edge</p>
<p className="text-xs text-muted-foreground">Menu ( top-right) <strong>"Apps"</strong> <strong>"Install this site as an app"</strong></p>
</div>
</div>
<div className="text-xs text-amber-400">
Creates Start Menu shortcut (Windows) / Applications folder (Mac)
</div>
</div>
</div>
)}
{platform === 'unknown' && (
<p className="text-sm text-muted-foreground text-center">
Open in Chrome/Edge/Safari/Brave for install options
</p>
)}
<Button className="w-full mt-4" onClick={() => setShowInstructions(false)}>
Got it
</Button>
</div>
</div>
)}
</div>
)
}
+132
View File
@@ -0,0 +1,132 @@
'use client'
import * as React from 'react'
import { X, Download, Smartphone } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface InstallPromptProps {
onDismiss: () => void
onInstall: () => void
}
export function InstallPrompt({ onDismiss, onInstall }: InstallPromptProps) {
const [deferredPrompt, setDeferredPrompt] = React.useState<BeforeInstallPromptEvent | null>(null)
const [isIOS, setIsIOS] = React.useState(false)
const [showIOSInstructions, setShowIOSInstructions] = React.useState(false)
React.useEffect(() => {
// Check if iOS
const ua = navigator.userAgent
const isIOSDevice = /iPad|iPhone|iPod/.test(ua)
setIsIOS(isIOSDevice)
// Check if already installed
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
const isInWebAppiOS = (window.navigator as any).standalone === true
if (isStandalone || isInWebAppiOS) {
onDismiss()
return
}
// Check if dismissed before
const dismissed = localStorage.getItem('offlineacademy-install-dismissed')
if (dismissed) {
onDismiss()
return
}
// Listen for beforeinstallprompt event (Android/Chrome)
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault()
setDeferredPrompt(e as BeforeInstallPromptEvent)
}
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
}
}, [onDismiss])
const handleInstall = async () => {
if (deferredPrompt) {
deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
if (outcome === 'accepted') {
localStorage.setItem('offlineacademy-install-dismissed', 'true')
onDismiss()
}
setDeferredPrompt(null)
} else {
// iOS - show instructions
setShowIOSInstructions(true)
}
}
const handleDismiss = () => {
localStorage.setItem('offlineacademy-install-dismissed', 'true')
onDismiss()
}
if (showIOSInstructions) {
return (
<div className="fixed bottom-4 left-4 right-4 sm:bottom-6 sm:left-6 sm:right-6 sm:max-w-md z-50 animate-slide-up">
<div className="glass-card-elevated rounded-xl p-4 shadow-xl border border-white/10">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary/20 flex items-center justify-center">
<Smartphone className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg">Install on iOS</h3>
<p className="text-sm text-muted-foreground mt-1">
Tap the <strong>Share button</strong> (square with arrow up) in Safari,
then scroll down and tap <strong>"Add to Home Screen"</strong>.
</p>
<div className="mt-3 flex gap-2">
<Button variant="outline" size="sm" onClick={() => setShowIOSInstructions(false)}>
Got it
</Button>
<Button variant="ghost" size="sm" onClick={handleDismiss}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
</div>
)
}
return (
<div className="fixed bottom-4 left-4 right-4 sm:bottom-6 sm:left-6 sm:right-6 sm:max-w-md z-50 animate-slide-up">
<div className="glass-card-elevated rounded-xl p-4 shadow-xl border border-white/10">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary/20 flex items-center justify-center">
<Download className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg">Install OfflineAcademy</h3>
<p className="text-sm text-muted-foreground mt-1">
Add to home screen for offline access, faster loads, and app-like experience.
</p>
<div className="mt-3 flex gap-2">
<Button size="sm" onClick={handleInstall} className="gap-1">
<Download className="h-4 w-4" />
Install
</Button>
<Button variant="ghost" size="sm" onClick={handleDismiss}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
</div>
)
}
// Type for beforeinstallprompt event
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
+75
View File
@@ -0,0 +1,75 @@
'use client'
import * as React from 'react'
import { createPortal } from 'react-dom'
import { X, Keyboard, MousePointer, Monitor, HelpCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ShortcutItem {
key: string
description: string
}
const shortcuts: ShortcutItem[] = [
{ key: 'Space / K', description: 'Play / Pause' },
{ key: 'J / ←', description: 'Rewind 10s' },
{ key: 'L / →', description: 'Forward 30s' },
{ key: '↑ / ↓', description: 'Volume Up / Down' },
{ key: 'M', description: 'Mute / Unmute' },
{ key: 'F', description: 'Toggle Fullscreen' },
{ key: ', / [', description: 'Decrease speed' },
{ key: '. / ]', description: 'Increase speed' },
{ key: '0-9', description: 'Seek to 0%-90%' },
{ key: 'P / I', description: 'Toggle Picture-in-Picture' },
{ key: 'Esc', description: 'Close overlay / exit fullscreen / exit PiP' },
{ key: '?', description: 'Show / Hide This Overlay' },
]
export function KeyboardShortcutsOverlay({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
if (!isOpen) return null
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" onClick={onClose}>
<div
className="bg-card border rounded-xl shadow-2xl max-w-md w-full p-6 animate-in fade-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Keyboard className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold">Keyboard Shortcuts</h2>
</div>
<button
onClick={onClose}
className="p-1 rounded-lg hover:bg-accent transition-colors"
aria-label="Close shortcuts"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-3 max-h-64 overflow-y-auto pr-2">
{shortcuts.map((item, index) => (
<div key={index} className="flex items-center gap-3 p-2 rounded-lg hover:bg-accent/50 transition-colors">
<kbd className="flex items-center gap-1 px-2.5 py-1 bg-muted border rounded text-sm font-mono text-muted-foreground min-w-[90px] text-center">
{item.key.split(' / ').map((k, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="text-xs text-muted-foreground/50">/</span>}
<span>{k.trim()}</span>
</React.Fragment>
))}
</kbd>
<span className="text-sm flex-1 text-foreground">{item.description}</span>
</div>
))}
</div>
<div className="mt-4 pt-4 border-t flex items-center gap-2 text-xs text-muted-foreground">
<HelpCircle className="h-4 w-4" />
<span>Press <kbd className="px-1.5 py-0.5 bg-muted rounded">?</kbd> anywhere to toggle</span>
</div>
</div>
</div>,
document.body
)
}
+251
View File
@@ -0,0 +1,251 @@
'use client'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Bookmark, BookmarkPlus, Clock, Trash2 } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { formatTime } from '@/lib/utils'
export type LessonBookmark = {
id: string
lessonId: string
courseId: string
moduleId: string
position: number
note: string
createdAt: string
updatedAt: string
lesson: {
id: string
title: string
slug: string
}
}
interface LessonBookmarksProps {
lessonId: string
courseId: string
moduleId: string
currentTime: number
onJumpToTime: (time: number) => void
isVideo?: boolean
}
function formatSavedAt(isoDate: string) {
const date = new Date(isoDate)
return date.toLocaleString([], {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
export function LessonBookmarks({
lessonId,
courseId,
moduleId,
currentTime,
onJumpToTime,
isVideo = true,
}: LessonBookmarksProps) {
const [bookmarks, setBookmarks] = useState<LessonBookmark[]>([])
const [note, setNote] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const sortedBookmarks = useMemo(
() => [...bookmarks].sort((a, b) => a.position - b.position || a.createdAt.localeCompare(b.createdAt)),
[bookmarks]
)
const fetchBookmarks = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await fetch('/api/bookmarks?lessonId=' + lessonId)
const data = await response.json()
if (!response.ok) {
throw new Error(data?.error || 'Failed to fetch bookmarks')
}
setBookmarks(data.items || [])
} catch (fetchError) {
console.error('Failed to fetch bookmarks:', fetchError)
setError('Could not load bookmarks. The notebook page got smudged.')
} finally {
setLoading(false)
}
}, [lessonId])
useEffect(() => {
fetchBookmarks()
}, [fetchBookmarks])
const handleSave = async () => {
if (!isVideo) return
setSaving(true)
setError(null)
try {
const response = await fetch('/api/bookmarks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lessonId,
courseId,
moduleId,
position: Math.max(0, Math.floor(currentTime)),
note,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data?.error || 'Failed to save bookmark')
}
setBookmarks(prev => [data.bookmark, ...prev])
setNote('')
} catch (saveError) {
console.error('Failed to create bookmark:', saveError)
setError('Could not save bookmark. The pen ran out of cosmic ink.')
} finally {
setSaving(false)
}
}
const handleDelete = async (bookmarkId: string) => {
setDeletingId(bookmarkId)
setError(null)
try {
const response = await fetch('/api/bookmarks/' + bookmarkId, { method: 'DELETE' })
const data = await response.json()
if (!response.ok) {
throw new Error(data?.error || 'Failed to delete bookmark')
}
setBookmarks(prev => prev.filter(bookmark => bookmark.id !== bookmarkId))
} catch (deleteError) {
console.error('Failed to delete bookmark:', deleteError)
setError('Could not delete bookmark. The eraser bounced off the desk.')
} finally {
setDeletingId(null)
}
}
return (
<Card className="border-white/10 bg-card/70 backdrop-blur-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Bookmark className="h-4 w-4 text-primary" />
Lesson Bookmarks
</CardTitle>
<CardDescription>
Save a timestamp and note for this lesson so you can jump back to the exact spot later.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!isVideo && (
<div className="rounded-lg border border-dashed border-white/10 bg-white/5 px-3 py-2 text-sm text-muted-foreground">
Time-based bookmarks are available on video lessons.
</div>
)}
{isVideo && (
<div className="space-y-3 rounded-lg border border-white/10 bg-white/5 p-3">
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<Clock className="h-4 w-4" />
Current time: {formatTime(Math.max(0, Math.floor(currentTime)))}
</span>
<Button type="button" size="sm" className="gap-2" onClick={handleSave} disabled={saving}>
<BookmarkPlus className="h-4 w-4" />
{saving ? 'Saving...' : 'Save bookmark'}
</Button>
</div>
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Add a note about why this timestamp matters..."
className="min-h-[92px] resize-none bg-background/80"
maxLength={1000}
/>
<p className="text-xs text-muted-foreground">
Tip: keep it short and searchable a command name, bug, or concept works great.
</p>
</div>
)}
{error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">Saved bookmarks</h4>
<span className="text-xs text-muted-foreground">{sortedBookmarks.length} total</span>
</div>
{loading ? (
<div className="rounded-lg border border-white/10 bg-white/5 px-3 py-4 text-sm text-muted-foreground">
Loading bookmarks...
</div>
) : sortedBookmarks.length === 0 ? (
<div className="rounded-lg border border-dashed border-white/10 bg-white/5 px-3 py-4 text-sm text-muted-foreground">
No bookmarks yet. Save your first aha! moment.
</div>
) : (
<div className="space-y-2">
{sortedBookmarks.map(bookmark => (
<div key={bookmark.id} className="rounded-lg border border-white/10 bg-background/60 p-3">
<div className="flex items-start gap-3">
<button
type="button"
onClick={() => onJumpToTime(bookmark.position)}
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
aria-label={`Jump to ${formatTime(bookmark.position)}`}
>
<Bookmark className="h-3.5 w-3.5" />
{formatTime(bookmark.position)}
</button>
<div className="min-w-0 flex-1">
<p className="text-sm text-foreground">
{bookmark.note || 'No note added'}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Saved {formatSavedAt(bookmark.createdAt)}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(bookmark.id)}
disabled={deletingId === bookmark.id}
aria-label="Delete bookmark"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
)
}
+361
View File
@@ -0,0 +1,361 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from '@/components/ui/accordion'
import { Button } from '@/components/ui/button'
import {
ChevronRight,
Play,
CheckCircle,
CheckCircle2,
Circle,
Clock,
FileText,
Film,
Image as ImageIcon,
Loader2,
Music,
HelpCircle,
} from 'lucide-react'
import { cn, formatDuration, formatTime } from '@/lib/utils'
interface ModuleAccordionProps {
courseId: string
modules: Array<{
id: string
name: string
slug: string
order: number
lessons: Array<{
id: string
title: string
slug: string
order: number
type: string
duration: number | null
moduleId: string
progress?: {
completed: boolean
position: number
lastWatched: Date | string | null
} | null
}>
}>
currentLessonId?: string
courseSlug: string
defaultOpenModuleId?: string | null
}
const lessonTypeIcons: Record<string, React.ReactNode> = {
VIDEO: <Film className="h-4 w-4 text-red-400" />,
AUDIO: <Music className="h-4 w-4 text-purple-400" />,
PDF: <FileText className="h-4 w-4 text-red-500" />,
MARKDOWN: <FileText className="h-4 w-4 text-blue-400" />,
HTML: <FileText className="h-4 w-4 text-orange-400" />,
IMAGE: <ImageIcon className="h-4 w-4 text-green-400" />,
QUIZ: <HelpCircle className="h-4 w-4 text-amber-400" />,
OTHER: <FileText className="h-4 w-4 text-muted-foreground" />,
}
async function saveProgress(payload: {
lessonId: string
courseId: string
moduleId: string
position: number
completed: boolean
}) {
const response = await fetch('/api/progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error('Failed to save progress')
}
}
export function ModuleAccordion({ modules, currentLessonId, courseSlug, courseId, defaultOpenModuleId }: ModuleAccordionProps) {
const router = useRouter()
const [savingLessonIds, setSavingLessonIds] = React.useState<Record<string, boolean>>({})
const [savingModuleIds, setSavingModuleIds] = React.useState<Record<string, boolean>>({})
const currentModuleId = React.useMemo(() => {
if (defaultOpenModuleId) return defaultOpenModuleId
if (!currentLessonId) return undefined
for (const module of modules) {
if (module.lessons.some(lesson => lesson.id === currentLessonId)) {
return module.id
}
}
return undefined
}, [currentLessonId, modules, defaultOpenModuleId])
const openModuleId = React.useMemo(() => {
if (!currentModuleId) return undefined
return [currentModuleId]
}, [currentModuleId])
const toggleLessonDone = React.useCallback(async (
lesson: {
id: string
moduleId: string
duration: number | null
progress?: { completed: boolean; position: number } | null
}
) => {
const wasCompleted = Boolean(lesson.progress?.completed)
const nextCompleted = !wasCompleted
const position = nextCompleted
? Math.max(lesson.progress?.position || 0, lesson.duration || 0)
: (lesson.progress?.position || 0)
setSavingLessonIds((prev) => ({ ...prev, [lesson.id]: true }))
try {
await saveProgress({
lessonId: lesson.id,
courseId,
moduleId: lesson.moduleId,
position,
completed: nextCompleted,
})
router.refresh()
} catch (error) {
console.error('Failed to toggle lesson done:', error)
} finally {
setSavingLessonIds((prev) => {
const next = { ...prev }
delete next[lesson.id]
return next
})
}
}, [courseId, router])
const toggleModuleDone = React.useCallback(async (
module: {
id: string
lessons: Array<{
id: string
moduleId: string
duration: number | null
progress?: { completed: boolean; position: number } | null
}>
}
) => {
const completedCount = module.lessons.filter((lesson) => lesson.progress?.completed).length
const allCompleted = completedCount === module.lessons.length && module.lessons.length > 0
const nextCompleted = !allCompleted
setSavingModuleIds((prev) => ({ ...prev, [module.id]: true }))
try {
for (const lesson of module.lessons) {
const position = nextCompleted
? Math.max(lesson.progress?.position || 0, lesson.duration || 0)
: (lesson.progress?.position || 0)
await saveProgress({
lessonId: lesson.id,
courseId,
moduleId: lesson.moduleId,
position,
completed: nextCompleted,
})
}
router.refresh()
} catch (error) {
console.error('Failed to toggle module done:', error)
} finally {
setSavingModuleIds((prev) => {
const next = { ...prev }
delete next[module.id]
return next
})
}
}, [courseId, router])
const [openModules, setOpenModules] = React.useState<string[] | undefined>(openModuleId)
React.useEffect(() => {
setOpenModules(openModuleId)
}, [openModuleId])
return (
<Accordion type="multiple" className="w-full" value={openModules} onValueChange={setOpenModules}>
{modules.map((module) => {
const completedCount = module.lessons.filter((lesson) => lesson.progress?.completed).length
const allCompleted = completedCount === module.lessons.length && module.lessons.length > 0
const isSavingModule = Boolean(savingModuleIds[module.id])
const isCurrentModule = currentModuleId === module.id
return (
<AccordionItem key={module.id} value={module.id}>
<AccordionTrigger className="py-3 text-left font-medium hover:bg-accent rounded-lg px-2">
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground font-mono">
{String(module.order + 1).padStart(2, '0')}
</span>
<span className="flex-1">{module.name}</span>
{isCurrentModule ? (
<span className="rounded-full border border-primary/40 bg-primary/10 px-2 py-0.5 text-xs text-primary">
Now Playing
</span>
) : (
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-muted-foreground">
{completedCount}/{module.lessons.length}
</span>
)}
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
</AccordionTrigger>
<AccordionContent className="pt-0 pb-2">
<div className="space-y-3 pl-8 border-l border-border/50 ml-2">
<div className="flex items-center justify-between gap-3 rounded-lg border border-white/5 bg-secondary/30 px-3 py-2">
<div>
<p className="text-sm font-medium">
{completedCount} / {module.lessons.length} lessons done
</p>
<p className="text-xs text-muted-foreground">
{allCompleted ? 'All lessons in this module are marked done.' : 'Mark the whole module done or clear it in one click.'}
</p>
</div>
<Button
type="button"
variant={allCompleted ? 'outline' : 'secondary'}
size="sm"
onClick={() => toggleModuleDone(module)}
disabled={isSavingModule}
className="gap-2"
>
{isSavingModule ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : allCompleted ? (
<Circle className="h-4 w-4" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
{allCompleted ? 'Mark module incomplete' : 'Mark module done'}
</Button>
</div>
{module.lessons.map((lesson) => {
const isCurrent = currentLessonId === lesson.id
const isCompleted = Boolean(lesson.progress?.completed)
const progress = lesson.progress
const hasProgress = progress && progress.position > 0 && !isCompleted
const progressPercent = lesson.duration && progress
? Math.round((progress.position / lesson.duration) * 100)
: 0
const isSavingLesson = Boolean(savingLessonIds[lesson.id])
const watchHref = '/watch/' + lesson.id + '?course=' + courseSlug
return (
<div
key={lesson.id}
className={cn(
'flex items-start gap-2 rounded-lg px-2 py-2 transition-colors',
'hover:bg-accent',
isCurrent && 'bg-primary/10 border border-primary/30',
isCompleted && 'opacity-60'
)}
>
<Link
href={watchHref}
className="flex flex-1 min-w-0 items-start gap-3"
>
<div className="flex items-center gap-2 shrink-0 pt-0.5">
<span className="text-xs text-muted-foreground font-mono w-6 text-right">
{String(lesson.order + 1).padStart(2, '0')}
</span>
{isCompleted ? (
<CheckCircle className="h-4 w-4 text-green-400 shrink-0" />
) : hasProgress ? (
<Clock className="h-4 w-4 text-yellow-400 shrink-0" />
) : (
<Play className="h-4 w-4 text-muted-foreground/50 shrink-0" />
)}
</div>
<div className="flex-1 min-w-0">
<p className={cn(
'font-medium truncate',
isCompleted && 'line-through text-muted-foreground',
isCurrent && 'text-primary'
)}>
{lesson.title}
</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{lessonTypeIcons[lesson.type] || lessonTypeIcons.OTHER}
<span className="capitalize">{lesson.type.toLowerCase()}</span>
{lesson.duration && (
<>
<span></span>
<span>{formatDuration(lesson.duration)}</span>
</>
)}
{hasProgress && lesson.duration && (
<>
<span></span>
<span>{formatTime(progress!.position)} / {formatDuration(lesson.duration)}</span>
</>
)}
</div>
{hasProgress && lesson.duration && (
<div className="h-1 bg-muted rounded-full mt-1 w-32">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: progressPercent + '%' }}
/>
</div>
)}
</div>
</Link>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
'mt-0.5 h-9 w-9 shrink-0 rounded-full',
isCompleted ? 'text-green-400 hover:text-green-300 hover:bg-green-500/10' : 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
onClick={() => toggleLessonDone({
id: lesson.id,
moduleId: lesson.moduleId,
duration: lesson.duration,
progress: lesson.progress ? {
completed: lesson.progress.completed,
position: lesson.progress.position,
} : null,
})}
disabled={isSavingLesson}
aria-label={isCompleted ? 'Mark lesson incomplete' : 'Mark lesson complete'}
title={isCompleted ? 'Mark lesson incomplete' : 'Mark lesson complete'}
>
{isSavingLesson ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isCompleted ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Circle className="h-4 w-4" />
)}
</Button>
</div>
)
})}
</div>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
)
}
+288
View File
@@ -0,0 +1,288 @@
'use client'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { CheckCircle2, Edit3, HelpCircle, RefreshCw, XCircle } from 'lucide-react'
import type { QuizCache } from '@/lib/quiz-types'
interface QuizPlayerProps {
quiz: QuizCache
lessonId: string
courseId: string
moduleId: string
onCompleted?: () => void
}
export function QuizPlayer({ quiz, lessonId, courseId, moduleId, onCompleted }: QuizPlayerProps) {
const router = useRouter()
const [quizData, setQuizData] = useState(quiz)
const [started, setStarted] = useState(false)
const [selectedAnswers, setSelectedAnswers] = useState<Record<string, number | null>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const [topicDraft, setTopicDraft] = useState(quiz.topic)
const [editingTopic, setEditingTopic] = useState(false)
const answeredCount = useMemo(
() => Object.values(selectedAnswers).filter((value) => value !== null && value !== undefined).length,
[selectedAnswers]
)
const score = useMemo(() => {
return quizData.questions.reduce((sum, question) => {
const answer = selectedAnswers[question.id]
return sum + (typeof answer === 'number' && answer === question.answerIndex ? 1 : 0)
}, 0)
}, [quizData.questions, selectedAnswers])
const totalQuestions = quizData.questions.length
const passed = totalQuestions > 0 && score / totalQuestions >= 0.8
const startQuiz = () => setStarted(true)
const chooseAnswer = (questionId: string, answerIndex: number) => {
setSelectedAnswers((prev) => ({ ...prev, [questionId]: answerIndex }))
}
const submitQuiz = async () => {
setIsSubmitting(true)
try {
const response = await fetch('/api/quiz/attempt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lessonId,
courseId,
moduleId,
score,
totalQuestions,
passed,
}),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data?.error || 'Failed to save quiz attempt')
}
setSubmitted(true)
onCompleted?.()
router.refresh()
} catch (error) {
console.error('Quiz submission failed:', error)
} finally {
setIsSubmitting(false)
}
}
const refreshQuiz = async () => {
setIsRefreshing(true)
try {
const response = await fetch('/api/quiz', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lessonId,
topic: topicDraft,
}),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data?.error || 'Failed to fetch quiz')
}
const data = await response.json() as { quiz?: QuizCache }
if (data.quiz) {
setQuizData(data.quiz)
setSelectedAnswers({})
setStarted(false)
setSubmitted(false)
}
setEditingTopic(false)
router.refresh()
} catch (error) {
console.error('Quiz refresh failed:', error)
} finally {
setIsRefreshing(false)
}
}
if (!started) {
return (
<div className="mx-auto w-full max-w-3xl p-4 sm:p-6">
<Card className="border-border/60 bg-card/80 backdrop-blur">
<CardHeader className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="gap-1">
<HelpCircle className="h-3.5 w-3.5" />
Quiz
</Badge>
<Badge variant="outline">{quizData.questions.length} questions</Badge>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
onClick={() => setEditingTopic((value) => !value)}
>
<Edit3 className="h-4 w-4" />
Edit Topic
</Button>
</div>
<CardTitle className="text-2xl">{quizData.title}</CardTitle>
<CardDescription>{quizData.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{editingTopic && (
<div className="flex flex-col gap-3 rounded-lg border border-border/60 bg-muted/30 p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<HelpCircle className="h-4 w-4" />
Override the topic and refresh the quiz cache.
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Input value={topicDraft} onChange={(event) => setTopicDraft(event.target.value)} placeholder="Type a new quiz topic" />
<Button type="button" onClick={refreshQuiz} disabled={isRefreshing} className="gap-2">
<RefreshCw className={isRefreshing ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} />
{isRefreshing ? 'Refreshing...' : 'Fetch Quiz'}
</Button>
</div>
</div>
)}
<div className="grid gap-3 rounded-lg border border-border/60 bg-muted/20 p-4 text-sm text-muted-foreground sm:grid-cols-2">
<div>
<span className="font-medium text-foreground">Source:</span> {quizData.source.toUpperCase()}
</div>
<div>
<span className="font-medium text-foreground">Topic:</span> {quizData.topic}
</div>
<div>
<span className="font-medium text-foreground">Questions:</span> {quizData.questions.length}
</div>
<div>
<span className="font-medium text-foreground">Pass mark:</span> 80%
</div>
</div>
<Button type="button" size="lg" className="w-full sm:w-auto" onClick={startQuiz}>
Start Quiz
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="mx-auto w-full max-w-4xl p-4 sm:p-6 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border/60 bg-card/80 px-4 py-3">
<div>
<p className="text-sm text-muted-foreground">Quiz in progress</p>
<h2 className="text-xl font-semibold">{quizData.title}</h2>
</div>
<div className="flex items-center gap-2 text-sm">
<Badge variant="secondary">Answered {answeredCount}/{totalQuestions}</Badge>
<Badge variant="outline">Source: {quizData.source.toUpperCase()}</Badge>
</div>
</div>
{quizData.questions.map((question, index) => {
const selected = selectedAnswers[question.id]
const answered = typeof selected === 'number'
const isCorrect = submitted && selected === question.answerIndex
const isWrong = submitted && answered && selected !== question.answerIndex
return (
<Card key={question.id} className="border-border/60 bg-card/80">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<Badge variant="outline" className="mb-2">Question {index + 1}</Badge>
<CardTitle className="text-lg leading-snug">{question.prompt}</CardTitle>
</div>
{submitted && isCorrect && <CheckCircle2 className="h-5 w-5 text-green-400 shrink-0" />}
{submitted && isWrong && <XCircle className="h-5 w-5 text-red-400 shrink-0" />}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2">
{question.options.map((option, optionIndex) => {
const active = selected === optionIndex
const revealCorrect = submitted && optionIndex === question.answerIndex
const revealWrong = submitted && active && optionIndex !== question.answerIndex
return (
<button
key={`${question.id}-${optionIndex}`}
type="button"
onClick={() => !submitted && chooseAnswer(question.id, optionIndex)}
className={[
'rounded-lg border px-4 py-3 text-left transition-colors',
active ? 'border-primary bg-primary/10' : 'border-border/60 bg-background hover:bg-muted/40',
revealCorrect ? 'border-green-500/60 bg-green-500/10' : '',
revealWrong ? 'border-red-500/60 bg-red-500/10' : '',
submitted ? 'cursor-default' : '',
].join(' ')}
>
<div className="flex items-center justify-between gap-3">
<span>{option}</span>
{submitted && revealCorrect && <CheckCircle2 className="h-4 w-4 text-green-400" />}
{submitted && revealWrong && <XCircle className="h-4 w-4 text-red-400" />}
</div>
</button>
)
})}
</div>
{submitted && question.explanation && (
<div className="rounded-lg bg-muted/40 p-3 text-sm text-muted-foreground">
{question.explanation}
</div>
)}
</CardContent>
</Card>
)
})}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border/60 bg-card/80 px-4 py-3">
<div className="text-sm text-muted-foreground">
{submitted ? (
<span>
Score: <span className="font-semibold text-foreground">{score}/{totalQuestions}</span> {' '}
<span className={passed ? 'text-green-400 font-semibold' : 'text-red-400 font-semibold'}>
{passed ? 'Passed' : 'Not passed yet'}
</span>
</span>
) : (
<span>Pick an answer for each question, then submit your attempt.</span>
)}
</div>
<div className="flex items-center gap-2">
{!submitted ? (
<Button type="button" onClick={submitQuiz} disabled={isSubmitting || answeredCount < totalQuestions}>
{isSubmitting ? 'Submitting...' : 'Submit Quiz'}
</Button>
) : (
<Button type="button" variant="outline" onClick={() => {
setSubmitted(false)
setStarted(false)
setSelectedAnswers({})
}}>
Retry Quiz
</Button>
)}
</div>
</div>
</div>
)
}
+96
View File
@@ -0,0 +1,96 @@
'use client'
import * as React from 'react'
interface SplashScreenProps {
onComplete: () => void
minDuration?: number
}
export function SplashScreen({ onComplete, minDuration = 1500 }: SplashScreenProps) {
const [visible, setVisible] = React.useState(true)
const [logoScale, setLogoScale] = React.useState(0.8)
const [textOpacity, setTextOpacity] = React.useState(0)
const onCompleteRef = React.useRef(onComplete)
onCompleteRef.current = onComplete
React.useEffect(() => {
let mounted = true
// Animation sequence
const timer = setTimeout(() => {
if (!mounted) return
setLogoScale(1)
setTextOpacity(1)
setTimeout(() => {
if (!mounted) return
setVisible(false)
onCompleteRef.current()
}, 800)
}, minDuration)
return () => {
mounted = false
clearTimeout(timer)
}
}, [minDuration])
if (!visible) return null
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background"
style={{ backgroundColor: 'var(--background)' }}
role="img"
aria-label="OfflineAcademy loading"
>
<div className="flex flex-col items-center gap-6">
{/* Animated logo - using favicon.ico */}
<div
className="relative"
style={{
transform: `scale(${logoScale})`,
transition: 'transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
>
<img
src="/favicon16x16.ico"
alt="OfflineAcademy"
className="w-28 h-28 object-contain"
style={{ imageRendering: 'pixelated' }}
/>
</div>
{/* App name */}
<div
className="text-center"
style={{
opacity: textOpacity,
transition: 'opacity 0.4s ease 0.2s',
}}
>
<h1 className="text-2xl font-bold tracking-tight bg-gradient-to-r from-primary to-emerald-400 bg-clip-text text-transparent">
OfflineAcademy
</h1>
<p className="text-sm text-muted-foreground mt-1">Your Personal Offline Learning Center</p>
</div>
{/* Loading indicator */}
<div
className="w-48 h-1.5 bg-muted rounded-full overflow-hidden"
style={{
opacity: textOpacity,
transition: 'opacity 0.4s ease 0.4s',
}}
>
<div
className="h-full bg-gradient-to-r from-primary via-emerald-400 to-primary rounded-full animate-pulse"
style={{ width: '60%' }}
/>
</div>
</div>
</div>
)
}
+131
View File
@@ -0,0 +1,131 @@
'use client'
import * as React from 'react'
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
import { Sun, Moon, Monitor, ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
type Theme = 'light' | 'dark' | 'system'
interface ThemeContextType {
theme: Theme
setTheme: (theme: Theme) => void
resolvedTheme: 'light' | 'dark'
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('system')
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('dark')
const [mounted, setMounted] = useState(false)
// Initialize from localStorage
useEffect(() => {
const stored = localStorage.getItem('theme') as Theme | null
if (stored) {
setTheme(stored)
}
setMounted(true)
}, [])
// Apply theme to document
useEffect(() => {
if (!mounted) return
const root = document.documentElement
let resolved: 'light' | 'dark'
if (theme === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
} else {
resolved = theme
}
setResolvedTheme(resolved)
root.classList.remove('light', 'dark')
root.classList.add(resolved)
root.style.colorScheme = resolved
localStorage.setItem('theme', theme)
}, [theme, mounted])
// Listen for system theme changes
useEffect(() => {
if (!mounted || theme !== 'system') return
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = () => {
const resolved = mediaQuery.matches ? 'dark' : 'light'
setResolvedTheme(resolved)
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(resolved)
document.documentElement.style.colorScheme = resolved
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [theme, mounted])
const setThemeCallback = useCallback((newTheme: Theme) => {
setTheme(newTheme)
}, [])
if (!mounted) {
return (
<ThemeContext.Provider value={{ theme: 'system', setTheme: () => {}, resolvedTheme: 'dark' }}>
{children}
</ThemeContext.Provider>
)
}
return (
<ThemeContext.Provider value={{ theme, setTheme: setThemeCallback, resolvedTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme()
const themes: { value: Theme; label: string; icon: React.ReactNode }[] = [
{ value: 'light', label: 'Light', icon: <Sun className="h-4 w-4" /> },
{ value: 'dark', label: 'Dark', icon: <Moon className="h-4 w-4" /> },
{ value: 'system', label: 'System', icon: <Monitor className="h-4 w-4" /> },
]
return (
<div className="relative">
<Button
variant="ghost"
size="icon"
className="relative"
onClick={() => {
const currentIndex = themes.findIndex(t => t.value === theme)
const nextIndex = (currentIndex + 1) % themes.length
setTheme(themes[nextIndex].value)
}}
aria-label={`Current theme: ${theme}. Click to cycle.`}
title={`Theme: ${theme.charAt(0).toUpperCase() + theme.slice(1)}`}
>
{themes.find(t => t.value === resolvedTheme)?.icon || <Monitor className="h-4 w-4" />}
</Button>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 invisible transition-all group-hover:opacity-100 group-hover:visible pointer-events-none">
<div className="bg-muted px-2 py-1 rounded text-xs text-muted-foreground whitespace-nowrap">
Theme: {theme.charAt(0).toUpperCase() + theme.slice(1)}
</div>
</div>
</div>
)
}
+641
View File
@@ -0,0 +1,641 @@
'use client'
import * as React from 'react'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Play, Pause, Volume2, VolumeX, Maximize, Minimize, SkipBack, SkipForward, Loader2, HelpCircle, PictureInPicture2, Check } from 'lucide-react'
import { cn, formatTime } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { KeyboardShortcutsOverlay } from '@/components/KeyboardShortcutsOverlay'
interface VideoPlayerProps {
src: string
poster?: string
subtitles?: string
onTimeUpdate?: (time: number) => void
onEnded?: () => void
onProgressSave?: (time: number) => void
initialTime?: number
autoPlay?: boolean
seekRequest?: { time: number; nonce: number } | null
}
export function VideoPlayer({
src,
poster,
subtitles,
onTimeUpdate,
onEnded,
onProgressSave,
initialTime = 0,
autoPlay = true,
seekRequest = null,
}: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(initialTime)
const [duration, setDuration] = useState(0)
const [volume, setVolume] = useState(1)
const [isMuted, setIsMuted] = useState(false)
const [isFullscreen, setIsFullscreen] = useState(false)
const [showControls, setShowControls] = useState(true)
const [buffered, setBuffered] = useState(0)
const [playbackRate, setPlaybackRate] = useState(1)
const playbackRates = [0.75, 1, 1.25, 1.5, 2]
const [showShortcuts, setShowShortcuts] = useState(false)
const [isPiPSupported, setIsPiPSupported] = useState(false)
const [isPiPActive, setIsPiPActive] = useState(false)
const [hoverProgress, setHoverProgress] = useState<{ time: number; x: number } | null>(null)
const [showSettingsMenu, setShowSettingsMenu] = useState(false)
const settingsMenuRef = useRef<HTMLDivElement>(null)
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
const progressSaveTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
const hasAutoPlayedRef = useRef(false)
const formatTimeDisplay = (seconds: number) => formatTime(seconds)
useEffect(() => {
if (autoPlay && videoRef.current && !hasAutoPlayedRef.current) {
hasAutoPlayedRef.current = true
const playPromise = videoRef.current.play()
if (playPromise !== undefined) {
playPromise
.then(() => {
setIsPlaying(true)
})
.catch(() => {
// Auto-play failed (browser policy), user must tap to play
setIsPlaying(false)
hasAutoPlayedRef.current = false // Allow retry on user interaction
})
}
}
}, [autoPlay])
useEffect(() => {
const video = videoRef.current
if (!video) return
const handleEnterPiP = () => setIsPiPActive(true)
const handleLeavePiP = () => setIsPiPActive(false)
video.addEventListener('enterpictureinpicture', handleEnterPiP)
video.addEventListener('leavepictureinpicture', handleLeavePiP)
return () => {
video.removeEventListener('enterpictureinpicture', handleEnterPiP)
video.removeEventListener('leavepictureinpicture', handleLeavePiP)
}
}, [])
useEffect(() => {
if (typeof document !== 'undefined') {
setIsPiPSupported(Boolean((document as Document & { pictureInPictureEnabled?: boolean }).pictureInPictureEnabled))
}
}, [])
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(Boolean(document.fullscreenElement))
}
document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
}, [])
const handleMouseMove = useCallback(() => {
setShowControls(true)
if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current)
controlsTimeoutRef.current = setTimeout(() => setShowControls(false), 3000)
}, [])
const togglePlay = useCallback(() => {
if (!videoRef.current) return
if (isPlaying) {
videoRef.current.pause()
} else {
videoRef.current.play()
}
}, [isPlaying])
const togglePictureInPicture = useCallback(async () => {
if (!videoRef.current) return
const video = videoRef.current
const doc = document as Document & { pictureInPictureEnabled?: boolean; pictureInPictureElement?: Element | null }
if (!doc.pictureInPictureEnabled) {
window.alert('Picture-in-Picture is not supported in this browser.')
return
}
try {
if (doc.pictureInPictureElement === video) {
await doc.exitPictureInPicture?.()
} else {
await video.requestPictureInPicture?.()
}
} catch (error) {
console.error('Picture-in-Picture toggle failed:', error)
}
}, [])
const saveProgress = useCallback((time: number) => {
if (progressSaveTimeoutRef.current) clearTimeout(progressSaveTimeoutRef.current)
progressSaveTimeoutRef.current = setTimeout(() => {
onProgressSave?.(time)
}, 500)
}, [onProgressSave])
const seekToPercent = (percent: number) => {
if (videoRef.current && duration > 0) {
videoRef.current.currentTime = (percent / 100) * duration
setCurrentTime(videoRef.current.currentTime)
saveProgress(videoRef.current.currentTime)
}
}
const handleProgressHover = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (duration > 0) {
const rect = e.currentTarget.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
const hoverTime = percent * duration
setHoverProgress({ time: hoverTime, x: e.clientX - rect.left })
}
}, [duration])
const handleProgressLeave = useCallback(() => {
setHoverProgress(null)
}, [])
const handleProgressClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (videoRef.current && duration > 0) {
const rect = e.currentTarget.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
videoRef.current.currentTime = percent * duration
setCurrentTime(videoRef.current.currentTime)
saveProgress(videoRef.current.currentTime)
}
}, [duration, saveProgress])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!videoRef.current) return
const video = videoRef.current
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
switch (e.key) {
case '?':
if (e.shiftKey) {
e.preventDefault()
setShowShortcuts(prev => !prev)
}
break
case ' ':
case 'k':
e.preventDefault()
togglePlay()
break
case 'j':
case 'ArrowLeft':
e.preventDefault()
video.currentTime = Math.max(0, video.currentTime - 10)
saveProgress(video.currentTime)
break
case 'l':
case 'ArrowRight':
e.preventDefault()
video.currentTime = Math.min(video.duration, video.currentTime + 30)
saveProgress(video.currentTime)
break
case 'ArrowUp':
e.preventDefault()
video.volume = Math.min(1, video.volume + 0.1)
setVolume(video.volume)
setIsMuted(false)
break
case 'ArrowDown':
e.preventDefault()
video.volume = Math.max(0, video.volume - 0.1)
setVolume(video.volume)
if (video.volume === 0) setIsMuted(true)
break
case 'm':
if (videoRef.current) {
videoRef.current.muted = !isMuted
setIsMuted(!isMuted)
}
break
case 'f':
if (videoRef.current) {
if (!isFullscreen) {
videoRef.current.requestFullscreen()
} else {
document.exitFullscreen()
}
}
break
case 'p':
case 'i':
e.preventDefault()
togglePictureInPicture()
break
case 'Escape':
e.preventDefault()
if (showShortcuts) {
setShowShortcuts(false)
break
}
if (document.fullscreenElement) {
document.exitFullscreen()
break
}
if (isPiPActive) {
togglePictureInPicture()
}
break
case ',':
case '[':
video.playbackRate = Math.max(0.25, video.playbackRate - 0.25)
setPlaybackRate(video.playbackRate)
break
case '.':
case ']':
video.playbackRate = Math.min(2, video.playbackRate + 0.25)
setPlaybackRate(video.playbackRate)
break
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
e.preventDefault()
seekToPercent(parseInt(e.key) * 10)
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [togglePlay, isMuted, isFullscreen, saveProgress, duration, seekToPercent, togglePictureInPicture, showShortcuts, isPiPActive])
const handleLoadedMetadata = () => {
if (videoRef.current) {
setDuration(videoRef.current.duration)
if (initialTime > 0) {
videoRef.current.currentTime = initialTime
}
}
}
const handleTimeUpdate = () => {
if (videoRef.current) {
const time = videoRef.current.currentTime
setCurrentTime(time)
onTimeUpdate?.(time)
saveProgress(time)
}
}
const handleDurationChange = () => {
if (videoRef.current) {
setDuration(videoRef.current.duration)
}
}
const handleProgress = () => {
if (videoRef.current && videoRef.current.buffered.length > 0) {
setBuffered(videoRef.current.buffered.end(videoRef.current.buffered.length - 1))
}
}
const handleEnded = () => {
setIsPlaying(false)
onEnded?.()
}
const handlePlay = () => setIsPlaying(true)
const handlePause = () => {
setIsPlaying(false)
if (videoRef.current) {
saveProgress(videoRef.current.currentTime)
}
}
const handleVolumeChangeNative = () => {
if (videoRef.current) {
setVolume(videoRef.current.volume)
setIsMuted(videoRef.current.muted)
}
}
const handleRateChange = () => {
if (videoRef.current) {
setPlaybackRate(videoRef.current.playbackRate)
}
}
useEffect(() => {
if (videoRef.current) {
videoRef.current.playbackRate = playbackRate
}
}, [playbackRate])
useEffect(() => {
if (videoRef.current && initialTime > 0 && videoRef.current.readyState >= 1) {
videoRef.current.currentTime = initialTime
// Allow auto-play attempt again when seeking to saved position
hasAutoPlayedRef.current = false
}
}, [initialTime])
useEffect(() => {
if (!videoRef.current || !seekRequest) return
const nextTime = Math.max(0, seekRequest.time)
videoRef.current.currentTime = nextTime
setCurrentTime(nextTime)
saveProgress(nextTime)
videoRef.current.play().catch(() => {})
}, [seekRequest?.nonce, saveProgress])
useEffect(() => {
return () => {
if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current)
if (progressSaveTimeoutRef.current) clearTimeout(progressSaveTimeoutRef.current)
}
}, [])
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (settingsMenuRef.current && !settingsMenuRef.current.contains(e.target as Node)) {
setShowSettingsMenu(false)
}
}
if (showSettingsMenu) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showSettingsMenu])
return (
<div
className="video-player-container"
onMouseMove={handleMouseMove}
onMouseLeave={() => setShowControls(false)}
>
<div className="relative w-full h-full" style={{ position: 'relative' }}>
<video
ref={videoRef}
src={src}
poster={poster}
preload="metadata"
playsInline
onLoadedMetadata={handleLoadedMetadata}
onTimeUpdate={handleTimeUpdate}
onDurationChange={handleDurationChange}
onProgress={handleProgress}
onEnded={handleEnded}
onPlay={handlePlay}
onPause={handlePause}
onVolumeChange={handleVolumeChangeNative}
onRateChange={handleRateChange}
onClick={togglePlay}
className="w-full h-full cursor-pointer"
>
{subtitles && (
<track kind="subtitles" src={subtitles} srcLang="en" label="English" default />
)}
</video>
</div>
{videoRef.current && videoRef.current.readyState < 2 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
<Loader2 className="h-8 w-8 text-primary animate-spin" />
</div>
)}
<div
className={cn(
'absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/90 to-transparent transition-opacity duration-200',
showControls || isPlaying ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
>
<div className="flex items-center gap-3 mb-2 relative">
<span className="text-xs text-white font-mono w-12 text-right">
{formatTimeDisplay(currentTime)}
</span>
<div
className="flex-1 h-1.5 bg-white/20 rounded-full cursor-pointer relative"
onMouseMove={handleProgressHover}
onMouseLeave={handleProgressLeave}
onClick={handleProgressClick}
>
{buffered > 0 && duration > 0 && (
<div
className="absolute top-0 left-0 h-full bg-white/30 rounded-full"
style={{ width: `${(buffered / duration) * 100}%` }}
/>
)}
<div
className="absolute top-0 left-0 h-full bg-primary rounded-full transition-none"
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
/>
{hoverProgress && (
<div
className="absolute bottom-full left-0 mb-2 transform -translate-x-1/2 transition-opacity duration-100 opacity-100 pointer-events-none z-20"
style={{ left: `${(hoverProgress.x / (duration > 0 ? 1 : 1)) * 100}%` }}
>
<div className="bg-black/90 text-white text-xs px-2 py-1 rounded whitespace-nowrap shadow-lg">
{formatTimeDisplay(hoverProgress.time)}
</div>
<div className="w-0 h-0 border-4 border-t-black/90 border-r-transparent border-l-transparent border-b-transparent mt-1" />
</div>
)}
</div>
<span className="text-xs text-white font-mono w-12">
{formatTimeDisplay(duration)}
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
onClick={togglePlay}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</Button>
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
onClick={() => videoRef.current && (videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime - 10))}
aria-label="Rewind 10s"
>
<SkipBack className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
onClick={() => videoRef.current && (videoRef.current.currentTime = Math.min(duration, videoRef.current.currentTime + 30))}
aria-label="Forward 30s"
>
<SkipForward className="h-5 w-5" />
</Button>
</div>
<div className="flex items-center gap-2 relative">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
onClick={() => {
if (videoRef.current) {
videoRef.current.muted = !isMuted
setIsMuted(!isMuted)
}
}}
aria-label={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted || volume === 0 ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
</Button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={isMuted ? 0 : volume}
onChange={(e) => {
const vol = parseFloat(e.target.value)
if (videoRef.current) {
videoRef.current.volume = vol
setVolume(vol)
setIsMuted(vol === 0)
}
}}
className="w-20 h-1 bg-white/20 rounded-lg appearance-none accent-primary cursor-pointer"
aria-label="Volume"
/>
</div>
{/* Fullscreen - FIRST */}
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
onClick={async () => {
if (videoRef.current) {
if (!isFullscreen) {
try {
await videoRef.current.requestFullscreen({ navigationUI: 'hide' })
} catch {
videoRef.current.requestFullscreen()
}
} else {
document.exitFullscreen()
}
}
}}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Minimize className="h-5 w-5" /> : <Maximize className="h-5 w-5" />}
</Button>
{/* Picture-in-Picture */}
<Button
variant="ghost"
size="icon"
className={cn(
'text-white hover:bg-white/20',
isPiPActive && 'bg-primary text-primary-foreground hover:bg-primary/90'
)}
onClick={togglePictureInPicture}
disabled={!isPiPSupported}
aria-label={isPiPActive ? 'Exit Picture-in-Picture' : 'Picture-in-Picture'}
title={isPiPSupported ? 'Toggle Picture-in-Picture (I)' : 'Picture-in-Picture not supported'}
>
<PictureInPicture2 className="h-5 w-5" />
</Button>
{/* Settings Menu (3-dot) - Speed + Shortcuts */}
<div className="relative" ref={settingsMenuRef}>
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
onClick={() => setShowSettingsMenu(!showSettingsMenu)}
aria-label="Settings"
aria-expanded={showSettingsMenu}
>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1"></circle>
<circle cx="19" cy="12" r="1"></circle>
<circle cx="5" cy="12" r="1"></circle>
</svg>
</Button>
{showSettingsMenu && (
<div
className="absolute bottom-full right-0 mb-2 w-40 glass-card-elevated rounded-lg p-2 shadow-xl border border-white/10 animate-in zoom-in-95 z-20"
role="menu"
>
<div className="px-3 py-2 text-xs font-semibold text-white/60 uppercase tracking-wider">Speed</div>
{playbackRates.map((rate) => (
<button
key={rate}
onClick={() => {
if (videoRef.current) {
videoRef.current.playbackRate = rate
}
setPlaybackRate(rate)
setShowSettingsMenu(false)
}}
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-white hover:bg-white/10 rounded ${playbackRate === rate ? 'bg-primary/20 text-primary' : ''}`}
role="menuitemradio"
aria-checked={playbackRate === rate}
>
<span>{rate}x</span>
{playbackRate === rate && <Check className="h-4 w-4" />}
</button>
))}
<hr className="border-white/10 my-1" />
<button
onClick={() => setShowShortcuts(true)}
className="w-full flex items-center justify-between px-3 py-2 text-sm text-white hover:bg-white/10 rounded"
role="menuitem"
>
<span>Keyboard Shortcuts</span>
<HelpCircle className="h-4 w-4" />
</button>
<hr className="border-white/10 my-1" />
<button
onClick={() => setShowSettingsMenu(false)}
className="w-full flex items-center justify-center px-3 py-2 text-sm text-white/70 hover:bg-white/10 rounded"
role="menuitem"
>
Close
</button>
</div>
)}
</div>
</div>
</div>
</div>
<KeyboardShortcutsOverlay isOpen={showShortcuts} onClose={() => setShowShortcuts(false)} />
</div>
)
}
+54
View File
@@ -0,0 +1,54 @@
import * as React from 'react'
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b', className)}
{...props}
/>
))
AccordionItem.displayName = 'AccordionItem'
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-4', className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+44
View File
@@ -0,0 +1,44 @@
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { cn } from '@/lib/utils'
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }
+32
View File
@@ -0,0 +1,32 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
success: 'border-transparent bg-green-500 text-white hover:bg-green-600',
warning: 'border-transparent bg-yellow-500 text-white hover:bg-yellow-600',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }
+52
View File
@@ -0,0 +1,52 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
+66
View File
@@ -0,0 +1,66 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
)
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+171
View File
@@ -0,0 +1,171 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { Check, ChevronRight, Circle } from 'lucide-react'
import { cn } from '@/lib/utils'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2.5 w-2.5 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
}
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+24
View File
@@ -0,0 +1,24 @@
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@/lib/utils'
const Progress = React.forwardRef(
(
{ className, value = 0, ...props }: React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & { value?: number },
ref: React.Ref<HTMLDivElement>
) => (
<ProgressPrimitive.Root
ref={ref}
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-secondary', className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: 'translateX(-' + (100 - value) + '%)' }}
/>
</ProgressPrimitive.Root>
)
)
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
+45
View File
@@ -0,0 +1,45 @@
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+158
View File
@@ -0,0 +1,158 @@
'use client'
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
error?: boolean
}
>(({ className, children, error, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between rounded-lg border bg-background px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
error && 'border-destructive focus:ring-destructive',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+23
View File
@@ -0,0 +1,23 @@
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+15
View File
@@ -0,0 +1,15 @@
import { cn } from '@/lib/utils'
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
)
}
export { Skeleton }
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = 'Textarea'
export { Textarea }
+25
View File
@@ -0,0 +1,25 @@
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }