mirror of
https://github.com/nicetry247/offlineacademy.git
synced 2026-08-18 06:33:15 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
BarChart3,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
BookOpen,
|
||||
Play,
|
||||
TrendingUp,
|
||||
Bookmark,
|
||||
Film,
|
||||
Music,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Award,
|
||||
} from 'lucide-react'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Analytics',
|
||||
description: 'Learning analytics and progress overview.',
|
||||
}
|
||||
|
||||
interface AnalyticsData {
|
||||
totalCourses: number
|
||||
completedCourses: number
|
||||
inProgressCourses: number
|
||||
totalLessons: number
|
||||
completedLessons: number
|
||||
inProgressLessons: number
|
||||
notStartedLessons: number
|
||||
totalWatchedHours: number
|
||||
totalWatchedMinutes: number
|
||||
weeklyWatchedHours: number
|
||||
weeklyWatchedMinutes: number
|
||||
weeklyCompletedLessons: number
|
||||
lessonsByType: Array<{ type: string; _count: number }>
|
||||
completedByType: Record<string, number>
|
||||
completionRate: number
|
||||
lessonCompletionRate: number
|
||||
}
|
||||
|
||||
async function getAnalyticsData(): Promise<AnalyticsData> {
|
||||
const res = await fetch('http://127.0.0.1:6767/api/progress?type=analytics', {
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch analytics')
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
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" />,
|
||||
OTHER: <FileText className="h-4 w-4 text-muted-foreground" />,
|
||||
}
|
||||
|
||||
const lessonTypeColors: Record<string, string> = {
|
||||
VIDEO: 'bg-red-500/20 text-red-400',
|
||||
AUDIO: 'bg-purple-500/20 text-purple-400',
|
||||
PDF: 'bg-red-500/20 text-red-400',
|
||||
MARKDOWN: 'bg-blue-500/20 text-blue-400',
|
||||
HTML: 'bg-orange-500/20 text-orange-400',
|
||||
IMAGE: 'bg-green-500/20 text-green-400',
|
||||
OTHER: 'bg-muted text-muted-foreground',
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`
|
||||
}
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function AnalyticsPage() {
|
||||
const data = await getAnalyticsData()
|
||||
|
||||
const totalWatchedHours = Math.floor(data.totalWatchedMinutes / 60)
|
||||
const remainingMinutes = data.totalWatchedMinutes % 60
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header backHref="/" backLabel="Dashboard" coffeeUrl="https://ko-fi.com/nicetry247" />
|
||||
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<section className="mb-8">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight mb-2 text-gradient-primary">
|
||||
Learning Analytics
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Your personal learning insights and progress overview.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Overview Cards */}
|
||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4 text-accent" />
|
||||
Total Courses
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-3xl font-bold">{data.totalCourses}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.completedCourses} completed · {data.inProgressCourses} in progress
|
||||
</p>
|
||||
<Progress
|
||||
value={data.totalCourses > 0 ? Math.round((data.completedCourses / data.totalCourses) * 100) : 0}
|
||||
className="h-1.5 mt-2 gradient-progress"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/15 flex items-center justify-center">
|
||||
<Award className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
Lessons Completed
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-3xl font-bold">{data.completedLessons}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.lessonCompletionRate}% completion rate
|
||||
</p>
|
||||
<Progress value={data.lessonCompletionRate} className="h-1.5 mt-2 gradient-progress" />
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-green-500/15 flex items-center justify-center">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-blue-400" />
|
||||
Total Time Watched
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-3xl font-bold">
|
||||
{totalWatchedHours}h {remainingMinutes}m
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{data.totalWatchedMinutes} minutes total</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-500/15 flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-blue-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-emerald-400" />
|
||||
This Week
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-3xl font-bold">
|
||||
{data.weeklyWatchedHours}h
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.weeklyCompletedLessons} lessons completed
|
||||
</p>
|
||||
<Progress
|
||||
value={Math.min(100, Math.round((data.weeklyWatchedHours / 10) * 100))}
|
||||
className="h-1.5 mt-2 gradient-progress-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-emerald-500/15 flex items-center justify-center">
|
||||
<TrendingUp className="h-6 w-6 text-emerald-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Detailed Breakdown */}
|
||||
<section className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||
{/* Lessons by Type */}
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Film className="h-5 w-5 text-primary" />
|
||||
Lessons by Type
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{data.lessonsByType.map(({ type, _count }) => {
|
||||
const completed = data.completedByType[type] || 0
|
||||
const percentage = _count > 0 ? Math.round((completed / _count) * 100) : 0
|
||||
const Icon = lessonTypeIcons[type] || lessonTypeIcons.OTHER
|
||||
return (
|
||||
<div key={type} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`flex h-8 w-8 items-center justify-center rounded-lg ${lessonTypeColors[type] || lessonTypeColors.OTHER}`}>
|
||||
{Icon}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium capitalize">{type.toLowerCase()}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{completed} / {_count} completed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{percentage}%
|
||||
</Badge>
|
||||
</div>
|
||||
<Progress value={percentage} className="h-2 gradient-progress" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Course Progress Distribution */}
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5 text-accent" />
|
||||
Course Progress Distribution
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-green-500" />
|
||||
Completed
|
||||
</span>
|
||||
<Badge variant="secondary">{data.completedCourses}</Badge>
|
||||
</div>
|
||||
<Progress
|
||||
value={data.totalCourses > 0 ? Math.round((data.completedCourses / data.totalCourses) * 100) : 0}
|
||||
className="h-2.5 gradient-progress"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-blue-400" />
|
||||
In Progress
|
||||
</span>
|
||||
<Badge variant="secondary">{data.inProgressCourses}</Badge>
|
||||
</div>
|
||||
<Progress
|
||||
value={data.totalCourses > 0 ? Math.round((data.inProgressCourses / data.totalCourses) * 100) : 0}
|
||||
className="h-2.5 gradient-progress-accent"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-muted" />
|
||||
Not Started
|
||||
</span>
|
||||
<Badge variant="secondary">
|
||||
{data.totalCourses - data.completedCourses - data.inProgressCourses}
|
||||
</Badge>
|
||||
</div>
|
||||
<Progress
|
||||
value={data.totalCourses > 0
|
||||
? Math.round(((data.totalCourses - data.completedCourses - data.inProgressCourses) / data.totalCourses) * 100)
|
||||
: 0}
|
||||
className="h-2.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-green-500" />
|
||||
Lesson Completion
|
||||
</span>
|
||||
<Badge variant="secondary">{data.lessonCompletionRate}%</Badge>
|
||||
</div>
|
||||
<Progress value={data.lessonCompletionRate} className="h-3 gradient-progress" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Time Stats */}
|
||||
<section className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-blue-400" />
|
||||
Total Watch Time
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Hours watched</span>
|
||||
<span className="font-semibold text-lg">{totalWatchedHours}h {remainingMinutes}m</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Minutes total</span>
|
||||
<span className="font-semibold">{data.totalWatchedMinutes} min</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Average per lesson</span>
|
||||
<span className="font-semibold">
|
||||
{data.completedLessons > 0
|
||||
? `${Math.round(data.totalWatchedMinutes / data.completedLessons)} min`
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-emerald-400" />
|
||||
This Week Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Hours watched</span>
|
||||
<span className="font-semibold text-lg">{data.weeklyWatchedHours}h</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Minutes</span>
|
||||
<span className="font-semibold">{data.weeklyWatchedMinutes} min</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Lessons completed</span>
|
||||
<span className="font-semibold text-green-500">{data.weeklyCompletedLessons}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Play className="h-5 w-5 text-primary" />
|
||||
Lesson Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<span className="w-3 h-3 rounded-full bg-green-500" />
|
||||
Completed
|
||||
</span>
|
||||
<span className="font-semibold text-green-500">{data.completedLessons}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<span className="w-3 h-3 rounded-full bg-blue-400" />
|
||||
In Progress
|
||||
</span>
|
||||
<span className="font-semibold text-blue-400">{data.inProgressLessons}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<span className="w-3 h-3 rounded-full bg-muted" />
|
||||
Not Started
|
||||
</span>
|
||||
<span className="font-semibold text-muted-foreground">{data.notStartedLessons}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<Bookmark className="h-3 w-3" />
|
||||
Total
|
||||
</span>
|
||||
<span className="font-semibold">{data.totalLessons}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const userId = 'local-user'
|
||||
|
||||
// 1. Total completed lessons and total watch time (from completed lessons)
|
||||
const completedLessons = await prisma.progress.findMany({
|
||||
where: {
|
||||
userId,
|
||||
lessonId: { not: null },
|
||||
completed: true,
|
||||
},
|
||||
include: {
|
||||
lesson: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 2. In-progress lessons (position watch time)
|
||||
const inProgressLessons = await prisma.progress.findMany({
|
||||
where: {
|
||||
userId,
|
||||
lessonId: { not: null },
|
||||
completed: false,
|
||||
position: { gt: 0 },
|
||||
},
|
||||
include: {
|
||||
lesson: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 3. All lessons for completion stats
|
||||
const allLessons = await prisma.lesson.count({
|
||||
where: {
|
||||
module: { course: { hidden: false } },
|
||||
},
|
||||
})
|
||||
|
||||
// 4. Completed courses
|
||||
const completedCourses = await prisma.progress.findMany({
|
||||
where: {
|
||||
userId,
|
||||
lessonId: null,
|
||||
moduleId: null,
|
||||
completed: true,
|
||||
course: { hidden: false },
|
||||
},
|
||||
include: { course: true },
|
||||
})
|
||||
|
||||
// 5. In-progress courses (have lesson progress but course not completed)
|
||||
const inProgressCourses = await prisma.progress.findMany({
|
||||
where: {
|
||||
userId,
|
||||
lessonId: { not: null },
|
||||
lesson: { module: { course: { hidden: false } } },
|
||||
},
|
||||
include: {
|
||||
lesson: {
|
||||
include: {
|
||||
module: { include: { course: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { lastWatched: 'desc' },
|
||||
})
|
||||
|
||||
// 6. Bookmarks count
|
||||
const totalBookmarks = await prisma.bookmark.count({
|
||||
where: { userId },
|
||||
})
|
||||
|
||||
// 7. Weekly progress - lessons completed in the last 8 weeks
|
||||
const eightWeeksAgo = new Date()
|
||||
eightWeeksAgo.setDate(eightWeeksAgo.getDate() - 56)
|
||||
|
||||
const weeklyCompleted = await prisma.progress.groupBy({
|
||||
by: ['lastWatched'],
|
||||
where: {
|
||||
userId,
|
||||
lessonId: { not: null },
|
||||
completed: true,
|
||||
lastWatched: { gte: eightWeeksAgo },
|
||||
},
|
||||
_count: { id: true },
|
||||
})
|
||||
|
||||
// Aggregate by week
|
||||
const weeklyData = new Map<string, number>()
|
||||
for (const wc of weeklyCompleted) {
|
||||
const weekStart = new Date(wc.lastWatched)
|
||||
weekStart.setDate(weekStart.getDate() - weekStart.getDay()) // Start of week (Sunday)
|
||||
const weekKey = weekStart.toISOString().split('T')[0]
|
||||
weeklyData.set(weekKey, (weeklyData.get(weekKey) || 0) + wc._count.id)
|
||||
}
|
||||
|
||||
// Ensure last 8 weeks have entries (even if 0)
|
||||
const weeklyProgress: Array<{ week: string; count: number }> = []
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - date.getDay() - i * 7)
|
||||
const weekKey = date.toISOString().split('T')[0]
|
||||
weeklyProgress.push({
|
||||
week: weekKey,
|
||||
count: weeklyData.get(weekKey) || 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const completedLessonCount = completedLessons.length
|
||||
const totalWatchTimeSeconds = completedLessons.reduce((sum, p) => {
|
||||
return sum + (p.lesson?.duration || 0)
|
||||
}, 0) + inProgressLessons.reduce((sum, p) => {
|
||||
return sum + (p.position || 0)
|
||||
}, 0)
|
||||
|
||||
// Unique courses with progress
|
||||
const courseIds = new Set<string>()
|
||||
for (const p of inProgressCourses) {
|
||||
if (p.lesson?.module?.courseId) {
|
||||
courseIds.add(p.lesson.module.courseId)
|
||||
}
|
||||
}
|
||||
for (const c of completedCourses) {
|
||||
if (c.courseId) courseIds.add(c.courseId)
|
||||
}
|
||||
|
||||
// In-progress courses with their progress
|
||||
const inProgressCourseMap = new Map<string, {
|
||||
courseId: string
|
||||
courseName: string
|
||||
courseSlug: string
|
||||
completedLessons: number
|
||||
totalLessons: number
|
||||
percentage: number
|
||||
lastWatched: Date
|
||||
}>()
|
||||
|
||||
for (const p of inProgressCourses) {
|
||||
const course = p.lesson?.module?.course
|
||||
if (!course) continue
|
||||
const existing = inProgressCourseMap.get(course.id)
|
||||
if (!existing || p.lastWatched > existing.lastWatched) {
|
||||
inProgressCourseMap.set(course.id, {
|
||||
courseId: course.id,
|
||||
courseName: course.displayName || course.name,
|
||||
courseSlug: course.slug,
|
||||
completedLessons: 0, // will compute below
|
||||
totalLessons: 0,
|
||||
percentage: 0,
|
||||
lastWatched: p.lastWatched,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Compute progress for each in-progress course
|
||||
for (const [courseId, data] of Array.from(inProgressCourseMap.entries())) {
|
||||
const lessons = await prisma.lesson.findMany({
|
||||
where: { module: { courseId } },
|
||||
select: { id: true },
|
||||
})
|
||||
const completed = await prisma.progress.count({
|
||||
where: {
|
||||
userId,
|
||||
lessonId: { in: lessons.map(l => l.id) },
|
||||
completed: true,
|
||||
},
|
||||
})
|
||||
data.totalLessons = lessons.length
|
||||
data.completedLessons = completed
|
||||
data.percentage = lessons.length > 0 ? Math.round((completed / lessons.length) * 100) : 0
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
summary: {
|
||||
totalWatchTimeHours: Math.round(totalWatchTimeSeconds / 3600 * 10) / 10,
|
||||
totalWatchTimeMinutes: Math.round(totalWatchTimeSeconds / 60),
|
||||
completedLessons: completedLessonCount,
|
||||
totalLessons: allLessons,
|
||||
overallCompletionRate: allLessons > 0 ? Math.round((completedLessonCount / allLessons) * 100) : 0,
|
||||
completedCourses: completedCourses.length,
|
||||
inProgressCourses: inProgressCourseMap.size,
|
||||
totalBookmarks,
|
||||
},
|
||||
weeklyProgress,
|
||||
inProgressCourses: Array.from(inProgressCourseMap.values()),
|
||||
completedCourses: completedCourses.map(c => ({
|
||||
courseId: c.courseId,
|
||||
courseName: c.course.displayName || c.course.name,
|
||||
courseSlug: c.course.slug,
|
||||
completedAt: c.lastWatched,
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Analytics fetch error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch analytics' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const updateSchema = z.object({
|
||||
note: z.string().trim().max(1000).optional().nullable(),
|
||||
})
|
||||
|
||||
function normalizeNote(note?: string | null) {
|
||||
const trimmed = (note || '').trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ bookmarkId: string }> }) {
|
||||
try {
|
||||
const { bookmarkId } = await params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const parsed = updateSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const bookmark = await prisma.bookmark.update({
|
||||
where: { id: bookmarkId },
|
||||
data: { note: normalizeNote(parsed.data.note) },
|
||||
include: {
|
||||
lesson: {
|
||||
select: { id: true, title: true, slug: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
bookmark: {
|
||||
...bookmark,
|
||||
note: bookmark.note ?? '',
|
||||
createdAt: bookmark.createdAt.toISOString(),
|
||||
updatedAt: bookmark.updatedAt.toISOString(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Bookmark update error:', error)
|
||||
return NextResponse.json({ error: 'Failed to update bookmark' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ bookmarkId: string }> }) {
|
||||
try {
|
||||
const { bookmarkId } = await params
|
||||
|
||||
await prisma.bookmark.delete({ where: { id: bookmarkId } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Bookmark delete error:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete bookmark' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const bookmarkSchema = z.object({
|
||||
lessonId: z.string().min(1),
|
||||
courseId: z.string().min(1),
|
||||
moduleId: z.string().min(1),
|
||||
position: z.number().int().min(0),
|
||||
note: z.string().trim().max(1000).optional().nullable(),
|
||||
})
|
||||
|
||||
function normalizeNote(note?: string | null) {
|
||||
const trimmed = (note || '').trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const lessonId = searchParams.get('lessonId')
|
||||
const courseId = searchParams.get('courseId')
|
||||
|
||||
if (!lessonId && !courseId) {
|
||||
return NextResponse.json({ error: 'lessonId or courseId required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const bookmarks = await prisma.bookmark.findMany({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
...(lessonId ? { lessonId } : { courseId: courseId! }),
|
||||
lesson: {
|
||||
module: {
|
||||
course: { hidden: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
|
||||
include: {
|
||||
lesson: {
|
||||
select: { id: true, title: true, slug: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
items: bookmarks.map(bookmark => ({
|
||||
...bookmark,
|
||||
note: bookmark.note ?? '',
|
||||
createdAt: bookmark.createdAt.toISOString(),
|
||||
updatedAt: bookmark.updatedAt.toISOString(),
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Bookmark fetch error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch bookmarks' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const parsed = bookmarkSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const bookmark = await prisma.bookmark.create({
|
||||
data: {
|
||||
userId: 'local-user',
|
||||
lessonId: parsed.data.lessonId,
|
||||
courseId: parsed.data.courseId,
|
||||
moduleId: parsed.data.moduleId,
|
||||
position: parsed.data.position,
|
||||
note: normalizeNote(parsed.data.note),
|
||||
},
|
||||
include: {
|
||||
lesson: {
|
||||
select: { id: true, title: true, slug: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
bookmark: {
|
||||
...bookmark,
|
||||
note: bookmark.note ?? '',
|
||||
createdAt: bookmark.createdAt.toISOString(),
|
||||
updatedAt: bookmark.updatedAt.toISOString(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Bookmark create error:', error)
|
||||
return NextResponse.json({ error: 'Failed to create bookmark' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ensureModuleQuizCache, syncQuizLessonFromCache, writeQuizCache } from '@/lib/quiz'
|
||||
import { join, dirname } from 'path'
|
||||
const QUIZ_CACHE_FILE = 'quiz_cache.json'
|
||||
|
||||
async function getCourseWithModules(slug: string) {
|
||||
return prisma.course.findFirst({
|
||||
where: { slug, hidden: false },
|
||||
include: {
|
||||
modules: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: { lessons: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ slug: string }> }) {
|
||||
try {
|
||||
const { slug } = await params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const action = body?.action
|
||||
if (action === 'regenerate') {
|
||||
const course = await getCourseWithModules(slug)
|
||||
if (!course) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const sourceValue = body?.source
|
||||
const difficulty = typeof body?.difficulty === 'string' ? body.difficulty.toLowerCase() : 'medium'
|
||||
const results: Array<{ module: string; status: 'ok' | 'skipped'; error?: string }> = []
|
||||
|
||||
for (const module of course.modules) {
|
||||
const modulePath = join(course.path, module.name)
|
||||
try {
|
||||
const generated = await ensureModuleQuizCache({
|
||||
modulePath,
|
||||
topic: module.name,
|
||||
source: sourceValue === 'the-trivia-api' ? 'the-trivia-api' : 'quizapi',
|
||||
force: true,
|
||||
})
|
||||
|
||||
if (!generated) {
|
||||
results.push({ module: module.name, status: 'skipped', error: 'Intro/local-only quiz generation skipped' })
|
||||
continue
|
||||
}
|
||||
|
||||
await syncQuizLessonFromCache({
|
||||
moduleId: module.id,
|
||||
modulePath,
|
||||
courseRoot: dirname(course.path),
|
||||
topic: module.name,
|
||||
source: sourceValue === 'the-trivia-api' ? 'the-trivia-api' : 'quizapi',
|
||||
lessonOrder: module.lessons.length,
|
||||
autoFetch: false,
|
||||
force: true,
|
||||
})
|
||||
|
||||
results.push({ module: module.name, status: 'ok' })
|
||||
} catch (error) {
|
||||
results.push({ module: module.name, status: 'skipped', error: String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, action: 'regenerate', difficulty, results })
|
||||
}
|
||||
|
||||
if (action === 'import') {
|
||||
const course = await getCourseWithModules(slug)
|
||||
if (!course) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const moduleName = typeof body?.module === 'string' ? body.module.trim() : ''
|
||||
const targetModule = course.modules.find((item) => item.name === moduleName)
|
||||
if (!targetModule) {
|
||||
return NextResponse.json({ error: 'Module not found', availableModules: course.modules.map((item) => item.name) }, { status: 404 })
|
||||
}
|
||||
|
||||
const quiz = typeof body?.quiz === 'object' && body.quiz !== null ? (body.quiz as Record<string, unknown>) : null
|
||||
if (!quiz) {
|
||||
return NextResponse.json({ error: 'Missing quiz JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const modulePath = join(course.path, targetModule.name)
|
||||
const cachePath = join(modulePath, QUIZ_CACHE_FILE)
|
||||
await writeQuizCache(cachePath, quiz as never)
|
||||
|
||||
const synced = await syncQuizLessonFromCache({
|
||||
moduleId: targetModule.id,
|
||||
modulePath,
|
||||
courseRoot: dirname(course.path),
|
||||
topic: targetModule.name,
|
||||
source: 'the-trivia-api',
|
||||
lessonOrder: targetModule.lessons.length,
|
||||
autoFetch: false,
|
||||
force: true,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
action: 'import',
|
||||
module: targetModule.name,
|
||||
cachePath,
|
||||
lesson: synced?.lesson || null,
|
||||
})
|
||||
}
|
||||
|
||||
if (action === 'clear') {
|
||||
const course = await getCourseWithModules(slug)
|
||||
if (!course) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const moduleName = typeof body?.module === 'string' ? body.module.trim() : ''
|
||||
|
||||
if (moduleName === '__all__') {
|
||||
const moduleIds = course.modules.map((module) => module.id)
|
||||
const modulePaths = course.modules.map((module) => join(course.path, module.name, QUIZ_CACHE_FILE))
|
||||
|
||||
try {
|
||||
await prisma.lesson.deleteMany({ where: { moduleId: { in: moduleIds }, slug: 'quiz' } })
|
||||
} catch {
|
||||
// ignore if no quiz lessons exist
|
||||
}
|
||||
|
||||
try {
|
||||
const { unlink } = await import('fs/promises')
|
||||
await Promise.allSettled(modulePaths.map((cachePath) => unlink(cachePath).catch(() => {})))
|
||||
} catch {
|
||||
// ignore missing cache files
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, action: 'clear', module: '__all__' })
|
||||
}
|
||||
|
||||
if (!moduleName) {
|
||||
return NextResponse.json({ error: 'Missing module name' }, { status: 400 })
|
||||
}
|
||||
|
||||
const targetModule = course.modules.find((item) => item.name === moduleName)
|
||||
if (!targetModule) {
|
||||
return NextResponse.json({ error: 'Module not found', availableModules: course.modules.map((item) => item.name) }, { status: 404 })
|
||||
}
|
||||
|
||||
const modulePath = join(course.path, targetModule.name)
|
||||
const cachePath = join(modulePath, QUIZ_CACHE_FILE)
|
||||
|
||||
try {
|
||||
await prisma.lesson.deleteMany({ where: { moduleId: targetModule.id, slug: 'quiz' } })
|
||||
} catch {
|
||||
// ignore if no quiz lesson exists
|
||||
}
|
||||
|
||||
try {
|
||||
const { unlink } = await import('fs/promises')
|
||||
await unlink(cachePath).catch(() => {})
|
||||
} catch {
|
||||
// ignore missing cache file
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, action: 'clear', module: targetModule.name })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Unsupported action' }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error('Course quiz batch error:', error)
|
||||
return NextResponse.json({ error: 'Failed to process course quiz action' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { rm } from 'fs/promises'
|
||||
import { resolve, sep } from 'path'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const renameSchema = z.object({ displayName: z.string().trim().min(1).max(200) })
|
||||
const tagSchema = z.object({ tagId: z.string().min(1) })
|
||||
const favoriteSchema = z.object({ favorited: z.boolean() })
|
||||
|
||||
async function getCoursesRootPath() {
|
||||
const setting = await prisma.setting.findUnique({ where: { key: 'coursesRoot' } })
|
||||
return setting?.value || './My_Courses'
|
||||
}
|
||||
|
||||
async function findCourse(slug: string, includeHidden = false) {
|
||||
return prisma.course.findFirst({
|
||||
where: includeHidden ? { slug } : { slug, hidden: false },
|
||||
select: { id: true, slug: true, name: true, displayName: true, hidden: true, path: true },
|
||||
})
|
||||
}
|
||||
|
||||
function isPathWithinRoot(candidatePath: string, rootPath: string) {
|
||||
const normalizedCandidate = resolve(candidatePath)
|
||||
const normalizedRoot = resolve(rootPath)
|
||||
return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(normalizedRoot + sep)
|
||||
}
|
||||
|
||||
async function enrichCourseTags(course: { id: string }) {
|
||||
const connections = await prisma.courseTag.findMany({
|
||||
where: { courseId: course.id },
|
||||
include: { tag: true },
|
||||
})
|
||||
return connections.map(cn => cn.tag)
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||
try {
|
||||
const { slug } = await params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const rename = renameSchema.safeParse(body)
|
||||
const tag = body?.tagId ? tagSchema.safeParse(body) : null
|
||||
const favorite = body?.favorited !== undefined ? favoriteSchema.safeParse(body) : null
|
||||
|
||||
if (!rename.success && !tag?.success && !favorite?.success) {
|
||||
return NextResponse.json({ error: 'Invalid request', details: rename.error?.flatten() ?? {} }, { status: 400 })
|
||||
}
|
||||
|
||||
const existing = await findCourse(slug)
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
|
||||
if (rename.success) {
|
||||
data.displayName = rename.data.displayName
|
||||
}
|
||||
|
||||
if (favorite?.success) {
|
||||
data.favorited = favorite.data.favorited
|
||||
}
|
||||
|
||||
const tagRecord = tag?.success
|
||||
? await prisma.tag.upsert({
|
||||
where: { name: tag.data.tagId },
|
||||
update: {},
|
||||
create: { name: tag.data.tagId },
|
||||
})
|
||||
: null
|
||||
|
||||
const course = await prisma.course.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
hidden: true,
|
||||
path: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (tagRecord) {
|
||||
await prisma.courseTag.upsert({
|
||||
where: {
|
||||
courseId_tagId: {
|
||||
courseId: existing.id,
|
||||
tagId: tagRecord.id,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
courseId: existing.id,
|
||||
tagId: tagRecord.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const tags = await enrichCourseTags(course)
|
||||
|
||||
return NextResponse.json({ success: true, course: { ...course, tags } })
|
||||
} catch (error) {
|
||||
console.error('Course update error:', error)
|
||||
return NextResponse.json({ error: 'Failed to update course' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||
try {
|
||||
const { slug } = await params
|
||||
const scope = (request.nextUrl.searchParams.get('scope') || 'library').toLowerCase()
|
||||
|
||||
if (scope !== 'library' && scope !== 'disk' && scope !== 'tag') {
|
||||
return NextResponse.json({ error: 'Invalid delete scope. Use library, disk, or tag.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const existing = await findCourse(slug, scope === 'disk')
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (scope === 'disk') {
|
||||
const coursesRoot = await getCoursesRootPath()
|
||||
const absoluteCoursePath = resolve(existing.path)
|
||||
const absoluteCoursesRoot = resolve(coursesRoot)
|
||||
|
||||
if (!isPathWithinRoot(absoluteCoursePath, absoluteCoursesRoot)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Course path is outside the configured courses root',
|
||||
details: { coursePath: absoluteCoursePath, coursesRoot: absoluteCoursesRoot },
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await rm(absoluteCoursePath, { recursive: true, force: true })
|
||||
await prisma.course.delete({ where: { id: existing.id } })
|
||||
|
||||
return NextResponse.json({ success: true, deletedFromDisk: true, path: absoluteCoursePath })
|
||||
}
|
||||
|
||||
if (scope === 'tag') {
|
||||
const { tagId } = await request.json().catch(() => ({ tagId: '' }))
|
||||
|
||||
if (!tagId) {
|
||||
return NextResponse.json({ error: 'tagId is required when removing a tag' }, { status: 400 })
|
||||
}
|
||||
|
||||
await prisma.courseTag.deleteMany({
|
||||
where: { courseId: existing.id, tagId },
|
||||
})
|
||||
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { id: existing.id },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
hidden: true,
|
||||
path: true,
|
||||
},
|
||||
})
|
||||
|
||||
const tags = course ? await enrichCourseTags(course) : []
|
||||
return NextResponse.json({ success: true, course: course ? { ...course, tags } : null })
|
||||
}
|
||||
|
||||
await prisma.course.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
hidden: true,
|
||||
displayName: null,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, deletedFromLibrary: true })
|
||||
} catch (error) {
|
||||
console.error('Course delete error:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete course' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getLocalThumbnailUrl } from '@/lib/thumbnail-index-server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = request.nextUrl
|
||||
|
||||
// Pagination
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 100)
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
// Search
|
||||
const search = searchParams.get('search') || ''
|
||||
|
||||
// Filters
|
||||
const filter = searchParams.get('filter') || 'all' // all, in-progress, completed, not-started, favorites, tag:<tagId>
|
||||
const tag = searchParams.get('tag') || ''
|
||||
const tagFilter = tag || (filter.startsWith('tag:') ? filter.slice(4) : '')
|
||||
const favoritesOnly = searchParams.get('favorites') === 'true'
|
||||
const sortBy = searchParams.get('sortBy') || 'updatedAt'
|
||||
const sortOrder = searchParams.get('sortOrder') || 'desc'
|
||||
|
||||
// Build where clause
|
||||
const where: any = { hidden: false }
|
||||
|
||||
if (search) {
|
||||
const term = search.trim()
|
||||
where.OR = [
|
||||
{ name: { contains: term } },
|
||||
{ displayName: { contains: term } },
|
||||
{ slug: { contains: term } },
|
||||
]
|
||||
}
|
||||
|
||||
if (favoritesOnly || filter === 'favorites') {
|
||||
where.favorited = true
|
||||
}
|
||||
|
||||
if (tagFilter) {
|
||||
where.courseTags = { some: { tagId: tagFilter } }
|
||||
}
|
||||
|
||||
if (filter === 'in-progress') {
|
||||
where.progress = {
|
||||
some: { userId: 'local-user', completed: false, lessonId: null },
|
||||
}
|
||||
} else if (filter === 'completed') {
|
||||
where.progress = {
|
||||
some: { userId: 'local-user', completed: true, lessonId: null },
|
||||
}
|
||||
} else if (filter === 'not-started') {
|
||||
where.NOT = {
|
||||
progress: { some: { userId: 'local-user', lessonId: null } },
|
||||
}
|
||||
}
|
||||
|
||||
// Build orderBy. Favorites are pinned above non-favorites for library organization.
|
||||
const secondaryOrderBy: any = {}
|
||||
if (sortBy === 'name') {
|
||||
secondaryOrderBy.name = sortOrder
|
||||
} else if (sortBy === 'progress') {
|
||||
secondaryOrderBy.updatedAt = sortOrder
|
||||
} else {
|
||||
secondaryOrderBy[sortBy] = sortOrder
|
||||
}
|
||||
const orderBy: any[] = [{ favorited: 'desc' }, secondaryOrderBy]
|
||||
|
||||
const total = await prisma.course.count({ where })
|
||||
|
||||
const courses = await prisma.course.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip: Math.max(0, skip),
|
||||
take: limit,
|
||||
include: {
|
||||
_count: { select: { modules: true } },
|
||||
progress: {
|
||||
where: { userId: 'local-user', lessonId: null },
|
||||
},
|
||||
courseTags: {
|
||||
include: { tag: true },
|
||||
orderBy: { tag: { name: 'asc' } },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const courseIds = courses.map(c => c.id)
|
||||
const lessonCounts = await prisma.lesson.groupBy({
|
||||
by: ['moduleId'],
|
||||
where: { module: { courseId: { in: courseIds } } },
|
||||
_count: true,
|
||||
})
|
||||
|
||||
const moduleToCourse = new Map<string, string>()
|
||||
const modules = await prisma.module.findMany({
|
||||
where: { courseId: { in: courseIds } },
|
||||
select: { id: true, courseId: true },
|
||||
})
|
||||
modules.forEach(m => moduleToCourse.set(m.id, m.courseId))
|
||||
|
||||
const moduleLessonCounts = new Map<string, number>()
|
||||
lessonCounts.forEach(lc => {
|
||||
const courseId = moduleToCourse.get(lc.moduleId as string)
|
||||
if (courseId) {
|
||||
moduleLessonCounts.set(courseId, (moduleLessonCounts.get(courseId) || 0) + lc._count)
|
||||
}
|
||||
})
|
||||
|
||||
const coursesWithProgress = await Promise.all(courses.map(async (course) => {
|
||||
const courseProgress = course.progress[0]
|
||||
const totalLessons = moduleLessonCounts.get(course.id) || 0
|
||||
let completedLessons = 0
|
||||
let percentage = 0
|
||||
let lastWatched = null
|
||||
|
||||
if (courseProgress) {
|
||||
completedLessons = courseProgress.completed ? totalLessons : 0
|
||||
percentage = courseProgress.completed ? 100 : 0
|
||||
lastWatched = courseProgress.lastWatched.toISOString()
|
||||
}
|
||||
|
||||
return {
|
||||
...course,
|
||||
_count: { ...course._count, lessons: totalLessons },
|
||||
progress: { completedLessons, totalLessons, percentage, lastWatched },
|
||||
thumbnail: await getLocalThumbnailUrl(course.slug) ?? null,
|
||||
description: course.description,
|
||||
tags: course.courseTags.map((connection) => connection.tag),
|
||||
}
|
||||
}))
|
||||
|
||||
if (sortBy === 'progress') {
|
||||
coursesWithProgress.sort((a, b) =>
|
||||
sortOrder === 'asc'
|
||||
? a.progress.percentage - b.progress.percentage
|
||||
: b.progress.percentage - a.progress.percentage
|
||||
)
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
const tags = await prisma.tag.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: { _count: { select: { courseTags: true } } },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
courses: coursesWithProgress,
|
||||
tags,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Courses API error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch courses', details: String(error) },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { existsSync, statSync, createReadStream } from 'fs'
|
||||
import { join, resolve } from 'path'
|
||||
import { getCoursesRootPath } from '@/lib/scanner'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
try {
|
||||
const { path: pathSegments } = await params
|
||||
const filePath = pathSegments.join('/')
|
||||
|
||||
// Security: resolve and validate path is within COURSES_ROOT
|
||||
const coursesRoot = resolve(await getCoursesRootPath())
|
||||
const requestedPath = resolve(join(coursesRoot, filePath))
|
||||
|
||||
// Prevent directory traversal
|
||||
if (!requestedPath.startsWith(coursesRoot)) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
// Helper: try to find file with alternative extensions (e.g., .svg vs .png)
|
||||
const findActualFile = async (basePath: string): Promise<string | null> => {
|
||||
const extensions = ['.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp']
|
||||
for (const ext of extensions) {
|
||||
const tryPath = basePath.replace(/\.[^.]+$/, '') + ext
|
||||
if (existsSync(tryPath)) return tryPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
let actualPath = requestedPath
|
||||
if (!existsSync(actualPath)) {
|
||||
// Try alternative extensions (for thumbnails with wrong extension requests)
|
||||
const foundPath = await findActualFile(requestedPath)
|
||||
if (foundPath) {
|
||||
actualPath = foundPath
|
||||
} else {
|
||||
return new NextResponse('Not Found', { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
const stats = statSync(actualPath)
|
||||
|
||||
// Don't serve directories
|
||||
if (stats.isDirectory()) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
const fileSize = stats.size
|
||||
const range = request.headers.get('range')
|
||||
|
||||
// Determine content type from ACTUAL file extension
|
||||
const actualExt = actualPath.split('.').pop()?.toLowerCase()
|
||||
const mimeTypes: Record<string, string> = {
|
||||
mp4: 'video/mp4',
|
||||
mkv: 'video/x-matroska',
|
||||
webm: 'video/webm',
|
||||
mov: 'video/quicktime',
|
||||
avi: 'video/x-msvideo',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
m4a: 'audio/mp4',
|
||||
pdf: 'application/pdf',
|
||||
md: 'text/markdown; charset=utf-8',
|
||||
html: 'text/html; charset=utf-8',
|
||||
htm: 'text/html; charset=utf-8',
|
||||
json: 'application/json; charset=utf-8',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
srt: 'text/plain; charset=utf-8',
|
||||
vtt: 'text/vtt; charset=utf-8',
|
||||
txt: 'text/plain; charset=utf-8',
|
||||
}
|
||||
const contentType = mimeTypes[actualExt || ''] || 'application/octet-stream'
|
||||
|
||||
// Handle range requests (video seeking)
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, '').split('-')
|
||||
const start = parseInt(parts[0], 10)
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
|
||||
const chunkSize = end - start + 1
|
||||
|
||||
if (start >= fileSize || end >= fileSize) {
|
||||
return new NextResponse(null, {
|
||||
status: 416,
|
||||
headers: { 'Content-Range': `bytes */${fileSize}` },
|
||||
})
|
||||
}
|
||||
|
||||
const stream = createReadStream(actualPath, { start, end })
|
||||
|
||||
// Set Content-Disposition to inline for previewable file types
|
||||
const previewableTypes = ['pdf', 'markdown', 'html', 'plain', 'json', 'vtt', 'srt']
|
||||
const isPreviewable = previewableTypes.some(t => contentType.includes(t))
|
||||
const contentDisposition = isPreviewable ? 'inline' : 'attachment'
|
||||
|
||||
return new NextResponse(stream as any, {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunkSize.toString(),
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'Content-Disposition': `${contentDisposition}; filename="${actualPath.split('/').pop()}"`,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Full file response
|
||||
const stream = createReadStream(actualPath)
|
||||
|
||||
// Set Content-Disposition to inline for previewable file types
|
||||
const previewableTypes = ['pdf', 'markdown', 'html', 'plain', 'json', 'vtt', 'srt']
|
||||
const isPreviewable = previewableTypes.some(t => contentType.includes(t))
|
||||
const contentDisposition = isPreviewable ? 'inline' : 'attachment'
|
||||
|
||||
return new NextResponse(stream as any, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Length': fileSize.toString(),
|
||||
'Content-Type': contentType,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'Content-Disposition': `${contentDisposition}; filename="${actualPath.split('/').pop()}"`,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('File serve error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
}
|
||||
}
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ lessonId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { lessonId } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const courseSlug = searchParams.get('course')
|
||||
|
||||
// Find the lesson with its module and progress
|
||||
const lesson = await prisma.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
include: {
|
||||
module: true,
|
||||
progress: {
|
||||
where: { userId: 'local-user' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!lesson) {
|
||||
return NextResponse.json({ error: 'Lesson not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Fetch the course
|
||||
const course = await prisma.course.findFirst({
|
||||
where: { id: lesson.module.courseId, hidden: false },
|
||||
})
|
||||
|
||||
if (!course) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Fetch modules with lessons and progress
|
||||
const modules = await prisma.module.findMany({
|
||||
where: { courseId: course.id },
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
lessons: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
progress: {
|
||||
where: { userId: 'local-user' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch course-level progress, with a fallback to the latest watched lesson.
|
||||
const courseProgress = await prisma.progress.findFirst({
|
||||
where: { userId: 'local-user', courseId: course.id, lessonId: null, moduleId: null },
|
||||
})
|
||||
const latestLessonProgress = courseProgress
|
||||
? null
|
||||
: await prisma.progress.findFirst({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
courseId: course.id,
|
||||
lessonId: { not: null },
|
||||
},
|
||||
orderBy: { lastWatched: 'desc' },
|
||||
})
|
||||
|
||||
// Calculate all lessons
|
||||
const allLessons = modules.flatMap(m => m.lessons)
|
||||
const currentIndex = allLessons.findIndex(l => l.id === lessonId)
|
||||
const prevLesson = currentIndex > 0 ? allLessons[currentIndex - 1] : null
|
||||
const nextLesson = currentIndex < allLessons.length - 1 ? allLessons[currentIndex + 1] : null
|
||||
|
||||
const totalLessons = allLessons.length
|
||||
const completedLessons = allLessons.filter(l => l.progress[0]?.completed).length
|
||||
const percentage = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0
|
||||
|
||||
// Build response data
|
||||
const data = {
|
||||
lesson: {
|
||||
...lesson,
|
||||
progress: lesson.progress[0] ? {
|
||||
...lesson.progress[0],
|
||||
lastWatched: lesson.progress[0].lastWatched.toISOString()
|
||||
} : null,
|
||||
},
|
||||
course: {
|
||||
...course,
|
||||
progress: courseProgress
|
||||
? {
|
||||
...courseProgress,
|
||||
lastWatched: courseProgress.lastWatched.toISOString(),
|
||||
}
|
||||
: latestLessonProgress
|
||||
? {
|
||||
...latestLessonProgress,
|
||||
lastWatched: latestLessonProgress.lastWatched.toISOString(),
|
||||
}
|
||||
: null,
|
||||
modules: modules.map(m => ({
|
||||
...m,
|
||||
lessons: m.lessons.map(l => ({
|
||||
...l,
|
||||
progress: l.progress[0] ? {
|
||||
...l.progress[0],
|
||||
lastWatched: l.progress[0].lastWatched.toISOString()
|
||||
} : null,
|
||||
})),
|
||||
})),
|
||||
stats: {
|
||||
totalLessons,
|
||||
completedLessons,
|
||||
percentage,
|
||||
},
|
||||
},
|
||||
prevLesson,
|
||||
nextLesson,
|
||||
currentIndex,
|
||||
totalLessons: allLessons.length,
|
||||
}
|
||||
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Lesson fetch error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch lesson' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Executable
+320
@@ -0,0 +1,320 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const progressSchema = z.object({
|
||||
lessonId: z.string(),
|
||||
courseId: z.string(),
|
||||
moduleId: z.string(),
|
||||
position: z.number().min(0),
|
||||
completed: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const parsed = progressSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid payload', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
|
||||
const { lessonId, courseId, moduleId, position, completed } = parsed.data
|
||||
|
||||
const progress = await prisma.progress.upsert({
|
||||
where: {
|
||||
userId_lessonId: {
|
||||
userId: 'local-user',
|
||||
lessonId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
position,
|
||||
completed,
|
||||
lastWatched: new Date(),
|
||||
courseId,
|
||||
moduleId,
|
||||
},
|
||||
create: {
|
||||
userId: 'local-user',
|
||||
lessonId,
|
||||
courseId,
|
||||
moduleId,
|
||||
position,
|
||||
completed,
|
||||
lastWatched: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Also maintain a course-level summary row so course pages can resume correctly.
|
||||
// This is stored separately from lesson progress using null lessonId/moduleId.
|
||||
const totalLessons = await prisma.lesson.count({
|
||||
where: { module: { courseId } },
|
||||
})
|
||||
const completedLessons = await prisma.lesson.count({
|
||||
where: {
|
||||
module: { courseId },
|
||||
progress: { some: { userId: 'local-user', completed: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (totalLessons > 0) {
|
||||
const courseCompleted = completedLessons === totalLessons
|
||||
const courseProgressData = {
|
||||
userId: 'local-user',
|
||||
courseId,
|
||||
lessonId: null,
|
||||
moduleId: null,
|
||||
position,
|
||||
completed: courseCompleted,
|
||||
lastWatched: new Date(),
|
||||
}
|
||||
|
||||
const existingCourseProgress = await prisma.progress.findFirst({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
courseId,
|
||||
lessonId: null,
|
||||
moduleId: null,
|
||||
},
|
||||
})
|
||||
|
||||
if (existingCourseProgress) {
|
||||
await prisma.progress.update({
|
||||
where: { id: existingCourseProgress.id },
|
||||
data: courseProgressData,
|
||||
})
|
||||
} else {
|
||||
await prisma.progress.create({
|
||||
data: courseProgressData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, progress })
|
||||
} catch (error) {
|
||||
console.error('Progress save error:', error)
|
||||
return NextResponse.json({ error: 'Failed to save progress' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const lessonId = searchParams.get('lessonId')
|
||||
const courseId = searchParams.get('courseId')
|
||||
const type = searchParams.get('type') // 'continue' or 'completed'
|
||||
|
||||
if (type === 'continue') {
|
||||
// Get in-progress lessons
|
||||
const progressRecords = await prisma.progress.findMany({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
lessonId: { not: null },
|
||||
completed: false,
|
||||
lesson: { module: { course: { hidden: false } } },
|
||||
},
|
||||
orderBy: { lastWatched: 'desc' },
|
||||
take: 20,
|
||||
include: {
|
||||
lesson: {
|
||||
include: {
|
||||
module: {
|
||||
include: {
|
||||
course: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const items = progressRecords
|
||||
.filter(p => p.lesson)
|
||||
.map(p => ({
|
||||
progress: {
|
||||
...p,
|
||||
lastWatched: p.lastWatched.toISOString(),
|
||||
},
|
||||
lesson: p.lesson!,
|
||||
course: p.lesson!.module.course,
|
||||
module: p.lesson!.module,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ items })
|
||||
}
|
||||
|
||||
if (type === 'completed') {
|
||||
// Get completed courses
|
||||
const completedProgress = await prisma.progress.findMany({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
lessonId: null,
|
||||
moduleId: null,
|
||||
completed: true,
|
||||
course: { hidden: false },
|
||||
},
|
||||
include: {
|
||||
course: true,
|
||||
},
|
||||
})
|
||||
|
||||
const items = completedProgress.map(p => p.course)
|
||||
|
||||
return NextResponse.json({ items })
|
||||
}
|
||||
|
||||
if (type === 'analytics') {
|
||||
// Get analytics data for dashboard
|
||||
const allProgress = await prisma.progress.findMany({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
course: { hidden: false },
|
||||
},
|
||||
})
|
||||
|
||||
const completedLessons = allProgress.filter(p => p.lessonId && p.completed)
|
||||
const inProgressLessons = allProgress.filter(p => p.lessonId && !p.completed && p.position > 0)
|
||||
const notStartedCount = await prisma.lesson.count({
|
||||
where: {
|
||||
module: { course: { hidden: false } },
|
||||
progress: { none: { userId: 'local-user' } },
|
||||
},
|
||||
})
|
||||
|
||||
const totalCourses = await prisma.course.count({ where: { hidden: false } })
|
||||
const completedCourses = await prisma.progress.count({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
lessonId: null,
|
||||
moduleId: null,
|
||||
completed: true,
|
||||
course: { hidden: false },
|
||||
},
|
||||
})
|
||||
const inProgressCourses = await prisma.progress.count({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
lessonId: null,
|
||||
moduleId: null,
|
||||
completed: false,
|
||||
course: { hidden: false },
|
||||
},
|
||||
})
|
||||
|
||||
// Calculate total watched time (in seconds)
|
||||
const totalWatchedSeconds = allProgress.reduce((sum, p) => sum + (p.position || 0), 0)
|
||||
|
||||
// Get weekly activity (last 7 days)
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
const recentProgress = allProgress.filter(p => new Date(p.lastWatched) >= sevenDaysAgo)
|
||||
const weeklyWatchedSeconds = recentProgress.reduce((sum, p) => sum + (p.position || 0), 0)
|
||||
const weeklyCompleted = recentProgress.filter(p => p.lessonId && p.completed).length
|
||||
|
||||
// Lessons by type
|
||||
const lessonsByType = await prisma.lesson.groupBy({
|
||||
by: ['type'],
|
||||
_count: true,
|
||||
where: { module: { course: { hidden: false } } },
|
||||
})
|
||||
|
||||
// Completed lessons by type
|
||||
const completedByType: Record<string, number> = {}
|
||||
for (const p of completedLessons) {
|
||||
if (!p.lessonId) continue
|
||||
const lesson = await prisma.lesson.findUnique({ where: { id: p.lessonId }, select: { type: true } })
|
||||
if (lesson) {
|
||||
completedByType[lesson.type] = (completedByType[lesson.type] || 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
totalCourses,
|
||||
completedCourses,
|
||||
inProgressCourses,
|
||||
totalLessons: completedLessons.length + inProgressLessons.length + notStartedCount,
|
||||
completedLessons: completedLessons.length,
|
||||
inProgressLessons: inProgressLessons.length,
|
||||
notStartedLessons: notStartedCount,
|
||||
totalWatchedHours: Math.round(totalWatchedSeconds / 3600 * 10) / 10,
|
||||
totalWatchedMinutes: Math.round(totalWatchedSeconds / 60),
|
||||
weeklyWatchedHours: Math.round(weeklyWatchedSeconds / 3600 * 10) / 10,
|
||||
weeklyWatchedMinutes: Math.round(weeklyWatchedSeconds / 60),
|
||||
weeklyCompletedLessons: weeklyCompleted,
|
||||
lessonsByType,
|
||||
completedByType,
|
||||
completionRate: totalCourses > 0 ? Math.round((completedCourses / totalCourses) * 100) : 0,
|
||||
lessonCompletionRate: (completedLessons.length + inProgressLessons.length + notStartedCount) > 0
|
||||
? Math.round((completedLessons.length / (completedLessons.length + inProgressLessons.length + notStartedCount)) * 100)
|
||||
: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if (lessonId) {
|
||||
const progress = await prisma.progress.findUnique({
|
||||
where: {
|
||||
userId_lessonId: {
|
||||
userId: 'local-user',
|
||||
lessonId,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (progress) {
|
||||
return NextResponse.json({
|
||||
progress: {
|
||||
...progress,
|
||||
lastWatched: progress.lastWatched.toISOString()
|
||||
}
|
||||
})
|
||||
}
|
||||
return NextResponse.json({ progress: null })
|
||||
}
|
||||
|
||||
if (courseId) {
|
||||
const progress = await prisma.progress.findFirst({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
courseId,
|
||||
lessonId: null,
|
||||
moduleId: null,
|
||||
course: { hidden: false },
|
||||
},
|
||||
})
|
||||
|
||||
if (progress) {
|
||||
return NextResponse.json({
|
||||
progress: {
|
||||
...progress,
|
||||
lastWatched: progress.lastWatched.toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const latestLessonProgress = await prisma.progress.findFirst({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
courseId,
|
||||
lessonId: { not: null },
|
||||
course: { hidden: false },
|
||||
},
|
||||
orderBy: { lastWatched: 'desc' },
|
||||
})
|
||||
|
||||
if (latestLessonProgress) {
|
||||
return NextResponse.json({
|
||||
progress: {
|
||||
...latestLessonProgress,
|
||||
lastWatched: latestLessonProgress.lastWatched.toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ progress: null })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'lessonId, courseId or type required' }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error('Progress fetch error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch progress' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const requestSchema = z.object({
|
||||
lessonId: z.string().min(1),
|
||||
courseId: z.string().min(1),
|
||||
moduleId: z.string().min(1),
|
||||
score: z.number().int().min(0),
|
||||
totalQuestions: z.number().int().positive(),
|
||||
passed: z.boolean(),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const parsed = requestSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { lessonId, courseId, moduleId, score, totalQuestions, passed } = parsed.data
|
||||
|
||||
const quizAttempt = await prisma.quizAttempt.upsert({
|
||||
where: {
|
||||
userId_lessonId: {
|
||||
userId: 'local-user',
|
||||
lessonId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
score,
|
||||
passed,
|
||||
completed: passed,
|
||||
},
|
||||
create: {
|
||||
userId: 'local-user',
|
||||
lessonId,
|
||||
score,
|
||||
passed,
|
||||
completed: passed,
|
||||
},
|
||||
})
|
||||
|
||||
const progress = await prisma.progress.upsert({
|
||||
where: {
|
||||
userId_lessonId: {
|
||||
userId: 'local-user',
|
||||
lessonId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
courseId,
|
||||
moduleId,
|
||||
completed: passed,
|
||||
position: passed ? totalQuestions : score,
|
||||
lastWatched: new Date(),
|
||||
},
|
||||
create: {
|
||||
userId: 'local-user',
|
||||
lessonId,
|
||||
courseId,
|
||||
moduleId,
|
||||
completed: passed,
|
||||
position: passed ? totalQuestions : score,
|
||||
lastWatched: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, quizAttempt, progress })
|
||||
} catch (error) {
|
||||
console.error('Quiz attempt error:', error)
|
||||
return NextResponse.json({ error: 'Failed to save quiz attempt' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ensureModuleQuizCache, syncQuizLessonFromCache, shouldSkipQuizGeneration } from '@/lib/quiz'
|
||||
import type { QuizSource } from '@/lib/quiz-types'
|
||||
import { z } from 'zod'
|
||||
import { join, dirname } from 'path'
|
||||
|
||||
const requestSchema = z.object({
|
||||
lessonId: z.string().min(1),
|
||||
topic: z.string().min(1).optional(),
|
||||
source: z.enum(['quizapi', 'the-trivia-api']).optional(),
|
||||
force: z.boolean().optional(),
|
||||
})
|
||||
|
||||
async function getQuizSource(): Promise<QuizSource> {
|
||||
const setting = await prisma.setting.findUnique({ where: { key: 'quizApiSource' } })
|
||||
return setting?.value === 'the-trivia-api' ? 'the-trivia-api' : 'quizapi'
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const parsed = requestSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const lesson = await prisma.lesson.findUnique({
|
||||
where: { id: parsed.data.lessonId },
|
||||
include: {
|
||||
module: {
|
||||
include: {
|
||||
course: true,
|
||||
lessons: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!lesson) {
|
||||
return NextResponse.json({ error: 'Lesson not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!lesson.module.course.path) {
|
||||
return NextResponse.json({ error: 'Course path missing' }, { status: 400 })
|
||||
}
|
||||
|
||||
const source = parsed.data.source || await getQuizSource()
|
||||
const topic = parsed.data.topic?.trim() || lesson.module.name
|
||||
const modulePath = join(lesson.module.course.path, lesson.module.name)
|
||||
const moduleShouldSkip = await shouldSkipQuizGeneration(modulePath, topic)
|
||||
if (moduleShouldSkip) {
|
||||
return NextResponse.json({ error: 'Quiz generation skipped for this module' }, { status: 400 })
|
||||
}
|
||||
|
||||
const cache = await ensureModuleQuizCache({
|
||||
modulePath,
|
||||
topic,
|
||||
source,
|
||||
force: parsed.data.force ?? Boolean(parsed.data.topic),
|
||||
})
|
||||
|
||||
if (!cache) {
|
||||
return NextResponse.json({ error: 'Failed to generate quiz cache' }, { status: 500 })
|
||||
}
|
||||
|
||||
const synced = await syncQuizLessonFromCache({
|
||||
moduleId: lesson.moduleId,
|
||||
modulePath,
|
||||
courseRoot: dirname(lesson.module.course.path),
|
||||
topic,
|
||||
source,
|
||||
autoFetch: false,
|
||||
force: false,
|
||||
})
|
||||
|
||||
return NextResponse.json({ ...cache, lesson: synced?.lesson || null })
|
||||
} catch (error) {
|
||||
console.error('Quiz API error:', error)
|
||||
return NextResponse.json({ error: 'Failed to generate quiz' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { scanCoursesDirectory, scanCoursesFull } from '@/lib/scanner'
|
||||
|
||||
async function getQuizScanSettings() {
|
||||
const settings = await prisma.setting.findMany({
|
||||
where: { key: { in: ['autoFetchQuizzes', 'quizApiSource'] } },
|
||||
})
|
||||
|
||||
const settingsMap = Object.fromEntries(settings.map((setting) => [setting.key, setting.value]))
|
||||
return {
|
||||
autoFetchQuizzes: settingsMap.autoFetchQuizzes === 'true',
|
||||
quizApiSource: settingsMap.quizApiSource === 'the-trivia-api'
|
||||
? 'the-trivia-api'
|
||||
: 'quizapi',
|
||||
} as const
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const isFullScan = body.fullScan === true
|
||||
const quizSettings = await getQuizScanSettings()
|
||||
|
||||
const result = isFullScan
|
||||
? await scanCoursesFull(quizSettings)
|
||||
: await scanCoursesDirectory(quizSettings)
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
return NextResponse.json({
|
||||
...result,
|
||||
duration,
|
||||
success: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Scan API error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Scan failed', details: String(error), success: false },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(
|
||||
{ error: 'Method not allowed. Use POST to trigger scan.' },
|
||||
{ status: 405 }
|
||||
)
|
||||
}
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const SETTINGS_KEYS = ['coursesRoot', 'autoFetchQuizzes', 'quizApiSource', 'quizApiKey'] as const
|
||||
const SETTINGS_KEYS_ARRAY: string[] = [...SETTINGS_KEYS]
|
||||
|
||||
const singleSettingSchema = z.object({
|
||||
key: z.enum(['coursesRoot', 'autoFetchQuizzes', 'quizApiSource', 'quizApiKey']),
|
||||
value: z.union([z.string(), z.boolean()]),
|
||||
})
|
||||
|
||||
const bulkSettingsSchema = z.object({
|
||||
coursesRoot: z.string().min(1).optional(),
|
||||
autoFetchQuizzes: z.union([z.boolean(), z.string()]).optional(),
|
||||
quizApiSource: z.enum(['the-trivia-api', 'quizapi']).optional(),
|
||||
quizApiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const settings = await prisma.setting.findMany({
|
||||
where: { key: { in: SETTINGS_KEYS_ARRAY } },
|
||||
})
|
||||
|
||||
const settingsMap: Record<string, string> = {}
|
||||
for (const s of settings) {
|
||||
settingsMap[s.key] = s.value
|
||||
}
|
||||
|
||||
// Default coursesRoot if not set
|
||||
if (!settingsMap.coursesRoot) {
|
||||
settingsMap.coursesRoot = './My_Courses'
|
||||
}
|
||||
|
||||
if (!settingsMap.autoFetchQuizzes) {
|
||||
settingsMap.autoFetchQuizzes = 'false'
|
||||
}
|
||||
|
||||
if (!settingsMap.quizApiSource) {
|
||||
settingsMap.quizApiSource = 'quizapi'
|
||||
}
|
||||
|
||||
// Always include quizApiKey in response (empty string if not set)
|
||||
settingsMap.quizApiKey = settingsMap.quizApiKey || ''
|
||||
console.log('[DEBUG] Settings response:', JSON.stringify(settingsMap))
|
||||
|
||||
return NextResponse.json(settingsMap)
|
||||
} catch (error) {
|
||||
console.error('Settings fetch error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
const singleParsed = singleSettingSchema.safeParse(body)
|
||||
if (singleParsed.success) {
|
||||
const { key, value } = singleParsed.data
|
||||
const normalizedValue = typeof value === 'boolean' ? String(value) : value
|
||||
|
||||
const setting = await prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value: normalizedValue },
|
||||
create: { key, value: normalizedValue },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, setting })
|
||||
}
|
||||
|
||||
const bulkParsed = bulkSettingsSchema.safeParse(body)
|
||||
if (!bulkParsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request', details: bulkParsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const entries = Object.entries(bulkParsed.data).filter(([, value]) => value !== undefined)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return NextResponse.json({ error: 'No settings provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const savedSettings = []
|
||||
for (const [key, value] of entries) {
|
||||
const normalizedValue = typeof value === 'boolean' ? String(value) : value
|
||||
const setting = await prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value: normalizedValue },
|
||||
create: { key, value: normalizedValue },
|
||||
})
|
||||
savedSettings.push(setting)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, settings: savedSettings })
|
||||
} catch (error) {
|
||||
console.error('Settings update error:', error)
|
||||
return NextResponse.json({ error: 'Failed to update setting' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
ArrowLeft,
|
||||
RotateCcw,
|
||||
Share2,
|
||||
Settings,
|
||||
BrainCircuit,
|
||||
Loader2,
|
||||
Star,
|
||||
Tags,
|
||||
FolderPlus,
|
||||
PencilLine,
|
||||
Trash2,
|
||||
Bomb,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
type CourseActionsProps = {
|
||||
course: {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
path: string
|
||||
favorited?: boolean
|
||||
}
|
||||
modules: any[]
|
||||
}
|
||||
|
||||
export function CourseActions({ course, modules }: CourseActionsProps) {
|
||||
const router = useRouter()
|
||||
const [menuOpen, setMenuOpen] = React.useState(false)
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [status, setStatus] = React.useState<{ kind: 'success' | 'error'; text: string } | null>(null)
|
||||
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 [quizResults, setQuizResults] = React.useState<{ ok: number; skipped: number } | null>(null)
|
||||
|
||||
const refreshLibrary = async () => {
|
||||
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)
|
||||
setStatus({ kind: 'error', text: '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 [clearQuizModule, setClearQuizModule] = React.useState<string>('')
|
||||
|
||||
const [clearQuizMode, setClearQuizMode] = React.useState(false)
|
||||
|
||||
const clearQuiz = async () => {
|
||||
if (!clearQuizModule) {
|
||||
setStatus({ kind: 'error', text: 'Pick a module to clear first.' })
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(`Remove quiz cache and quiz lesson for module "${clearQuizModule}"? This cannot be undone.`)
|
||||
if (!confirmed) return
|
||||
|
||||
setQuizStatus('running')
|
||||
setQuizMessage('Clearing quiz...')
|
||||
setClearQuizMode(false)
|
||||
setClearQuizModule('')
|
||||
try {
|
||||
const response = await fetch(`/api/courses/${course.slug}/quiz`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'clear', module: clearQuizModule }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Failed to clear quiz')
|
||||
}
|
||||
|
||||
await refreshLibrary()
|
||||
router.refresh()
|
||||
setQuizStatus('success')
|
||||
setQuizMessage(`✅ Cleared quiz for ${clearQuizModule}`)
|
||||
setQuizResults(null)
|
||||
} catch (error) {
|
||||
console.error('Clear quiz failed:', error)
|
||||
setQuizStatus('error')
|
||||
setQuizMessage(error instanceof Error ? error.message : 'Failed to clear quiz.')
|
||||
}
|
||||
}
|
||||
|
||||
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 okCount = data.results?.filter((item: { status: string }) => item.status === 'ok').length ?? 0
|
||||
const skippedCount = data.results?.filter((item: { status: string }) => item.status === 'skipped').length ?? 0
|
||||
setQuizResults({ ok: okCount, skipped: skippedCount })
|
||||
await refreshLibrary()
|
||||
router.refresh()
|
||||
setQuizStatus('success')
|
||||
setQuizMessage(`✅ Regenerated ${okCount} module quiz${okCount !== 1 ? 's' : ''}${skippedCount ? ` (${skippedCount} skipped)` : ''}`)
|
||||
} catch (error) {
|
||||
console.error('Regenerate quiz failed:', error)
|
||||
setQuizStatus('error')
|
||||
setQuizMessage(error instanceof Error ? error.message : 'Quiz regeneration failed.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRename = async () => {
|
||||
const nextName = window.prompt('Rename course', course.name)
|
||||
if (nextName === null) return
|
||||
|
||||
const trimmed = nextName.trim()
|
||||
if (!trimmed || trimmed === course.name) 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)
|
||||
setStatus({ kind: 'error', text: '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 "${course.name}" from disk?\n\nThis permanently removes the course folder and all library records. This cannot be undone.`
|
||||
: `Delete "${course.name}" 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()
|
||||
// Navigate back to library after deletion
|
||||
router.push('/')
|
||||
} catch (error) {
|
||||
console.error('Delete course failed:', error)
|
||||
setStatus({ kind: 'error', text: 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.' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="border-b bg-card/50 backdrop-blur-sm">
|
||||
<div className="container mx-auto flex items-center justify-between gap-4 px-4 py-3">
|
||||
<Link href="/" className="flex items-center gap-2 text-muted-foreground transition-colors hover:text-foreground">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span className="hidden sm:inline ml-1">Back to Library</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Rescan
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<Share2 className="h-4 w-4" />
|
||||
Share
|
||||
</Button>
|
||||
<Link href="/settings">
|
||||
<Button variant="ghost" size="sm" className="gap-1" aria-label="Settings">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="https://ko-fi.com/nicetry247" target="_blank" rel="noreferrer noopener" className="header-control p-1.5" aria-label="Support on Ko-fi">
|
||||
<img src="/ko-fi-icon.gif" alt="Support on Ko-fi" className="h-8 w-8 object-contain" />
|
||||
</Link>
|
||||
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9" aria-label={`${course.name} course actions`}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="5" r="1" />
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="12" cy="19" r="1" />
|
||||
</svg>
|
||||
</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={(event) => { event.preventDefault(); setClearQuizMode(value => !value) }} disabled={quizStatus === 'running'} className="gap-2">
|
||||
{quizStatus === 'running' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
{clearQuizMode ? 'Cancel' : 'Clear Quiz'}
|
||||
</DropdownMenuItem>
|
||||
{clearQuizMode && (
|
||||
<div className="px-2 py-2" onClick={(event) => event.stopPropagation()}>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">Module to clear</label>
|
||||
<select
|
||||
value={clearQuizModule}
|
||||
onChange={(event) => setClearQuizModule(event.target.value)}
|
||||
className="mb-2 h-9 w-full rounded-md border border-input bg-background px-2 text-sm outline-none focus:ring-2 focus:ring-primary/40"
|
||||
autoFocus
|
||||
>
|
||||
<option value="">Select module...</option>
|
||||
{modules.map((module: any) => (
|
||||
<option key={module.path || module.name} value={module.name}>
|
||||
{module.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => { setClearQuizMode(false); setClearQuizModule('') }}>Cancel</Button>
|
||||
<Button type="button" size="sm" onClick={clearQuiz} disabled={!clearQuizModule}>Clear</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{status ? <span className={`text-xs ${status.kind === 'success' ? 'text-primary' : 'text-destructive'}`}>{status.text}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(quizStatus === 'success' || quizStatus === 'error') && quizMessage && (
|
||||
<div
|
||||
className={`fixed bottom-4 right-4 z-50 animate-slide-in flex items-center gap-3 px-4 py-3 rounded-lg border shadow-lg min-w-[300px] max-w-md ${
|
||||
quizStatus === 'success'
|
||||
? 'bg-green-500/10 border-green-500/30 text-green-400'
|
||||
: 'bg-red-500/10 border-red-500/30 text-red-400'
|
||||
}`}
|
||||
role="alert"
|
||||
>
|
||||
<span className="flex-1 text-sm">{quizMessage}</span>
|
||||
{quizResults && (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
{quizResults.ok} generated
|
||||
{quizResults.skipped > 0 && <span>·</span>}
|
||||
{quizResults.skipped > 0 && <span>{quizResults.skipped} skipped</span>}
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => { setQuizStatus('idle'); setQuizMessage(null); setQuizResults(null); }}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading || quizStatus === 'running' ? <Progress value={70} className="h-1 rounded-none gradient-progress" /> : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
Executable
+173
@@ -0,0 +1,173 @@
|
||||
import Link from 'next/link'
|
||||
import { CourseActions } from './CourseActions'
|
||||
import { ModuleAccordion } from '@/components/ModuleAccordion'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Play, BookOpen } from 'lucide-react'
|
||||
import { getCourseDisplayName } from '@/lib/course-display'
|
||||
import { formatDuration } from '@/lib/utils'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
interface CoursePageProps {
|
||||
params: Promise<{ slug: string }>
|
||||
}
|
||||
|
||||
async function getCourse(slug: string) {
|
||||
const course = await prisma.course.findFirst({
|
||||
where: { slug, hidden: false },
|
||||
include: {
|
||||
modules: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
lessons: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
progress: {
|
||||
where: { userId: 'local-user' },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!course) return null
|
||||
|
||||
const modules = course.modules.map((module) => ({
|
||||
...module,
|
||||
lessons: module.lessons.map((lesson) => ({
|
||||
...lesson,
|
||||
progress: lesson.progress[0]
|
||||
? {
|
||||
completed: lesson.progress[0].completed,
|
||||
position: lesson.progress[0].position,
|
||||
lastWatched: lesson.progress[0].lastWatched.toISOString(),
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
const allLessons = modules.flatMap((module) => module.lessons)
|
||||
const completedLessons = allLessons.filter((lesson) => lesson.progress?.completed).length
|
||||
const totalLessons = allLessons.length
|
||||
const totalDuration = allLessons.reduce((sum, lesson) => sum + (lesson.duration || 0), 0)
|
||||
const percentage = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0
|
||||
const latestProgress = await prisma.progress.findFirst({
|
||||
where: { userId: 'local-user', courseId: course.id, lessonId: { not: null } },
|
||||
orderBy: { lastWatched: 'desc' },
|
||||
select: { lessonId: true },
|
||||
})
|
||||
|
||||
return {
|
||||
...course,
|
||||
modules,
|
||||
stats: {
|
||||
totalLessons,
|
||||
completedLessons,
|
||||
totalDuration,
|
||||
percentage,
|
||||
lastLessonId: latestProgress?.lessonId || null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function CoursePage({ params }: CoursePageProps) {
|
||||
const { slug } = await params
|
||||
const course = await getCourse(slug)
|
||||
if (!course) return null
|
||||
|
||||
const courseTitle = getCourseDisplayName(course)
|
||||
const modules = course.modules || []
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<CourseActions course={course} modules={modules} />
|
||||
|
||||
<section className="border-b bg-card/50 backdrop-blur-sm">
|
||||
<div className="container mx-auto px-4 sm:py-12">
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-center">
|
||||
<div className="aspect-video w-full overflow-hidden rounded-lg bg-muted lg:w-64 lg:flex-shrink-0">
|
||||
{course.thumbnail ? (
|
||||
<img src={course.thumbnail} alt={courseTitle} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-primary/20 to-primary/5">
|
||||
<BookOpen className="h-16 w-16 text-primary/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{modules.length} modules</Badge>
|
||||
<Badge variant="secondary">{course.stats.totalLessons} lessons</Badge>
|
||||
<Badge variant="outline">{formatDuration(course.stats.totalDuration)}</Badge>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold sm:text-4xl">{courseTitle}</h1>
|
||||
{course.description && <p className="text-muted-foreground line-clamp-3">{course.description}</p>}
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>{course.stats.completedLessons} / {course.stats.totalLessons} lessons completed</span>
|
||||
<span className="font-semibold text-primary">{course.stats.percentage}%</span>
|
||||
</div>
|
||||
<Progress value={course.stats.percentage} className="mt-2 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main className="container mx-auto grid grid-cols-1 gap-6 px-4 py-8 lg:grid-cols-4">
|
||||
<Card className="h-fit lg:sticky lg:top-20">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
Course Content
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-[calc(100vh-12rem)] overflow-y-auto p-0">
|
||||
<div className="p-4">
|
||||
<ModuleAccordion courseId={course.id} modules={modules} currentLessonId={course.stats.lastLessonId || undefined} courseSlug={course.slug} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="lg:col-span-3">
|
||||
<Card>
|
||||
<CardContent className="space-y-4 py-10 text-center">
|
||||
{course.stats.lastLessonId ? (
|
||||
<>
|
||||
<Play className="mx-auto h-12 w-12 text-primary/50" />
|
||||
<h3 className="text-lg font-semibold">Ready to continue?</h3>
|
||||
<p className="text-sm text-muted-foreground">You left off at <strong>Lesson {(modules.flatMap((module: any) => module.lessons).find((lesson: any) => lesson.id === course.stats.lastLessonId)?.order ?? 0) + 1}</strong></p>
|
||||
<Link href={`/watch/${course.stats.lastLessonId}?course=${course.slug}`}>
|
||||
<Button className="gap-2">
|
||||
<Play className="h-5 w-5" />
|
||||
Continue Watching
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BookOpen className="mx-auto h-12 w-12 text-primary/50" />
|
||||
<h3 className="text-lg font-semibold">Start Learning</h3>
|
||||
<p className="text-sm text-muted-foreground">Select a lesson from the sidebar to begin.</p>
|
||||
{modules[0]?.lessons[0] && (
|
||||
<Link href={`/watch/${modules[0].lessons[0].id}?course=${course.slug}`}>
|
||||
<Button className="gap-2">
|
||||
<Play className="h-5 w-5" />
|
||||
Start First Lesson
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Executable
+346
@@ -0,0 +1,346 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 220 25% 4%;
|
||||
--background-elevated: 220 20% 6%;
|
||||
--background-glass: 220 20% 8% / 0.7;
|
||||
--foreground: 210 40% 96%;
|
||||
--foreground-muted: 215 25% 65%;
|
||||
--foreground-subtle: 215 20% 45%;
|
||||
--card: 220 20% 7% / 0.8;
|
||||
--card-foreground: 210 40% 96%;
|
||||
--card-border: 180 50% 25% / 0.3;
|
||||
--card-border-hover: 160 100% 45% / 0.5;
|
||||
--popover: 220 20% 6%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 160 100% 42%;
|
||||
--primary-glow: 160 100% 42% / 0.4;
|
||||
--primary-foreground: 220 25% 4%;
|
||||
--primary-muted: 160 100% 42% / 0.15;
|
||||
--accent: 45 100% 58%;
|
||||
--accent-glow: 45 100% 58% / 0.4;
|
||||
--accent-foreground: 220 25% 4%;
|
||||
--accent-muted: 45 100% 58% / 0.15;
|
||||
--secondary: 220 15% 12%;
|
||||
--secondary-foreground: 210 40% 96%;
|
||||
--secondary-border: 220 15% 18%;
|
||||
--muted: 220 15% 10%;
|
||||
--muted-foreground: 215 20% 55%;
|
||||
--destructive: 0 75% 55%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 220 15% 18%;
|
||||
--input: 220 15% 12%;
|
||||
--ring: 160 100% 42%;
|
||||
--radius: 0.75rem;
|
||||
--radius-sm: 0.5rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-xl: 1.5rem;
|
||||
}
|
||||
|
||||
.light {
|
||||
--background: 220 25% 97%;
|
||||
--background-elevated: 220 20% 100%;
|
||||
--background-glass: 220 20% 98% / 0.7;
|
||||
--foreground: 220 25% 4%;
|
||||
--foreground-muted: 215 25% 35%;
|
||||
--foreground-subtle: 215 20% 25%;
|
||||
--card: 220 20% 93% / 0.8;
|
||||
--card-foreground: 220 25% 4%;
|
||||
--card-border: 180 50% 75% / 0.3;
|
||||
--card-border-hover: 160 100% 35% / 0.5;
|
||||
--popover: 220 20% 100%;
|
||||
--popover-foreground: 220 25% 4%;
|
||||
--primary: 160 100% 25%;
|
||||
--primary-glow: 160 100% 30% / 0.4;
|
||||
--primary-foreground: 220 25% 4%;
|
||||
--primary-muted: 160 100% 90% / 0.15;
|
||||
--accent: 45 100% 38%;
|
||||
--accent-glow: 45 100% 40% / 0.4;
|
||||
--accent-foreground: 220 25% 4%;
|
||||
--accent-muted: 45 100% 90% / 0.15;
|
||||
--secondary: 220 15% 88%;
|
||||
--secondary-foreground: 220 25% 4%;
|
||||
--secondary-border: 220 15% 80%;
|
||||
--muted: 220 15% 90%;
|
||||
--muted-foreground: 215 20% 35%;
|
||||
--destructive: 0 75% 45%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 220 15% 80%;
|
||||
--input: 220 15% 88%;
|
||||
--ring: 160 100% 30%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* { @apply border-border; }
|
||||
html { @apply scroll-smooth; }
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
background-image: radial-gradient(ellipse 80% 50% at 50% -20%, hsl(var(--background-elevated) / 0.6), transparent);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
html.light body {
|
||||
background-image: radial-gradient(ellipse 80% 50% at 50% -20%, hsl(220 25% 92% / 0.5), transparent);
|
||||
}
|
||||
::selection { @apply bg-primary/30 text-primary-foreground; }
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Border color utilities for @apply */
|
||||
.border-secondary-border { border-color: hsl(var(--secondary-border)); }
|
||||
.border-primary\/20 { border-color: hsl(var(--primary) / 0.2); }
|
||||
.border-primary\/30 { border-color: hsl(var(--primary) / 0.3); }
|
||||
.border-green-500\/30 { border-color: hsl(140 100% 30% / 0.3); }
|
||||
.border-emerald-500\/30 { border-color: hsl(160 100% 30% / 0.3); }
|
||||
.border-accent\/30 { border-color: hsl(var(--accent) / 0.3); }
|
||||
.border-white\/5 { border-color: hsl(0 0% 100% / 0.05); }
|
||||
.border-white\/10 { border-color: hsl(0 0% 100% / 0.1); }
|
||||
.border-white\/20 { border-color: hsl(0 0% 100% / 0.2); }
|
||||
.border-black\/60 { border-color: hsl(0 0% 0% / 0.6); }
|
||||
|
||||
/* Background color utilities */
|
||||
.bg-secondary-border { background-color: hsl(var(--secondary-border)); }
|
||||
.bg-primary-muted { background-color: hsl(var(--primary-muted)); }
|
||||
.bg-accent-muted { background-color: hsl(var(--accent-muted)); }
|
||||
.bg-card-border { background-color: hsl(var(--card-border)); }
|
||||
.bg-primary\/10 { background-color: hsl(var(--primary) / 0.1); }
|
||||
.bg-primary\/15 { background-color: hsl(var(--primary) / 0.15); }
|
||||
.bg-accent\/10 { background-color: hsl(var(--accent) / 0.1); }
|
||||
.bg-accent\/15 { background-color: hsl(var(--accent) / 0.15); }
|
||||
.bg-green-500\/15 { background-color: hsl(140 100% 30% / 0.15); }
|
||||
.bg-emerald-500\/15 { background-color: hsl(160 100% 30% / 0.15); }
|
||||
.bg-blue-500\/15 { background-color: hsl(220 100% 50% / 0.15); }
|
||||
.bg-primary\/10 { background-color: hsl(var(--primary) / 0.1); }
|
||||
.bg-white\/5 { background-color: hsl(0 0% 100% / 0.05); }
|
||||
.bg-black\/60 { background-color: hsl(0 0% 0% / 0.6); }
|
||||
.bg-primary\/90 { background-color: hsl(var(--primary) / 0.9); }
|
||||
.bg-accent\/90 { background-color: hsl(var(--accent) / 0.9); }
|
||||
.bg-primary\/20 { background-color: hsl(var(--primary) / 0.2); }
|
||||
|
||||
/* Text color utilities */
|
||||
.text-foreground-muted { color: hsl(var(--foreground-muted)); }
|
||||
.text-foreground-subtle { color: hsl(var(--foreground-subtle)); }
|
||||
.text-muted-foreground { color: hsl(var(--muted-foreground)); }
|
||||
.text-primary-foreground { color: hsl(var(--primary-foreground)); }
|
||||
.text-accent-foreground { color: hsl(var(--accent-foreground)); }
|
||||
.text-primary { color: hsl(var(--primary)); }
|
||||
.text-accent { color: hsl(var(--accent)); }
|
||||
.text-emerald-400 { color: hsl(160 100% 45%); }
|
||||
.text-cyan-400 { color: hsl(180 100% 50%); }
|
||||
.text-green-500 { color: hsl(140 100% 40%); }
|
||||
|
||||
.glass-card {
|
||||
@apply relative rounded-xl border bg-card/60 backdrop-blur-xl;
|
||||
border-color: hsl(var(--card-border));
|
||||
box-shadow:
|
||||
0 4px 24px -4px hsl(var(--background) / 0.5),
|
||||
0 0 0 1px hsl(var(--card-border)) inset,
|
||||
0 1px 2px -1px hsl(var(--background) / 0.3) inset;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.glass-card:hover {
|
||||
border-color: hsl(var(--card-border-hover));
|
||||
box-shadow:
|
||||
0 8px 32px -8px hsl(var(--primary) / 0.2),
|
||||
0 0 0 1px hsl(var(--card-border-hover)) inset,
|
||||
0 1px 2px -1px hsl(var(--background) / 0.3) inset;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.glass-card-elevated {
|
||||
@apply glass-card;
|
||||
box-shadow:
|
||||
0 12px 48px -12px hsl(var(--background) / 0.6),
|
||||
0 0 0 1px hsl(var(--card-border)) inset,
|
||||
0 4px 16px -4px hsl(var(--background) / 0.4) inset;
|
||||
}
|
||||
|
||||
.btn-premium {
|
||||
@apply relative inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50;
|
||||
}
|
||||
.btn-premium-primary {
|
||||
@apply btn-premium bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98];
|
||||
}
|
||||
.btn-premium-primary:hover {
|
||||
box-shadow: 0 0 25px -5px hsl(var(--primary-glow));
|
||||
}
|
||||
.btn-premium-secondary {
|
||||
@apply btn-premium bg-secondary text-secondary-foreground border border-secondary-border hover:bg-secondary/80 hover:border-border hover:shadow-lg;
|
||||
}
|
||||
.btn-premium-ghost {
|
||||
@apply btn-premium bg-transparent hover:bg-secondary text-foreground hover:text-primary hover:border-primary/30 border border-transparent;
|
||||
}
|
||||
.btn-premium-accent {
|
||||
@apply btn-premium bg-accent text-accent-foreground hover:bg-accent/90 active:scale-[0.98];
|
||||
}
|
||||
.btn-premium-accent:hover {
|
||||
box-shadow: 0 0 25px -5px hsl(var(--accent-glow));
|
||||
}
|
||||
.btn-premium-icon { @apply btn-premium p-2 rounded-lg; }
|
||||
.btn-premium-icon-sm { @apply btn-premium p-1.5 rounded-lg text-xs; }
|
||||
|
||||
.header-control {
|
||||
@apply relative flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium text-foreground-muted transition-all duration-200 hover:text-foreground hover:bg-secondary hover:border-primary/20 border border-transparent;
|
||||
}
|
||||
.header-control:hover {
|
||||
box-shadow: 0 0 15px -3px hsl(var(--primary) / 0.2);
|
||||
}
|
||||
.header-control-active {
|
||||
@apply header-control text-primary border-primary/30 bg-primary/10;
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
@apply flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-secondary/50 border border-secondary-border text-xs font-medium text-muted-foreground;
|
||||
}
|
||||
.stat-badge-icon {
|
||||
@apply flex items-center justify-center w-6 h-6 rounded-lg bg-primary/15 text-primary;
|
||||
}
|
||||
.stat-badge-icon-accent {
|
||||
@apply flex items-center justify-center w-6 h-6 rounded-lg bg-accent/15 text-accent;
|
||||
}
|
||||
.stat-badge-icon-green {
|
||||
@apply flex items-center justify-center w-6 h-6 rounded-lg bg-green-500/15 text-green-500;
|
||||
}
|
||||
.stat-badge-icon-blue {
|
||||
@apply flex items-center justify-center w-6 h-6 rounded-lg bg-blue-500/15 text-blue-500;
|
||||
}
|
||||
|
||||
.course-cover {
|
||||
@apply relative w-full aspect-video rounded-lg overflow-hidden;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
.course-cover-hashicorp {
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 20%, hsl(180 80% 35% / 0.3), transparent 50%),
|
||||
linear-gradient(135deg, hsl(var(--background) / 1) 0%, hsl(var(--background) / 1) 100%);
|
||||
}
|
||||
.course-cover-linux {
|
||||
background:
|
||||
radial-gradient(ellipse at 70% 80%, hsl(120 80% 35% / 0.3), transparent 50%),
|
||||
linear-gradient(135deg, hsl(var(--background) / 1) 0%, hsl(var(--background) / 1) 100%);
|
||||
}
|
||||
.course-cover-code {
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 50%, hsl(260 80% 45% / 0.25), transparent 60%),
|
||||
linear-gradient(135deg, hsl(var(--background) / 1) 0%, hsl(var(--background) / 1) 100%);
|
||||
}
|
||||
.course-cover-automation {
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 20%, hsl(45 100% 50% / 0.3), transparent 50%),
|
||||
radial-gradient(ellipse at 80% 80%, hsl(160 100% 42% / 0.3), transparent 50%),
|
||||
linear-gradient(135deg, hsl(var(--background) / 1) 0%, hsl(var(--background) / 1) 100%);
|
||||
}
|
||||
|
||||
.video-thumb {
|
||||
@apply relative w-full aspect-video rounded-lg overflow-hidden;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
.video-thumb-code { background: linear-gradient(135deg, hsl(var(--muted) / 1) 0%, hsl(var(--background) / 1) 100%); }
|
||||
.video-thumb-flow { background: linear-gradient(135deg, hsl(var(--muted) / 1) 0%, hsl(var(--primary) / 0.15) 100%); }
|
||||
.video-thumb-presenter { background: linear-gradient(135deg, hsl(var(--muted) / 1) 0%, hsl(var(--accent) / 0.15) 100%); }
|
||||
.video-thumb-linux { background: linear-gradient(135deg, hsl(var(--muted) / 1) 0%, hsl(var(--primary) / 0.1) 100%); }
|
||||
|
||||
.thumb-overlay {
|
||||
@apply absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 transition-opacity duration-300;
|
||||
}
|
||||
.group:hover .thumb-overlay {
|
||||
@apply opacity-100;
|
||||
}
|
||||
.play-button-glow {
|
||||
@apply relative w-16 h-16 rounded-full bg-primary/90 flex items-center justify-center text-primary-foreground transition-all duration-300 hover:scale-110 hover:bg-primary;
|
||||
box-shadow: 0 0 30px -5px hsl(var(--primary-glow)), 0 8px 24px -8px hsl(var(--background) / 0.5);
|
||||
}
|
||||
.play-button-glow::before {
|
||||
content: '';
|
||||
@apply absolute inset-0 rounded-full bg-primary/30 animate-pulse;
|
||||
}
|
||||
|
||||
.gradient-progress {
|
||||
@apply relative h-2 rounded-full overflow-hidden bg-secondary;
|
||||
}
|
||||
.gradient-progress::-webkit-progress-bar {
|
||||
@apply bg-secondary rounded-full;
|
||||
}
|
||||
.gradient-progress::-webkit-progress-value {
|
||||
@apply rounded-full;
|
||||
background: linear-gradient(90deg, hsl(160 100% 42%), hsl(150 100% 45%), hsl(140 100% 48%));
|
||||
background-size: 200% 100%;
|
||||
animation: progress-shimmer 2s ease-in-out infinite;
|
||||
box-shadow: 0 0 10px -2px hsl(160 100% 42% / 0.6);
|
||||
}
|
||||
.gradient-progress-accent::-webkit-progress-value {
|
||||
background: linear-gradient(90deg, hsl(45 100% 58%), hsl(35 100% 62%), hsl(25 100% 66%));
|
||||
background-size: 200% 100%;
|
||||
animation: progress-shimmer 2s ease-in-out infinite;
|
||||
box-shadow: 0 0 10px -2px hsl(45 100% 58% / 0.6);
|
||||
}
|
||||
progress.gradient-progress {
|
||||
@apply appearance-none;
|
||||
background: linear-gradient(hsl(var(--secondary)), hsl(var(--secondary)));
|
||||
}
|
||||
progress.gradient-progress::-webkit-progress-bar {
|
||||
@apply bg-secondary rounded-full;
|
||||
}
|
||||
progress.gradient-progress::-webkit-progress-value {
|
||||
@apply rounded-full;
|
||||
background: linear-gradient(90deg, hsl(160 100% 42%), hsl(150 100% 45%), hsl(140 100% 48%));
|
||||
background-size: 200% 100%;
|
||||
animation: progress-shimmer 2s ease-in-out infinite;
|
||||
box-shadow: 0 0 10px -2px hsl(160 100% 42% / 0.6);
|
||||
}
|
||||
progress.gradient-progress-accent::-webkit-progress-value {
|
||||
background: linear-gradient(90deg, hsl(45 100% 58%), hsl(35 100% 62%), hsl(25 100% 66%));
|
||||
background-size: 200% 100%;
|
||||
animation: progress-shimmer 2s ease-in-out infinite;
|
||||
box-shadow: 0 0 10px -2px hsl(45 100% 58% / 0.6);
|
||||
}
|
||||
@keyframes progress-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: hsl(var(--secondary) / 0.65); border-radius: 9999px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--primary) / 0.65);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid hsl(var(--secondary) / 0.65);
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: hsl(var(--primary) / 0.9); }
|
||||
.custom-scrollbar { scrollbar-width: thin; scrollbar-color: hsl(var(--primary) / 0.7) hsl(var(--secondary) / 0.65); }
|
||||
|
||||
.text-gradient-primary {
|
||||
background: linear-gradient(135deg, hsl(160 100% 42%), hsl(150 100% 50%));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.text-gradient-accent {
|
||||
background: linear-gradient(135deg, hsl(45 100% 58%), hsl(35 100% 62%));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.line-clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
|
||||
.video-player-container { @apply relative bg-black rounded-lg overflow-hidden; }
|
||||
.video-player-container video { @apply w-full h-full object-contain; }
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance { text-wrap: balance; }
|
||||
.animate-fade-in { animation: fadeIn 0.6s ease-out forwards; opacity: 0; }
|
||||
.animate-slide-up { animation: slideUp 0.6s ease-out forwards; opacity: 0; transform: translateY(20px); }
|
||||
.animate-slide-in { animation: slideIn 0.3s ease-out forwards; opacity: 0; transform: translateX(100%); }
|
||||
.animate-float { animation: float 3s ease-in-out infinite; }
|
||||
@keyframes fadeIn { to { opacity: 1; } }
|
||||
@keyframes slideUp { to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes slideIn { to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes float { 0%, 100% { transform: translateY(0px); } 50% { transform: translateY(-4px); } }
|
||||
@keyframes pulse-glow { 0%, 100% { box-shadow: 0 0 15px -3px hsl(var(--primary-glow)); } 50% { box-shadow: 0 0 25px -2px hsl(var(--primary-glow)), 0 0 40px -5px hsl(var(--primary-glow)); } }
|
||||
.pulse-glow { animation: pulse-glow 2s ease-in-out infinite; }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function HealthPage() {
|
||||
redirect('/progress')
|
||||
}
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
import Script from 'next/script'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'OfflineAcademy — Your Personal Offline Learning Center',
|
||||
template: '%s | OfflineAcademy',
|
||||
},
|
||||
description: 'A self-hosted, privacy-first learning platform for offline courses. Download, organize, and watch your educational content without internet.',
|
||||
keywords: ['offline learning', 'course platform', 'self-hosted', 'video courses', 'education', 'LMS'],
|
||||
authors: [{ name: 'OfflineAcademy' }],
|
||||
creator: 'OfflineAcademy',
|
||||
publisher: 'OfflineAcademy',
|
||||
robots: 'noindex, nofollow',
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'en_US',
|
||||
url: 'http://localhost:6767',
|
||||
siteName: 'OfflineAcademy',
|
||||
title: 'OfflineAcademy — Your Personal Offline Learning Center',
|
||||
description: 'A self-hosted, privacy-first learning platform for offline courses.',
|
||||
images: [
|
||||
{ url: '/og-image.svg', width: 1200, height: 630, alt: 'OfflineAcademy Dashboard' },
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'OfflineAcademy',
|
||||
description: 'Your Personal Offline Learning Center',
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.svg', type: 'image/svg+xml', sizes: 'any' },
|
||||
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
shortcut: '/favicon.svg',
|
||||
apple: '/apple-touch-icon.png',
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#0a0e14' },
|
||||
],
|
||||
colorScheme: 'dark',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="icon" href="/favicon16x16.ico" sizes="16x16" type="image/x-icon" />
|
||||
<link rel="icon" href="/icon-192.png" sizes="192x192" type="image/png" />
|
||||
<link rel="icon" href="/icon-512.png" sizes="512x512" type="image/png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<meta name="theme-color" content="#10b981" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased min-h-screen bg-background`}>
|
||||
<Providers>{children}</Providers>
|
||||
<Script id="sw-registration" strategy="lazyOnload">
|
||||
{`if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(reg => console.log('SW registered:', reg.scope))
|
||||
.catch(err => console.log('SW registration failed:', err))
|
||||
}`}
|
||||
</Script>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
+432
@@ -0,0 +1,432 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { CourseCard } from '@/components/CourseCard'
|
||||
import { Header } from '@/components/Header'
|
||||
import { BookOpen, Plus, Search, Filter, Settings, Play, Clock, CheckCircle, BarChart3, Zap, ChevronLeft, ChevronRight, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import Link from 'next/link'
|
||||
import { formatTime } from '@/lib/utils'
|
||||
import { useCourses } from '@/lib/hooks/useCourses'
|
||||
import { getCourseDisplayName } from '@/lib/course-display'
|
||||
|
||||
export default function Dashboard() {
|
||||
const {
|
||||
courses,
|
||||
tags,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
search,
|
||||
setSearch,
|
||||
filter,
|
||||
setFilter,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
setSortBy,
|
||||
setSortOrder,
|
||||
nextPage,
|
||||
prevPage,
|
||||
changeLimit,
|
||||
goToPage,
|
||||
refetch,
|
||||
toggleSortOrder,
|
||||
} = useCourses({ initialLimit: 12 })
|
||||
|
||||
const [continueWatching, setContinueWatching] = React.useState<any[]>([])
|
||||
const [completedCourses, setCompletedCourses] = React.useState<any[]>([])
|
||||
const [cwLoading, setCwLoading] = React.useState(true)
|
||||
const [ccLoading, setCcLoading] = React.useState(true)
|
||||
const categoryTags = React.useMemo(
|
||||
() => tags.filter((tag: any) => tag.name.startsWith('category:')),
|
||||
[tags]
|
||||
)
|
||||
const plainTags = React.useMemo(
|
||||
() => tags.filter((tag: any) => !tag.name.startsWith('category:')),
|
||||
[tags]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchContinueWatching = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/progress?type=continue')
|
||||
const data = await res.json()
|
||||
setContinueWatching(data.items || data)
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch continue watching:', e)
|
||||
} finally {
|
||||
setCwLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCompletedCourses = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/progress?type=completed')
|
||||
const data = await res.json()
|
||||
setCompletedCourses(data.items || data)
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch completed courses:', e)
|
||||
} finally {
|
||||
setCcLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchContinueWatching()
|
||||
fetchCompletedCourses()
|
||||
}, [])
|
||||
|
||||
const getVideoThumb = (lessonType: string, courseName: string) => {
|
||||
const name = courseName.toLowerCase()
|
||||
if (name.includes('linux') || name.includes('terraform')) return 'video-thumb-linux'
|
||||
if (name.includes('code') || name.includes('program')) return 'video-thumb-code'
|
||||
if (name.includes('automat') || name.includes('workflow')) return 'video-thumb-flow'
|
||||
return 'video-thumb-presenter'
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header showSearchFilter={false} showScanButtons={true} coffeeUrl="https://ko-fi.com/nicetry247" />
|
||||
<main className="container mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-center min-h-[40vh]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-3 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-muted-foreground">Loading courses...</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (courses.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header showSearchFilter={false} showScanButtons={true} coffeeUrl="https://ko-fi.com/nicetry247" />
|
||||
<main className="container mx-auto px-4 py-6">
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
|
||||
<div className="mb-6 p-8 bg-muted/50 rounded-full glass-card">
|
||||
<BookOpen className="h-16 w-16 text-muted-foreground/50 mx-auto" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-2">No courses found</h2>
|
||||
<p className="text-muted-foreground mb-6 max-w-md">
|
||||
Add course folders to your <code className="bg-muted px-1.5 py-0.5 rounded">My_Courses/</code> directory, following the scan page folder structure, then scan to populate your library.
|
||||
</p>
|
||||
<Link href="/scan">
|
||||
<Button size="lg" className="gap-2 btn-premium-primary">
|
||||
<Plus className="h-5 w-5" />
|
||||
Scan for Courses
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header showSearchFilter={false} showScanButtons={true} coffeeUrl="https://ko-fi.com/nicetry247" />
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className="border-b border-white/5">
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-6 text-sm">
|
||||
{/* Search/Filter bar */}
|
||||
<div className="flex items-center gap-2 flex-1 max-w-md">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search courses..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 pr-4 py-2 bg-background border border-white/10 rounded-lg text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 w-full"
|
||||
/>
|
||||
</div>
|
||||
<Select value={filter} onValueChange={setFilter}>
|
||||
<SelectTrigger className="bg-background border border-white/10 rounded-lg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 w-[160px]">
|
||||
<SelectValue placeholder="All Courses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Courses</SelectItem>
|
||||
<SelectItem value="favorites">Pinned Favorites</SelectItem>
|
||||
<SelectItem value="in-progress">In Progress</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="not-started">Not Started</SelectItem>
|
||||
{categoryTags.length > 0 && (
|
||||
<>
|
||||
{categoryTags.map((tag: any) => (
|
||||
<SelectItem key={tag.id} value={`tag:${tag.id}`}>
|
||||
Category: {tag.name.replace(/^category:/, '')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{plainTags.length > 0 && (
|
||||
<>
|
||||
{plainTags.map((tag: any) => (
|
||||
<SelectItem key={tag.id} value={`tag:${tag.id}`}>
|
||||
Tag: {tag.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="bg-background border border-white/10 rounded-lg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 w-[160px]">
|
||||
<SelectValue placeholder="Sort" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="updatedAt">Recently Updated</SelectItem>
|
||||
<SelectItem value="name">Name A-Z</SelectItem>
|
||||
<SelectItem value="progress">Progress</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="header-control"
|
||||
onClick={toggleSortOrder}
|
||||
aria-label="Sort"
|
||||
>
|
||||
{sortOrder === 'asc' ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex flex-wrap items-center gap-4 ml-auto">
|
||||
<div className="stat-badge">
|
||||
<span className="stat-badge-icon"><BarChart3 className="h-4 w-4" /></span>
|
||||
<span>{pagination.total} courses</span>
|
||||
</div>
|
||||
<div className="stat-badge">
|
||||
<span className="stat-badge-icon-accent"><Zap className="h-4 w-4" /></span>
|
||||
<span>{courses.reduce((sum, c) => sum + c._count.lessons, 0)} lessons</span>
|
||||
</div>
|
||||
<div className="stat-badge">
|
||||
<span className="stat-badge-icon-green"><CheckCircle className="h-4 w-4" /></span>
|
||||
<span className="text-green-400">{courses.filter(c => c.progress.percentage === 100 && c._count.lessons > 0).length} completed</span>
|
||||
</div>
|
||||
<div className="stat-badge">
|
||||
<span className="stat-badge-icon-blue"><Play className="h-4 w-4" /></span>
|
||||
<span className="text-blue-400">{courses.filter(c => c.progress.percentage > 0 && c.progress.percentage < 100).length} in progress</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="container mx-auto px-4 py-6">
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Continue Watching */}
|
||||
{!cwLoading && continueWatching.length > 0 && search.trim() === '' && (
|
||||
<section className="mb-8" aria-labelledby="continue-watching-heading">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 id="continue-watching-heading" className="text-xl font-semibold flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/15 flex items-center justify-center">
|
||||
<Play className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
Continue Watching
|
||||
</h2>
|
||||
</div>
|
||||
<ScrollArea type="always" className="h-auto">
|
||||
<div className="flex gap-4 pb-4 -mx-4 px-4" style={{ scrollSnapType: 'x mandatory' }} role="list">
|
||||
{continueWatching.slice(0, 1).map((item, index) => {
|
||||
const courseTitle = getCourseDisplayName(item.course)
|
||||
const thumbClass = getVideoThumb(item.lesson.type, courseTitle)
|
||||
return (
|
||||
<Link
|
||||
key={item.lesson.id + '-' + index}
|
||||
href={'/watch/' + item.lesson.id + '?course=' + item.course.slug}
|
||||
className="flex-none w-72 sm:w-80 snap-start group"
|
||||
>
|
||||
<Card className="glass-card-elevated h-full overflow-hidden transition-all hover:glow-primary group">
|
||||
<div className="relative aspect-video bg-muted overflow-hidden">
|
||||
<div className={'video-thumb ' + thumbClass + ' w-full h-full transition-transform duration-500 group-hover:scale-105'} />
|
||||
<div className="thumb-overlay">
|
||||
<button className="play-button-glow" aria-label="Resume">
|
||||
<Play className="h-8 w-8 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
{item.progress.position && item.lesson.duration && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/50">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: Math.min(100, (item.progress.position / item.lesson.duration) * 100) + '%' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1 truncate">{courseTitle}</p>
|
||||
<h3 className="font-semibold text-sm mb-2 line-clamp-1 text-foreground">{item.lesson.title}</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{item.lesson.duration
|
||||
? formatTime(item.progress.position || 0) + ' / ' + formatTime(item.lesson.duration)
|
||||
: formatTime(item.progress.position || 0)}
|
||||
</span>
|
||||
{item.lesson.duration && (
|
||||
<Progress
|
||||
value={Math.min(100, ((item.progress.position || 0) / item.lesson.duration) * 100)}
|
||||
className="h-1.5 flex-1 gradient-progress"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Completed Courses */}
|
||||
{!ccLoading && completedCourses.length > 0 && (
|
||||
<section className="mb-8" aria-labelledby="completed-heading">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 id="completed-heading" className="text-xl font-semibold flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-500/15 flex items-center justify-center">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
Completed Courses
|
||||
</h2>
|
||||
</div>
|
||||
<ScrollArea type="always" className="h-auto">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{completedCourses.map((course) => {
|
||||
const courseTitle = getCourseDisplayName(course)
|
||||
return (
|
||||
<Link key={course.id} href={'/course/' + course.slug} className="group">
|
||||
<Card className="glass-card border-green-500/30 bg-green-500/5 hover:border-green-500/50 hover:bg-green-500/10 transition-all">
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0" />
|
||||
<span className="font-medium text-sm truncate max-w-[200px]">{courseTitle}</span>
|
||||
<span className="ml-auto text-xs text-green-500 font-medium">100%</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Your Library */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-accent/15 flex items-center justify-center">
|
||||
<BookOpen className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
Your Library
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={String(pagination.limit)} onValueChange={(e: string) => changeLimit(parseInt(e))}>
|
||||
<SelectTrigger className="bg-background border border-white/10 rounded-lg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10 per page</SelectItem>
|
||||
<SelectItem value="20">20 per page</SelectItem>
|
||||
<SelectItem value="50">50 per page</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-auto">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5 pb-4">
|
||||
{courses.map((course) => (
|
||||
<CourseCard key={course.id} course={course} onChanged={refetch} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={prevPage}
|
||||
disabled={!pagination.hasPrev}
|
||||
className="disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{(() => {
|
||||
const pages = Array.from({ length: Math.min(5, pagination.totalPages) }, (_, i) => {
|
||||
let pageNum
|
||||
if (pagination.totalPages <= 5) {
|
||||
pageNum = i + 1
|
||||
} else if (pagination.page <= 3) {
|
||||
pageNum = i + 1
|
||||
} else if (pagination.page >= pagination.totalPages - 2) {
|
||||
pageNum = pagination.totalPages - 4 + i
|
||||
} else {
|
||||
pageNum = pagination.page - 2 + i
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={pageNum === pagination.page ? 'default' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => goToPage(pageNum)}
|
||||
className="w-8 h-8 rounded-lg"
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
return pages
|
||||
})()}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={nextPage}
|
||||
disabled={!pagination.hasNext}
|
||||
className="disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center text-sm text-muted-foreground mt-4">
|
||||
Page {pagination.page} of {pagination.totalPages} — {pagination.total} courses total
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import type { Metadata } from 'next'
|
||||
import type { ComponentType } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
BadgeCheck,
|
||||
BookOpen,
|
||||
Bookmark,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
Database,
|
||||
Download,
|
||||
FileText,
|
||||
Gauge,
|
||||
HelpCircle,
|
||||
Keyboard,
|
||||
Layers,
|
||||
Laptop,
|
||||
Package,
|
||||
PictureInPicture2,
|
||||
Play,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
Tag,
|
||||
Upload,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Progress',
|
||||
description: 'Internal product status board for OfflineAcademy.',
|
||||
}
|
||||
|
||||
type Status = 'Live' | 'Planned' | 'Future'
|
||||
|
||||
type FeatureGroup = {
|
||||
title: string
|
||||
status: Status
|
||||
icon: ComponentType<{ className?: string }>
|
||||
items: string[]
|
||||
completion: number
|
||||
}
|
||||
|
||||
type RoadmapItem = {
|
||||
label: string
|
||||
done: boolean
|
||||
}
|
||||
|
||||
type RoadmapPhase = {
|
||||
phase: string
|
||||
title: string
|
||||
items: RoadmapItem[]
|
||||
completion: number
|
||||
}
|
||||
|
||||
const liveGroups: FeatureGroup[] = [
|
||||
{
|
||||
title: 'Library & Scanning',
|
||||
status: 'Live',
|
||||
icon: Database,
|
||||
completion: 100,
|
||||
items: [
|
||||
'Recursive scan of a local course root directory',
|
||||
'Course, module, and lesson discovery',
|
||||
'Natural sort for lesson ordering',
|
||||
'Folder-driven import with local file access',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Playback & Resume',
|
||||
status: 'Live',
|
||||
icon: Play,
|
||||
completion: 100,
|
||||
items: [
|
||||
'Lesson playback inside the app',
|
||||
'Continue Watching and resume playback',
|
||||
'Auto-next / playback flow support',
|
||||
'Native HTML5 video player for speed and simplicity',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Playback Enhancements',
|
||||
status: 'Live',
|
||||
icon: Zap,
|
||||
completion: 100,
|
||||
items: [
|
||||
'Variable playback speed (0.75x, 1x, 1.25x, 1.5x, 2.0x)',
|
||||
'Global keyboard shortcuts (play/pause, seek, mute, fullscreen, speed, PiP, Escape)',
|
||||
'Picture-in-Picture toggle',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Learning Tools',
|
||||
status: 'Live',
|
||||
icon: Bookmark,
|
||||
completion: 100,
|
||||
items: [
|
||||
'Bookmarks with timestamp notes',
|
||||
'Jump-to-time bookmark playback',
|
||||
'Subtitle support for .srt and .vtt',
|
||||
'Inline document viewing for lessons',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Course Management',
|
||||
status: 'Live',
|
||||
icon: ShieldCheck,
|
||||
completion: 100,
|
||||
items: [
|
||||
'Persistent rename titles',
|
||||
'Delete from library',
|
||||
'Delete from disk',
|
||||
'Archived/hidden courses stay hidden on rescans',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Progress Tracking UI',
|
||||
status: 'Live',
|
||||
icon: Gauge,
|
||||
completion: 100,
|
||||
items: [
|
||||
'Progress bars on course cards',
|
||||
'Lesson/module manual Done toggles',
|
||||
'Local analytics dashboard (`/analytics`) with watch time, completion rates, weekly activity',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Built-in Quiz Interactivity',
|
||||
status: 'Live',
|
||||
icon: HelpCircle,
|
||||
completion: 100,
|
||||
items: [
|
||||
'Quiz settings: Auto-Fetch toggle + QuizAPI / The Trivia API source selector',
|
||||
'QuizPlayer: intro, active questions, results, 80% pass mark, ⚙️ Edit Topic override',
|
||||
'Auto-fetch writes quiz_cache.json to module folders on module open',
|
||||
'Scanner injects Quiz lesson type (🟣) into module sidebar',
|
||||
'Quiz, Question, QuizAttempt models; score/passed tracked; green checkmark on completion',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Library Organization',
|
||||
status: 'Live',
|
||||
icon: Tag,
|
||||
completion: 100,
|
||||
items: [
|
||||
'Custom tags & categories with color (Tag + CourseTag models)',
|
||||
'Filter by tag, favorites, in-progress, completed, not-started',
|
||||
'Sort by name, progress %, updatedAt; favorites pinned to top',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Content Types & Thumbnails',
|
||||
status: 'Live',
|
||||
icon: FileText,
|
||||
completion: 100,
|
||||
items: [
|
||||
'PDF, TXT, MD, HTML, and JSON inline preview',
|
||||
'Local thumbnail support',
|
||||
'Safer thumbnail lookup to reduce broken image noise',
|
||||
'Watch-page rendering for mixed lesson types',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Settings & Data Model',
|
||||
status: 'Live',
|
||||
icon: Laptop,
|
||||
completion: 100,
|
||||
items: [
|
||||
'Configured course root directory',
|
||||
'SQLite + Prisma local persistence',
|
||||
'Progress and bookmark tables',
|
||||
'No login, no accounts, no cloud storage',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const roadmapPhases: RoadmapPhase[] = [
|
||||
{
|
||||
phase: 'Phase 5',
|
||||
title: 'Data Portability',
|
||||
completion: 0,
|
||||
items: [
|
||||
{ label: 'Export local state to JSON (progress, bookmarks, notes, tags, course metadata, quiz attempts)', done: false },
|
||||
{ label: 'Import and restore JSON state safely', done: false },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const futureIdeas = [
|
||||
{
|
||||
title: 'Reset Course',
|
||||
icon: Gauge,
|
||||
note: 'Clear progress and start a course from zero.',
|
||||
},
|
||||
{
|
||||
title: 'Mobile App Wrapper',
|
||||
icon: Smartphone,
|
||||
note: 'Lightweight mobile wrapper or app packaging layer.',
|
||||
},
|
||||
{
|
||||
title: 'Import / Export Course Metadata',
|
||||
icon: Package,
|
||||
note: 'Move course metadata in and out without touching core progress state.',
|
||||
},
|
||||
]
|
||||
|
||||
const stackBadges = [
|
||||
'Next.js 14',
|
||||
'React',
|
||||
'TypeScript',
|
||||
'Tailwind',
|
||||
'shadcn/ui',
|
||||
'Prisma',
|
||||
'SQLite',
|
||||
'Native Video',
|
||||
]
|
||||
|
||||
const currentCompletion = Math.round((liveGroups.filter((group) => group.completion === 100).length / liveGroups.length) * 100)
|
||||
|
||||
export default function ProgressPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-background text-foreground">
|
||||
<div className="container mx-auto px-4 py-8 sm:py-10 max-w-7xl">
|
||||
<section className="glass-card rounded-2xl p-6 sm:p-8 mb-6">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<Badge variant="secondary" className="gap-1.5">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
Internal route
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="gap-1.5">
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
Local-only
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="gap-1.5">
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
Fast stack
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl sm:text-5xl font-bold tracking-tight mb-3 text-gradient-primary">
|
||||
OfflineAcademy Progress
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg text-muted-foreground max-w-2xl">
|
||||
A private status board for the app’s live features, roadmap, and future ideas.
|
||||
This page is intentionally not linked from the dashboard — it’s an internal route
|
||||
you can visit directly at <code className="bg-muted px-1.5 py-0.5 rounded">/progress</code>.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-5">
|
||||
{stackBadges.map((item) => (
|
||||
<Badge key={item} variant="outline" className="bg-secondary/60">
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="w-full lg:w-[360px] glass-card-elevated">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Gauge className="h-5 w-5 text-primary" />
|
||||
Current Build Progress
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-muted-foreground">Live core complete</span>
|
||||
<span className="font-semibold text-primary">{currentCompletion}%</span>
|
||||
</div>
|
||||
<Progress value={currentCompletion} className="h-3 gradient-progress" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-xl border border-white/5 bg-secondary/30 p-3">
|
||||
<div className="text-muted-foreground">Live feature groups</div>
|
||||
<div className="text-2xl font-bold mt-1">{liveGroups.length}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/5 bg-secondary/30 p-3">
|
||||
<div className="text-muted-foreground">Roadmap phases</div>
|
||||
<div className="text-2xl font-bold mt-1">{roadmapPhases.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-2">
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<CheckCircle2 className="h-5 w-5 text-primary" />
|
||||
What’s Live Today
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
{liveGroups.map((group) => {
|
||||
const Icon = group.icon
|
||||
return (
|
||||
<div key={group.title} className="rounded-xl border border-white/5 bg-secondary/30 p-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/15 text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="font-semibold">{group.title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{group.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">{group.completion}%</Badge>
|
||||
</div>
|
||||
<Progress value={group.completion} className="h-2 gradient-progress" />
|
||||
<ul className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||
{group.items.map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<BadgeCheck className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<Layers className="h-5 w-5 text-accent" />
|
||||
Roadmap Phases
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{roadmapPhases.map((phase) => (
|
||||
<div key={phase.title} className="rounded-xl border border-white/5 bg-secondary/30 p-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{phase.phase}</div>
|
||||
<h3 className="font-semibold text-lg">{phase.title}</h3>
|
||||
</div>
|
||||
<Badge variant="outline">{phase.completion}%</Badge>
|
||||
</div>
|
||||
<Progress value={phase.completion} className="h-2 gradient-progress-accent" />
|
||||
<ul className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||
{phase.items.map((item) => (
|
||||
<li key={item.label} className="flex gap-2">
|
||||
{item.done ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
) : (
|
||||
<Clock3 className="mt-0.5 h-4 w-4 shrink-0 text-accent" />
|
||||
)}
|
||||
<span className={item.done ? 'text-foreground' : ''}>{item.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<Tag className="h-5 w-5 text-primary" />
|
||||
Future Ideas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2">
|
||||
{futureIdeas.map((idea) => {
|
||||
const Icon = idea.icon
|
||||
return (
|
||||
<div key={idea.title} className="rounded-xl border border-white/5 bg-secondary/25 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent/15 text-accent">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<h3 className="font-semibold">{idea.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{idea.note}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<BookOpen className="h-5 w-5 text-primary" />
|
||||
Product Constraints
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<p className="flex gap-2"><CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" />No authentication, no accounts, no user profiles.</p>
|
||||
<p className="flex gap-2"><CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" />Everything stays local: folder scanning, SQLite, and file serving.</p>
|
||||
<p className="flex gap-2"><CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" />The app is built to stay fast as more courses are added.</p>
|
||||
<p className="flex gap-2"><CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" />This route is a direct internal progress page, not a dashboard menu item.</p>
|
||||
<div className="pt-3 flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="gap-1.5"><Zap className="h-3.5 w-3.5" />Playback speed & shortcuts: Live</Badge>
|
||||
<Badge variant="secondary" className="gap-1.5"><PictureInPicture2 className="h-3.5 w-3.5" />PiP: Live</Badge>
|
||||
<Badge variant="secondary" className="gap-1.5"><HelpCircle className="h-3.5 w-3.5" />Quizzes: Live</Badge>
|
||||
<Badge variant="secondary" className="gap-1.5"><Download className="h-3.5 w-3.5" />Export planned</Badge>
|
||||
<Badge variant="secondary" className="gap-1.5"><Upload className="h-3.5 w-3.5" />Import planned</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { ThemeProvider } from '@/components/ThemeToggle'
|
||||
import { ToastProvider } from '@/hooks/use-toast'
|
||||
import { SplashScreen } from '@/components/SplashScreen'
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [showSplash, setShowSplash] = React.useState(true)
|
||||
|
||||
// Safety fallback: force hide after 5 seconds max
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setShowSplash(false), 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ToastProvider>
|
||||
{showSplash && (
|
||||
<SplashScreen onComplete={() => setShowSplash(false)} minDuration={1500} />
|
||||
)}
|
||||
{!showSplash && children}
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
Executable
+215
@@ -0,0 +1,215 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Loader2, CheckCircle, AlertCircle, RefreshCw, Database, FolderOpen, Play } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Header } from '@/components/Header'
|
||||
|
||||
export default function ScanPage() {
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [coursesRoot, setCoursesRoot] = useState('./My_Courses')
|
||||
const [result, setResult] = useState<{
|
||||
coursesCreated: number
|
||||
coursesUpdated: number
|
||||
modulesCreated: number
|
||||
modulesUpdated: number
|
||||
lessonsCreated: number
|
||||
lessonsUpdated: number
|
||||
errors: string[]
|
||||
duration: number
|
||||
} | null>(null)
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
|
||||
const addLog = (message: string) => {
|
||||
setLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] ${message}`])
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
fetch('/api/settings')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.coursesRoot) setCoursesRoot(data.coursesRoot)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanning(true)
|
||||
setResult(null)
|
||||
setLogs([])
|
||||
addLog('Starting scan...')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/scan', { method: 'POST' })
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Scan failed')
|
||||
}
|
||||
|
||||
setResult(data)
|
||||
addLog(`Scan completed in ${data.duration}ms`)
|
||||
addLog(`Courses: ${data.coursesCreated} created, ${data.coursesUpdated} updated`)
|
||||
addLog(`Modules: ${data.modulesCreated} created, ${data.modulesUpdated} updated`)
|
||||
addLog(`Lessons: ${data.lessonsCreated} created, ${data.lessonsUpdated} updated`)
|
||||
|
||||
if (data.errors.length > 0) {
|
||||
data.errors.forEach((err: string) => addLog(`ERROR: ${err}`))
|
||||
}
|
||||
} catch (error) {
|
||||
addLog(`ERROR: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header showScanButtons={true} onQuickScan={handleScan} coffeeUrl="https://ko-fi.com/nicetry247" />
|
||||
<main className="container mx-auto px-4 py-8 max-w-3xl">
|
||||
<div className="text-center mb-8">
|
||||
<div className="mx-auto mb-4 p-3 bg-primary/10 rounded-full w-fit">
|
||||
<Database className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-2">Scan Courses</h1>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Scans your <code className="bg-muted px-1.5 py-0.5 rounded">My_Courses/</code> directory for new content.
|
||||
Courses are detected from the folder tree below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="h-5 w-5" />
|
||||
Start Scan
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Recursively scans for courses, modules, and lessons. Updates existing records, adds new ones, and keeps renamed display titles.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
onClick={handleScan}
|
||||
disabled={scanning}
|
||||
className="w-full gap-2"
|
||||
size="lg"
|
||||
>
|
||||
{scanning ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Scanning...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-5 w-5" />
|
||||
Start Scan
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
||||
Quick scan skips video metadata for speed. For full scan with metadata, use the Scan page.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{result && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{result.errors.length > 0 ? (
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
) : (
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
)}
|
||||
Scan Results
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<p className="text-3xl font-bold text-primary">{result.coursesCreated + result.coursesUpdated}</p>
|
||||
<p className="text-sm text-muted-foreground">Courses</p>
|
||||
</div>
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<p className="text-3xl font-bold text-primary">{result.modulesCreated + result.modulesUpdated}</p>
|
||||
<p className="text-sm text-muted-foreground">Modules</p>
|
||||
</div>
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<p className="text-3xl font-bold text-primary">{result.lessonsCreated + result.lessonsUpdated}</p>
|
||||
<p className="text-sm text-muted-foreground">Lessons</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Completed in {result.duration}ms
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{logs.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Play className="h-5 w-5" />
|
||||
Scan Logs
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="bg-muted p-4 rounded font-mono text-sm max-h-64 overflow-y-auto">
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="text-muted-foreground mb-1">{log}</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="mt-6 border-dashed">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="h-5 w-5" />
|
||||
Expected Folder Structure
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted p-4 rounded text-sm overflow-x-auto text-left leading-6 whitespace-pre">
|
||||
{coursesRoot || './My_Courses'}/
|
||||
├── Course Name 1/
|
||||
│ ├── 01 - Introduction/
|
||||
│ │ ├── 01 - Introduction.mp4
|
||||
│ │ ├── 01 - Introduction.srt
|
||||
│ │ ├── 02 - Overview.pdf
|
||||
│ │ └── 03 - Notes.txt
|
||||
│ ├── 02 - Advanced/
|
||||
│ │ ├── 01 - Deep Dive.mp4
|
||||
│ │ └── 01 - Deep Dive.vtt
|
||||
│ └── cover.jpg ← optional thumbnail
|
||||
├── Course Name 2/
|
||||
│ ├── Module A/
|
||||
│ │ ├── lesson1.mp4
|
||||
│ │ ├── lesson1.srt
|
||||
│ │ └── notes.md
|
||||
│ └── Module B/
|
||||
│ └── lesson1.json
|
||||
└── Course Name 3/
|
||||
└── Module 1/
|
||||
├── video1.mp4
|
||||
├── diagram.pdf
|
||||
└── README.txt
|
||||
</pre>
|
||||
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||
<p><strong>How the scanner reads it:</strong> course folder → module folder → lesson files.</p>
|
||||
<p><strong>Matching rule:</strong> subtitle files should share the same base name as the video, like <code className="bg-background px-1.5 py-0.5 rounded">lesson.mp4</code> + <code className="bg-background px-1.5 py-0.5 rounded">lesson.srt</code>.</p>
|
||||
<p><strong>Sorting:</strong> numeric prefixes like <code className="bg-background px-1.5 py-0.5 rounded">01</code>, <code className="bg-background px-1.5 py-0.5 rounded">02</code>, <code className="bg-background px-1.5 py-0.5 rounded">10</code> keep lessons in learning order.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Executable
+230
@@ -0,0 +1,230 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Loader2, Save, FolderOpen, Eye, EyeOff, Key } from 'lucide-react'
|
||||
import { Header } from '@/components/Header'
|
||||
|
||||
const DEFAULT_COURSES_ROOT = './My_Courses'
|
||||
const DEFAULT_QUIZ_API_SOURCE = 'quizapi'
|
||||
|
||||
function parseBoolean(value: string | null | undefined) {
|
||||
return value === 'true'
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [coursesRoot, setCoursesRoot] = useState(DEFAULT_COURSES_ROOT)
|
||||
const [autoFetchQuizzes, setAutoFetchQuizzes] = useState(false)
|
||||
const [quizApiSource, setQuizApiSource] = useState(DEFAULT_QUIZ_API_SOURCE)
|
||||
const [quizApiKey, setQuizApiKey] = useState('')
|
||||
const [showApiKey, setShowApiKey] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle')
|
||||
const [statusMessage, setStatusMessage] = useState('')
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/settings')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setCoursesRoot(data.coursesRoot || DEFAULT_COURSES_ROOT)
|
||||
setAutoFetchQuizzes(parseBoolean(data.autoFetchQuizzes))
|
||||
setQuizApiSource(data.quizApiSource || DEFAULT_QUIZ_API_SOURCE)
|
||||
setQuizApiKey(data.quizApiKey || '')
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error)
|
||||
}
|
||||
|
||||
setCoursesRoot(DEFAULT_COURSES_ROOT)
|
||||
setAutoFetchQuizzes(false)
|
||||
setQuizApiSource(DEFAULT_QUIZ_API_SOURCE)
|
||||
setQuizApiKey('')
|
||||
}
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setStatus('idle')
|
||||
|
||||
try {
|
||||
const saveSettings = {
|
||||
coursesRoot,
|
||||
autoFetchQuizzes: autoFetchQuizzes ? 'true' : 'false',
|
||||
quizApiSource,
|
||||
quizApiKey,
|
||||
}
|
||||
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(saveSettings),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data?.error || 'Failed to save')
|
||||
}
|
||||
|
||||
setStatus('success')
|
||||
setStatusMessage('Settings saved. Next scan will use the new directory.')
|
||||
} catch (error) {
|
||||
setStatus('error')
|
||||
setStatusMessage(error instanceof Error ? error.message : 'Failed to save settings')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchSettings()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header coffeeUrl="https://ko-fi.com/nicetry247" />
|
||||
<main className="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<div className="text-center mb-8">
|
||||
<div className="mx-auto mb-4 p-3 bg-primary/10 rounded-full w-fit">
|
||||
<FolderOpen className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-2">Settings</h1>
|
||||
<p className="text-muted-foreground">Configure your OfflineAcademy preferences</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="h-5 w-5" />
|
||||
Courses Directory
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
The root folder where OfflineAcademy scans for courses. Use an absolute path for best results.
|
||||
Add course folders using the structure shown on the Scan page.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSave} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coursesRoot">Courses Root Path</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="coursesRoot"
|
||||
placeholder="/absolute/path/to/courses"
|
||||
value={coursesRoot}
|
||||
onChange={(e) => setCoursesRoot(e.target.value)}
|
||||
className="flex-1"
|
||||
disabled={saving}
|
||||
/>
|
||||
<Button type="submit" disabled={saving} className="gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" style={{ display: saving ? 'block' : 'none' }} />
|
||||
<Save className="h-4 w-4" style={{ display: saving ? 'none' : 'block' }} />
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Current default: ./My_Courses</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6 space-y-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Key className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">Quiz Settings</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quizApiSource">Quiz API Source</Label>
|
||||
<Select value={quizApiSource} onValueChange={setQuizApiSource}>
|
||||
<SelectTrigger id="quizApiSource" disabled={saving}>
|
||||
<SelectValue placeholder="Select quiz API" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="quizapi">QuizAPI (requires API key)</SelectItem>
|
||||
<SelectItem value="the-trivia-api">The Trivia API (no key needed)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">Source used when auto-fetching quiz content.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<Label htmlFor="autoFetchQuizzes">Auto-Fetch Quizzes</Label>
|
||||
<p className="text-xs text-muted-foreground">Fetch quiz data when a module is opened.</p>
|
||||
</div>
|
||||
<input
|
||||
id="autoFetchQuizzes"
|
||||
type="checkbox"
|
||||
className="h-4 w-4"
|
||||
checked={autoFetchQuizzes}
|
||||
onChange={(event) => setAutoFetchQuizzes(event.target.checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{quizApiSource === 'quizapi' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quizApiKey">QuizAPI Key <span className="text-xs text-muted-foreground font-normal">(secret)</span></Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="quizApiKey"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder="Enter your QuizAPI key"
|
||||
value={quizApiKey}
|
||||
onChange={(e) => setQuizApiKey(e.target.value)}
|
||||
className="pr-10"
|
||||
disabled={saving}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 h-8 w-8 p-0"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Get your key at <a href="https://quizapi.io" target="_blank" rel="noopener noreferrer" className="underline hover:text-primary">quizapi.io</a>. Stored securely in local database.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground">
|
||||
<p className="font-medium mb-1">Quiz settings</p>
|
||||
<p>Auto-fetch is {autoFetchQuizzes ? 'enabled' : 'disabled'} with source <code className="bg-background px-2 py-1 rounded">{quizApiSource}</code>.</p>
|
||||
{quizApiSource === 'quizapi' && quizApiKey && (
|
||||
<p className="mt-1 text-green-500">✓ QuizAPI key configured ({quizApiKey.length} characters)</p>
|
||||
)}
|
||||
{quizApiSource === 'quizapi' && !quizApiKey && (
|
||||
<p className="mt-1 text-amber-500">⚠ QuizAPI selected but no API key configured. Quizzes will fall back to The Trivia API.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={saving} className="gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" style={{ display: saving ? 'block' : 'none' }} />
|
||||
<Save className="h-4 w-4" style={{ display: saving ? 'none' : 'block' }} />
|
||||
{saving ? 'Saving...' : 'Save settings'}
|
||||
</Button>
|
||||
|
||||
{status === 'success' && (
|
||||
<p className="text-sm text-green-500">Settings saved successfully.</p>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<p className="text-sm text-red-500">{statusMessage}</p>
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Executable
+538
@@ -0,0 +1,538 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Header } from '@/components/Header'
|
||||
import { VideoPlayer } from '@/components/VideoPlayer'
|
||||
import { ModuleAccordion } from '@/components/ModuleAccordion'
|
||||
import { LessonBookmarks } from '@/components/LessonBookmarks'
|
||||
import { QuizPlayer } from '@/components/QuizPlayer'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
Play, Clock, CheckCircle, BookOpen, ArrowLeft,
|
||||
ChevronLeft, ChevronRight, Volume2, VolumeX,
|
||||
Maximize, Minimize, Settings, Loader2, SkipBack, SkipForward,
|
||||
Film, Music, FileText, Image as ImageIcon, HelpCircle,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { formatDuration, formatTime, cn } from '@/lib/utils'
|
||||
import { getCourseDisplayName } from '@/lib/course-display'
|
||||
|
||||
interface LessonType {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
order: number
|
||||
moduleId: string
|
||||
module: { name: string; courseId?: string; id: string }
|
||||
type: string
|
||||
duration: number | null
|
||||
filePath: string
|
||||
thumbnail: string | null
|
||||
subtitlePath: string | null
|
||||
progress?: { completed: boolean; position: number; lastWatched: string } | null
|
||||
quiz?: {
|
||||
version: 1
|
||||
source: 'quizapi' | 'the-trivia-api'
|
||||
topic: string
|
||||
title: string
|
||||
description: string
|
||||
fetchedAt: string
|
||||
updatedAt: string
|
||||
questions: Array<{
|
||||
id: string
|
||||
prompt: string
|
||||
options: string[]
|
||||
answerIndex: number
|
||||
explanation?: string
|
||||
}>
|
||||
} | null
|
||||
}
|
||||
|
||||
interface CourseType {
|
||||
id: string
|
||||
slug: string
|
||||
name: 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
|
||||
}>
|
||||
}>
|
||||
stats: {
|
||||
totalLessons: number
|
||||
completedLessons: number
|
||||
percentage: number
|
||||
}
|
||||
}
|
||||
|
||||
interface WatchPageClientProps {
|
||||
initialData: {
|
||||
lesson: LessonType
|
||||
course: CourseType
|
||||
prevLesson: { id: string; courseSlug: string } | null
|
||||
nextLesson: { id: string; courseSlug: string } | null
|
||||
currentIndex: number
|
||||
totalLessons: number
|
||||
}
|
||||
}
|
||||
|
||||
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" />,
|
||||
}
|
||||
|
||||
function saveProgressFn(lessonId: string, courseId: string, moduleId: string, position: number) {
|
||||
return fetch('/api/progress', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ lessonId, courseId, moduleId, position, completed: false })
|
||||
})
|
||||
}
|
||||
|
||||
function markCompleteFn(lessonId: string, courseId: string, moduleId: string, position: number) {
|
||||
return fetch('/api/progress', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ lessonId, courseId, moduleId, position, completed: true })
|
||||
})
|
||||
}
|
||||
|
||||
function VideoContent({ lesson, lessonProgress, onSave, onEnd, onTimeUpdate, seekRequest }: {
|
||||
lesson: { id: string; filePath: string; thumbnail: string | null; duration: number | null; subtitlePath: string | null }
|
||||
lessonProgress: { completed: boolean; position: number; lastWatched: string }
|
||||
onSave: (position: number) => void
|
||||
onEnd: () => void
|
||||
onTimeUpdate: (time: number) => void
|
||||
seekRequest: { time: number; nonce: number } | null
|
||||
}) {
|
||||
return (
|
||||
<VideoPlayer
|
||||
src={'/api/files/' + lesson.filePath}
|
||||
poster={lesson.thumbnail ? '/api/files/' + lesson.thumbnail : undefined}
|
||||
initialTime={lessonProgress?.position || 0}
|
||||
autoPlay={true}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onProgressSave={onSave}
|
||||
onEnded={onEnd}
|
||||
seekRequest={seekRequest}
|
||||
subtitles={lesson.subtitlePath ? '/api/files/' + lesson.subtitlePath : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NonVideoContent({ lesson }: { lesson: { type: string; filePath: string; title: string; duration: number | null } }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full bg-muted/50 rounded-lg">
|
||||
<div className="text-center p-8">
|
||||
{lesson.type === 'AUDIO' && (
|
||||
<audio
|
||||
src={'/api/files/' + lesson.filePath}
|
||||
controls
|
||||
className="w-full max-w-2xl mb-4"
|
||||
/>
|
||||
)}
|
||||
{['PDF', 'MARKDOWN', 'HTML', 'JSON', 'TEXT', 'TXT', 'VTT'].includes(lesson.type) && (
|
||||
<iframe
|
||||
src={'/api/files/' + lesson.filePath}
|
||||
className="w-full h-[60vh] rounded border"
|
||||
title={lesson.title}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
/>
|
||||
)}
|
||||
{lesson.type === 'IMAGE' && (
|
||||
<img
|
||||
src={'/api/files/' + lesson.filePath}
|
||||
alt={lesson.title}
|
||||
className="max-w-full max-h-[70vh] rounded shadow-lg"
|
||||
/>
|
||||
)}
|
||||
{lesson.type === 'OTHER' && (
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground">This file type cannot be previewed inline.</p>
|
||||
<a
|
||||
href={'/api/files/' + lesson.filePath}
|
||||
target="_blank"
|
||||
className="text-primary hover:underline mt-2 inline-block"
|
||||
>
|
||||
Open in new tab →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileOverlay({ lesson, lessonProgress }: {
|
||||
lesson: { title: string; type: string; duration: number | null }
|
||||
lessonProgress: { completed: boolean; position: number; lastWatched: string }
|
||||
}) {
|
||||
const [visible, setVisible] = React.useState(true)
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setVisible(false), 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lg:hidden absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/90 to-transparent pointer-events-none"
|
||||
onTouchStart={() => setVisible(false)}
|
||||
>
|
||||
<div className="max-w-3xl mx-auto pointer-events-none">
|
||||
<h2 className="text-lg font-semibold text-white mb-1">{lesson.title}</h2>
|
||||
<div className="flex items-center gap-3 text-sm text-white/70">
|
||||
<span className="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 text-foreground capitalize">
|
||||
{lesson.type.toLowerCase()}
|
||||
</span>
|
||||
{lesson.duration && lessonProgress && (
|
||||
<>
|
||||
<span>{formatTime(lessonProgress.position)}</span>
|
||||
<span>/</span>
|
||||
<span>{formatTime(lesson.duration)}</span>
|
||||
</>
|
||||
)}
|
||||
{lesson.duration && !lessonProgress && (
|
||||
<span>{'0:00'} / {formatTime(lesson.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
{(lessonProgress?.position ?? 0) > 0 && lesson.duration && (
|
||||
<Progress
|
||||
value={Math.min(100, ((lessonProgress?.position ?? 0) / lesson.duration) * 100)}
|
||||
className="h-1.5 mt-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LessonHeader({ lesson, lessonProgress, prevLesson, nextLesson, course, currentIndex, totalLessons }: {
|
||||
lesson: {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
type: string
|
||||
duration: number | null
|
||||
order: number
|
||||
module: { name: string; id: string; courseId?: string }
|
||||
progress?: { completed: boolean; position: number; lastWatched: string } | null
|
||||
}
|
||||
lessonProgress: { completed: boolean; position: number; lastWatched: string }
|
||||
prevLesson: { id: string; courseSlug: string } | null
|
||||
nextLesson: { id: string; courseSlug: string } | null
|
||||
course: { id: string; slug: string; name: string; modules: any[] }
|
||||
currentIndex: number
|
||||
totalLessons: number
|
||||
}) {
|
||||
const allLessons = course.modules.flatMap(m => m.lessons)
|
||||
|
||||
return (
|
||||
<div className="hidden lg:block p-4 lg:p-6 border-t bg-card">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{lesson.title}</h2>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1 text-sm text-muted-foreground">
|
||||
{lessonTypeIcons[lesson.type] || lessonTypeIcons.OTHER}
|
||||
<span className="capitalize">{lesson.type.toLowerCase()}</span>
|
||||
{lesson.duration && <span>• {formatDuration(lesson.duration)}</span>}
|
||||
<span>• Module: {lesson.module.name}</span>
|
||||
<span>• Lesson {lesson.order + 1} of {allLessons.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{prevLesson && (
|
||||
<Link href={`/watch/${prevLesson.id}?course=${course.slug}`}>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{nextLesson && (
|
||||
<Link href={`/watch/${nextLesson.id}?course=${course.slug}`}>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lessonProgress?.position && lesson.duration && (
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm text-muted-foreground mb-1">
|
||||
<span>{formatTime(lessonProgress.position)} / {formatTime(lesson.duration)}</span>
|
||||
<span>{Math.round(Math.min(100, (lessonProgress.position / lesson.duration) * 100))}%</span>
|
||||
</div>
|
||||
<Progress value={Math.min(100, (lessonProgress.position / lesson.duration) * 100)} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WatchPageClient({ initialData }: WatchPageClientProps) {
|
||||
const { lesson, course, prevLesson, nextLesson, currentIndex, totalLessons } = initialData
|
||||
const isVideo = lesson.type === 'VIDEO'
|
||||
const isQuiz = lesson.type === 'QUIZ'
|
||||
const allLessons = course.modules.flatMap(m => m.lessons)
|
||||
|
||||
const [lessonProgress, setLessonProgress] = useState(lesson.progress || {
|
||||
completed: false,
|
||||
position: 0,
|
||||
lastWatched: new Date().toISOString(),
|
||||
})
|
||||
const [currentTime, setCurrentTime] = useState(lesson.progress?.position || 0)
|
||||
const [bookmarkSeekRequest, setBookmarkSeekRequest] = useState<{ time: number; nonce: number } | null>(null)
|
||||
|
||||
const currentModule = course.modules.find(m => m.id === lesson.moduleId) || null
|
||||
|
||||
// Sync progress when initialData changes (e.g., after auto-next navigation)
|
||||
useEffect(() => {
|
||||
setLessonProgress(lesson.progress || { completed: false, position: 0, lastWatched: new Date().toISOString() })
|
||||
}, [lesson.id, lesson.progress])
|
||||
|
||||
function onSave(position: number) {
|
||||
saveProgressFn(lesson.id, course.id, lesson.moduleId, position)
|
||||
.then(() => setLessonProgress(p => ({ ...p, position, lastWatched: new Date().toISOString() })))
|
||||
.catch(e => console.error('Failed to save progress:', e))
|
||||
}
|
||||
|
||||
function onEnd() {
|
||||
markCompleteFn(lesson.id, course.id, lesson.moduleId, lesson.duration || 0)
|
||||
.then(() => setLessonProgress(p => ({ ...p, completed: true, position: lesson.duration || 0 })))
|
||||
.catch(e => console.error('Failed to mark complete:', e))
|
||||
if (nextLesson) window.location.href = `/watch/${nextLesson.id}?course=${course.slug}`
|
||||
}
|
||||
|
||||
const handlePrevLesson = useCallback(() => {
|
||||
if (prevLesson) {
|
||||
window.location.href = `/watch/${prevLesson.id}?course=${course.slug}`
|
||||
}
|
||||
}, [prevLesson, course.slug])
|
||||
|
||||
const handleNextLesson = useCallback(() => {
|
||||
if (nextLesson) {
|
||||
window.location.href = `/watch/${nextLesson.id}?course=${course.slug}`
|
||||
}
|
||||
}, [nextLesson, course.slug])
|
||||
|
||||
const handleBookmarkJump = useCallback((time: number) => {
|
||||
setBookmarkSeekRequest({ time, nonce: Date.now() })
|
||||
setCurrentTime(time)
|
||||
// Clear the seek request after the video player processes it
|
||||
setTimeout(() => setBookmarkSeekRequest(null), 100)
|
||||
}, [])
|
||||
|
||||
const videoPlayer = isVideo ? (
|
||||
<VideoContent
|
||||
lesson={lesson}
|
||||
lessonProgress={lessonProgress}
|
||||
onSave={onSave}
|
||||
onEnd={onEnd}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
seekRequest={bookmarkSeekRequest}
|
||||
/>
|
||||
) : null
|
||||
|
||||
const quizContent = isQuiz ? (
|
||||
lesson.quiz ? (
|
||||
<QuizPlayer
|
||||
quiz={lesson.quiz}
|
||||
lessonId={lesson.id}
|
||||
courseId={course.id}
|
||||
moduleId={lesson.moduleId}
|
||||
/>
|
||||
) : (
|
||||
<Card className="mx-auto w-full max-w-3xl border-border/60 bg-card/80">
|
||||
<CardHeader>
|
||||
<CardTitle>Quiz unavailable</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
The quiz cache could not be loaded for this lesson yet.
|
||||
Try rescanning the course or open the course page to regenerate the cache.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
) : null
|
||||
|
||||
const nonVideoContent = !isVideo && !isQuiz ? (
|
||||
<NonVideoContent lesson={lesson} />
|
||||
) : null
|
||||
|
||||
const courseTitle = getCourseDisplayName(course)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<Header backHref={`/course/${course.slug}`} backLabel={courseTitle} coffeeUrl="https://ko-fi.com/nicetry247" />
|
||||
|
||||
<main className="flex-1 flex min-h-0 flex-col lg:flex-row overflow-hidden relative">
|
||||
{/* Main Video/Content Area */}
|
||||
<div className="flex-1 lg:w-3/4 min-w-0 min-h-0 flex flex-col relative">
|
||||
<div className="flex-1 min-h-0 relative bg-black">
|
||||
{/* Video container with proper aspect ratio on mobile */}
|
||||
<div className="w-full aspect-video lg:aspect-auto lg:h-full relative p-4 lg:p-6">
|
||||
{videoPlayer}
|
||||
{quizContent}
|
||||
{nonVideoContent}
|
||||
{isVideo && <MobileOverlay lesson={lesson} lessonProgress={lessonProgress} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lesson Header - Desktop */}
|
||||
<LessonHeader
|
||||
lesson={lesson}
|
||||
lessonProgress={lessonProgress}
|
||||
prevLesson={prevLesson}
|
||||
nextLesson={nextLesson}
|
||||
course={course}
|
||||
currentIndex={currentIndex}
|
||||
totalLessons={totalLessons}
|
||||
/>
|
||||
|
||||
{/* Mobile Navigation Buttons */}
|
||||
<div className="lg:hidden p-4 border-t bg-card flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePrevLesson}
|
||||
disabled={!prevLesson}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleNextLesson}
|
||||
disabled={!nextLesson}
|
||||
className="gap-1"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Sidebar - Below Video on Mobile */}
|
||||
<div className="lg:hidden border-t bg-card/50">
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
Course Content
|
||||
</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{course.stats.percentage}% complete
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${course.stats.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isVideo && currentModule && (
|
||||
<div className="shrink-0 border-b border-primary/30 bg-primary/10 px-4 py-3">
|
||||
<p className="text-xs font-semibold uppercase text-primary">Now Playing</p>
|
||||
<p className="truncate text-sm font-medium">{currentModule.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{lesson.title}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 space-y-4 max-h-[50vh] overflow-y-auto custom-scrollbar">
|
||||
{isVideo && (
|
||||
<LessonBookmarks
|
||||
lessonId={lesson.id}
|
||||
courseId={course.id}
|
||||
moduleId={lesson.moduleId}
|
||||
currentTime={currentTime}
|
||||
onJumpToTime={handleBookmarkJump}
|
||||
isVideo={isVideo}
|
||||
/>
|
||||
)}
|
||||
<ModuleAccordion
|
||||
courseId={course.id}
|
||||
modules={course.modules}
|
||||
currentLessonId={lesson.id}
|
||||
courseSlug={course.slug}
|
||||
defaultOpenModuleId={lesson.moduleId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - Desktop */}
|
||||
<aside className="hidden lg:flex lg:w-1/4 border-l bg-card/50 flex-col h-[calc(100vh-4rem)] min-h-0 overscroll-y-contain">
|
||||
<div className="p-4 border-b shrink-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
Course Content
|
||||
</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{course.stats.percentage}% complete
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${course.stats.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isVideo && currentModule && (
|
||||
<div className="shrink-0 border-b border-primary/30 bg-primary/10 px-4 py-3">
|
||||
<p className="text-xs font-semibold uppercase text-primary">Now Playing</p>
|
||||
<p className="truncate text-sm font-medium">{currentModule.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{lesson.title}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-y-contain p-4 custom-scrollbar space-y-4">
|
||||
{isVideo && (
|
||||
<LessonBookmarks
|
||||
lessonId={lesson.id}
|
||||
courseId={course.id}
|
||||
moduleId={lesson.moduleId}
|
||||
currentTime={currentTime}
|
||||
onJumpToTime={handleBookmarkJump}
|
||||
isVideo={isVideo}
|
||||
/>
|
||||
)}
|
||||
<ModuleAccordion
|
||||
courseId={course.id}
|
||||
modules={course.modules}
|
||||
currentLessonId={lesson.id}
|
||||
courseSlug={course.slug}
|
||||
defaultOpenModuleId={lesson.moduleId}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Executable
+133
@@ -0,0 +1,133 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { WatchPageClient } from './WatchPageClient'
|
||||
import { ensureModuleQuizCache } from '@/lib/quiz'
|
||||
import { join } from 'path'
|
||||
|
||||
interface WatchPageProps {
|
||||
params: Promise<{ lessonId: string }>
|
||||
searchParams: Promise<{ course?: string }>
|
||||
}
|
||||
|
||||
export default async function WatchPage({ params, searchParams }: WatchPageProps) {
|
||||
const { lessonId } = await params
|
||||
const { course: courseSlug } = await searchParams
|
||||
|
||||
try {
|
||||
// Find the lesson with its module
|
||||
const lesson = await prisma.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
include: {
|
||||
module: true,
|
||||
progress: {
|
||||
where: { userId: 'local-user' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!lesson) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Fetch the course
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { id: lesson.module.courseId },
|
||||
})
|
||||
|
||||
if (!course) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const quizSourceSetting = await prisma.setting.findUnique({ where: { key: 'quizApiSource' } })
|
||||
const quizSource = quizSourceSetting?.value === 'the-trivia-api'
|
||||
? 'the-trivia-api'
|
||||
: 'quizapi'
|
||||
const quizCacheResult = lesson.type === 'QUIZ'
|
||||
? await ensureModuleQuizCache({
|
||||
modulePath: join(course.path, lesson.module.name),
|
||||
topic: lesson.module.name,
|
||||
source: quizSource,
|
||||
})
|
||||
: null
|
||||
|
||||
// Fetch modules with lessons and progress
|
||||
const modules = await prisma.module.findMany({
|
||||
where: { courseId: course.id },
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
lessons: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
progress: {
|
||||
where: { userId: 'local-user' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch course-level progress
|
||||
const courseProgress = await prisma.progress.findFirst({
|
||||
where: { userId: 'local-user', courseId: course.id, lessonId: null, moduleId: null },
|
||||
})
|
||||
|
||||
// Calculate all lessons
|
||||
const allLessons = modules.flatMap(m => m.lessons)
|
||||
const currentIndex = allLessons.findIndex(l => l.id === lessonId)
|
||||
const prevLesson = currentIndex > 0
|
||||
? { id: allLessons[currentIndex - 1].id, courseSlug: course.slug }
|
||||
: null
|
||||
const nextLesson = currentIndex < allLessons.length - 1
|
||||
? { id: allLessons[currentIndex + 1].id, courseSlug: course.slug }
|
||||
: null
|
||||
|
||||
const totalLessons = allLessons.length
|
||||
const completedLessons = allLessons.filter(l => l.progress[0]?.completed).length
|
||||
const percentage = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0
|
||||
|
||||
// Build response data
|
||||
const data = {
|
||||
lesson: {
|
||||
...lesson,
|
||||
progress: lesson.progress[0] ? {
|
||||
completed: lesson.progress[0].completed,
|
||||
position: lesson.progress[0].position,
|
||||
lastWatched: lesson.progress[0].lastWatched.toISOString(),
|
||||
} : null,
|
||||
quiz: quizCacheResult?.quiz || null,
|
||||
},
|
||||
course: {
|
||||
...course,
|
||||
progress: courseProgress ? {
|
||||
...courseProgress,
|
||||
lastWatched: courseProgress.lastWatched.toISOString(),
|
||||
} : null,
|
||||
modules: modules.map(m => ({
|
||||
...m,
|
||||
lessons: m.lessons.map(l => ({
|
||||
...l,
|
||||
progress: l.progress[0] ? {
|
||||
completed: l.progress[0].completed,
|
||||
position: l.progress[0].position,
|
||||
lastWatched: l.progress[0].lastWatched.toISOString(),
|
||||
} : null,
|
||||
})),
|
||||
})),
|
||||
stats: {
|
||||
totalLessons,
|
||||
completedLessons,
|
||||
percentage,
|
||||
},
|
||||
},
|
||||
prevLesson,
|
||||
nextLesson,
|
||||
currentIndex,
|
||||
totalLessons: allLessons.length,
|
||||
}
|
||||
|
||||
return <WatchPageClient initialData={data} />
|
||||
} catch (error) {
|
||||
console.error('WatchPage error:', error)
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user