initial commit

This commit is contained in:
Nice Try
2026-06-26 02:51:08 +00:00
commit ad5810fba9
133 changed files with 28275 additions and 0 deletions
+397
View File
@@ -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>
)
}
+199
View File
@@ -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 })
}
}
+63
View File
@@ -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 })
}
}
+101
View File
@@ -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 })
}
}
+171
View File
@@ -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 })
}
}
+185
View File
@@ -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 })
}
}
+169
View File
@@ -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 }
)
}
}
+141
View File
@@ -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 })
}
}
+127
View File
@@ -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 })
}
}
+320
View File
@@ -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 })
}
}
+78
View File
@@ -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 })
}
}
+87
View File
@@ -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 })
}
}
+53
View File
@@ -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 }
)
}
+103
View File
@@ -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 })
}
}
+392
View File
@@ -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>
)
}
+173
View File
@@ -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>
)
}
+346
View File
@@ -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; }
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function HealthPage() {
redirect('/progress')
}
Executable
+85
View File
@@ -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
View File
@@ -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>
)
}
+424
View File
@@ -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 apps live features, roadmap, and future ideas.
This page is intentionally not linked from the dashboard its 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" />
Whats 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>
)
}
+27
View File
@@ -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>
)
}
+215
View File
@@ -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>
)
}
+230
View File
@@ -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>
)
}
+538
View File
@@ -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>
)
}
+133
View File
@@ -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()
}
}