mirror of
https://github.com/nicetry247/offlineacademy.git
synced 2026-07-26 11:58:44 +00:00
initial commit
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+512
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+235
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' }>
|
||||
}
|
||||
Executable
+75
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+361
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+131
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+641
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+54
@@ -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 }
|
||||
Executable
+44
@@ -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 }
|
||||
Executable
+32
@@ -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 }
|
||||
Executable
+52
@@ -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 }
|
||||
Executable
+66
@@ -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 }
|
||||
Executable
+171
@@ -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,
|
||||
}
|
||||
Executable
+22
@@ -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 }
|
||||
Executable
+24
@@ -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 }
|
||||
Executable
+24
@@ -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 }
|
||||
Executable
+45
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
Executable
+23
@@ -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 }
|
||||
Executable
+15
@@ -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 }
|
||||
@@ -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 }
|
||||
Executable
+25
@@ -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 }
|
||||
Reference in New Issue
Block a user