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