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
+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 })
}
}