mirror of
https://github.com/nicetry247/offlineacademy.git
synced 2026-08-20 07:23:37 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ensureModuleQuizCache, syncQuizLessonFromCache, writeQuizCache } from '@/lib/quiz'
|
||||
import { join, dirname } from 'path'
|
||||
const QUIZ_CACHE_FILE = 'quiz_cache.json'
|
||||
|
||||
async function getCourseWithModules(slug: string) {
|
||||
return prisma.course.findFirst({
|
||||
where: { slug, hidden: false },
|
||||
include: {
|
||||
modules: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: { lessons: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ slug: string }> }) {
|
||||
try {
|
||||
const { slug } = await params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const action = body?.action
|
||||
if (action === 'regenerate') {
|
||||
const course = await getCourseWithModules(slug)
|
||||
if (!course) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const sourceValue = body?.source
|
||||
const difficulty = typeof body?.difficulty === 'string' ? body.difficulty.toLowerCase() : 'medium'
|
||||
const results: Array<{ module: string; status: 'ok' | 'skipped'; error?: string }> = []
|
||||
|
||||
for (const module of course.modules) {
|
||||
const modulePath = join(course.path, module.name)
|
||||
try {
|
||||
const generated = await ensureModuleQuizCache({
|
||||
modulePath,
|
||||
topic: module.name,
|
||||
source: sourceValue === 'the-trivia-api' ? 'the-trivia-api' : 'quizapi',
|
||||
force: true,
|
||||
})
|
||||
|
||||
if (!generated) {
|
||||
results.push({ module: module.name, status: 'skipped', error: 'Intro/local-only quiz generation skipped' })
|
||||
continue
|
||||
}
|
||||
|
||||
await syncQuizLessonFromCache({
|
||||
moduleId: module.id,
|
||||
modulePath,
|
||||
courseRoot: dirname(course.path),
|
||||
topic: module.name,
|
||||
source: sourceValue === 'the-trivia-api' ? 'the-trivia-api' : 'quizapi',
|
||||
lessonOrder: module.lessons.length,
|
||||
autoFetch: false,
|
||||
force: true,
|
||||
})
|
||||
|
||||
results.push({ module: module.name, status: 'ok' })
|
||||
} catch (error) {
|
||||
results.push({ module: module.name, status: 'skipped', error: String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, action: 'regenerate', difficulty, results })
|
||||
}
|
||||
|
||||
if (action === 'import') {
|
||||
const course = await getCourseWithModules(slug)
|
||||
if (!course) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const moduleName = typeof body?.module === 'string' ? body.module.trim() : ''
|
||||
const targetModule = course.modules.find((item) => item.name === moduleName)
|
||||
if (!targetModule) {
|
||||
return NextResponse.json({ error: 'Module not found', availableModules: course.modules.map((item) => item.name) }, { status: 404 })
|
||||
}
|
||||
|
||||
const quiz = typeof body?.quiz === 'object' && body.quiz !== null ? (body.quiz as Record<string, unknown>) : null
|
||||
if (!quiz) {
|
||||
return NextResponse.json({ error: 'Missing quiz JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const modulePath = join(course.path, targetModule.name)
|
||||
const cachePath = join(modulePath, QUIZ_CACHE_FILE)
|
||||
await writeQuizCache(cachePath, quiz as never)
|
||||
|
||||
const synced = await syncQuizLessonFromCache({
|
||||
moduleId: targetModule.id,
|
||||
modulePath,
|
||||
courseRoot: dirname(course.path),
|
||||
topic: targetModule.name,
|
||||
source: 'the-trivia-api',
|
||||
lessonOrder: targetModule.lessons.length,
|
||||
autoFetch: false,
|
||||
force: true,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
action: 'import',
|
||||
module: targetModule.name,
|
||||
cachePath,
|
||||
lesson: synced?.lesson || null,
|
||||
})
|
||||
}
|
||||
|
||||
if (action === 'clear') {
|
||||
const course = await getCourseWithModules(slug)
|
||||
if (!course) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const moduleName = typeof body?.module === 'string' ? body.module.trim() : ''
|
||||
|
||||
if (moduleName === '__all__') {
|
||||
const moduleIds = course.modules.map((module) => module.id)
|
||||
const modulePaths = course.modules.map((module) => join(course.path, module.name, QUIZ_CACHE_FILE))
|
||||
|
||||
try {
|
||||
await prisma.lesson.deleteMany({ where: { moduleId: { in: moduleIds }, slug: 'quiz' } })
|
||||
} catch {
|
||||
// ignore if no quiz lessons exist
|
||||
}
|
||||
|
||||
try {
|
||||
const { unlink } = await import('fs/promises')
|
||||
await Promise.allSettled(modulePaths.map((cachePath) => unlink(cachePath).catch(() => {})))
|
||||
} catch {
|
||||
// ignore missing cache files
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, action: 'clear', module: '__all__' })
|
||||
}
|
||||
|
||||
if (!moduleName) {
|
||||
return NextResponse.json({ error: 'Missing module name' }, { status: 400 })
|
||||
}
|
||||
|
||||
const targetModule = course.modules.find((item) => item.name === moduleName)
|
||||
if (!targetModule) {
|
||||
return NextResponse.json({ error: 'Module not found', availableModules: course.modules.map((item) => item.name) }, { status: 404 })
|
||||
}
|
||||
|
||||
const modulePath = join(course.path, targetModule.name)
|
||||
const cachePath = join(modulePath, QUIZ_CACHE_FILE)
|
||||
|
||||
try {
|
||||
await prisma.lesson.deleteMany({ where: { moduleId: targetModule.id, slug: 'quiz' } })
|
||||
} catch {
|
||||
// ignore if no quiz lesson exists
|
||||
}
|
||||
|
||||
try {
|
||||
const { unlink } = await import('fs/promises')
|
||||
await unlink(cachePath).catch(() => {})
|
||||
} catch {
|
||||
// ignore missing cache file
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, action: 'clear', module: targetModule.name })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Unsupported action' }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error('Course quiz batch error:', error)
|
||||
return NextResponse.json({ error: 'Failed to process course quiz action' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { rm } from 'fs/promises'
|
||||
import { resolve, sep } from 'path'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const renameSchema = z.object({ displayName: z.string().trim().min(1).max(200) })
|
||||
const tagSchema = z.object({ tagId: z.string().min(1) })
|
||||
const favoriteSchema = z.object({ favorited: z.boolean() })
|
||||
|
||||
async function getCoursesRootPath() {
|
||||
const setting = await prisma.setting.findUnique({ where: { key: 'coursesRoot' } })
|
||||
return setting?.value || './My_Courses'
|
||||
}
|
||||
|
||||
async function findCourse(slug: string, includeHidden = false) {
|
||||
return prisma.course.findFirst({
|
||||
where: includeHidden ? { slug } : { slug, hidden: false },
|
||||
select: { id: true, slug: true, name: true, displayName: true, hidden: true, path: true },
|
||||
})
|
||||
}
|
||||
|
||||
function isPathWithinRoot(candidatePath: string, rootPath: string) {
|
||||
const normalizedCandidate = resolve(candidatePath)
|
||||
const normalizedRoot = resolve(rootPath)
|
||||
return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(normalizedRoot + sep)
|
||||
}
|
||||
|
||||
async function enrichCourseTags(course: { id: string }) {
|
||||
const connections = await prisma.courseTag.findMany({
|
||||
where: { courseId: course.id },
|
||||
include: { tag: true },
|
||||
})
|
||||
return connections.map(cn => cn.tag)
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||
try {
|
||||
const { slug } = await params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const rename = renameSchema.safeParse(body)
|
||||
const tag = body?.tagId ? tagSchema.safeParse(body) : null
|
||||
const favorite = body?.favorited !== undefined ? favoriteSchema.safeParse(body) : null
|
||||
|
||||
if (!rename.success && !tag?.success && !favorite?.success) {
|
||||
return NextResponse.json({ error: 'Invalid request', details: rename.error?.flatten() ?? {} }, { status: 400 })
|
||||
}
|
||||
|
||||
const existing = await findCourse(slug)
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
|
||||
if (rename.success) {
|
||||
data.displayName = rename.data.displayName
|
||||
}
|
||||
|
||||
if (favorite?.success) {
|
||||
data.favorited = favorite.data.favorited
|
||||
}
|
||||
|
||||
const tagRecord = tag?.success
|
||||
? await prisma.tag.upsert({
|
||||
where: { name: tag.data.tagId },
|
||||
update: {},
|
||||
create: { name: tag.data.tagId },
|
||||
})
|
||||
: null
|
||||
|
||||
const course = await prisma.course.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
hidden: true,
|
||||
path: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (tagRecord) {
|
||||
await prisma.courseTag.upsert({
|
||||
where: {
|
||||
courseId_tagId: {
|
||||
courseId: existing.id,
|
||||
tagId: tagRecord.id,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
courseId: existing.id,
|
||||
tagId: tagRecord.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const tags = await enrichCourseTags(course)
|
||||
|
||||
return NextResponse.json({ success: true, course: { ...course, tags } })
|
||||
} catch (error) {
|
||||
console.error('Course update error:', error)
|
||||
return NextResponse.json({ error: 'Failed to update course' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||
try {
|
||||
const { slug } = await params
|
||||
const scope = (request.nextUrl.searchParams.get('scope') || 'library').toLowerCase()
|
||||
|
||||
if (scope !== 'library' && scope !== 'disk' && scope !== 'tag') {
|
||||
return NextResponse.json({ error: 'Invalid delete scope. Use library, disk, or tag.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const existing = await findCourse(slug, scope === 'disk')
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (scope === 'disk') {
|
||||
const coursesRoot = await getCoursesRootPath()
|
||||
const absoluteCoursePath = resolve(existing.path)
|
||||
const absoluteCoursesRoot = resolve(coursesRoot)
|
||||
|
||||
if (!isPathWithinRoot(absoluteCoursePath, absoluteCoursesRoot)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Course path is outside the configured courses root',
|
||||
details: { coursePath: absoluteCoursePath, coursesRoot: absoluteCoursesRoot },
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await rm(absoluteCoursePath, { recursive: true, force: true })
|
||||
await prisma.course.delete({ where: { id: existing.id } })
|
||||
|
||||
return NextResponse.json({ success: true, deletedFromDisk: true, path: absoluteCoursePath })
|
||||
}
|
||||
|
||||
if (scope === 'tag') {
|
||||
const { tagId } = await request.json().catch(() => ({ tagId: '' }))
|
||||
|
||||
if (!tagId) {
|
||||
return NextResponse.json({ error: 'tagId is required when removing a tag' }, { status: 400 })
|
||||
}
|
||||
|
||||
await prisma.courseTag.deleteMany({
|
||||
where: { courseId: existing.id, tagId },
|
||||
})
|
||||
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { id: existing.id },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
hidden: true,
|
||||
path: true,
|
||||
},
|
||||
})
|
||||
|
||||
const tags = course ? await enrichCourseTags(course) : []
|
||||
return NextResponse.json({ success: true, course: course ? { ...course, tags } : null })
|
||||
}
|
||||
|
||||
await prisma.course.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
hidden: true,
|
||||
displayName: null,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, deletedFromLibrary: true })
|
||||
} catch (error) {
|
||||
console.error('Course delete error:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete course' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getLocalThumbnailUrl } from '@/lib/thumbnail-index-server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = request.nextUrl
|
||||
|
||||
// Pagination
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 100)
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
// Search
|
||||
const search = searchParams.get('search') || ''
|
||||
|
||||
// Filters
|
||||
const filter = searchParams.get('filter') || 'all' // all, in-progress, completed, not-started, favorites, tag:<tagId>
|
||||
const tag = searchParams.get('tag') || ''
|
||||
const tagFilter = tag || (filter.startsWith('tag:') ? filter.slice(4) : '')
|
||||
const favoritesOnly = searchParams.get('favorites') === 'true'
|
||||
const sortBy = searchParams.get('sortBy') || 'updatedAt'
|
||||
const sortOrder = searchParams.get('sortOrder') || 'desc'
|
||||
|
||||
// Build where clause
|
||||
const where: any = { hidden: false }
|
||||
|
||||
if (search) {
|
||||
const term = search.trim()
|
||||
where.OR = [
|
||||
{ name: { contains: term } },
|
||||
{ displayName: { contains: term } },
|
||||
{ slug: { contains: term } },
|
||||
]
|
||||
}
|
||||
|
||||
if (favoritesOnly || filter === 'favorites') {
|
||||
where.favorited = true
|
||||
}
|
||||
|
||||
if (tagFilter) {
|
||||
where.courseTags = { some: { tagId: tagFilter } }
|
||||
}
|
||||
|
||||
if (filter === 'in-progress') {
|
||||
where.progress = {
|
||||
some: { userId: 'local-user', completed: false, lessonId: null },
|
||||
}
|
||||
} else if (filter === 'completed') {
|
||||
where.progress = {
|
||||
some: { userId: 'local-user', completed: true, lessonId: null },
|
||||
}
|
||||
} else if (filter === 'not-started') {
|
||||
where.NOT = {
|
||||
progress: { some: { userId: 'local-user', lessonId: null } },
|
||||
}
|
||||
}
|
||||
|
||||
// Build orderBy. Favorites are pinned above non-favorites for library organization.
|
||||
const secondaryOrderBy: any = {}
|
||||
if (sortBy === 'name') {
|
||||
secondaryOrderBy.name = sortOrder
|
||||
} else if (sortBy === 'progress') {
|
||||
secondaryOrderBy.updatedAt = sortOrder
|
||||
} else {
|
||||
secondaryOrderBy[sortBy] = sortOrder
|
||||
}
|
||||
const orderBy: any[] = [{ favorited: 'desc' }, secondaryOrderBy]
|
||||
|
||||
const total = await prisma.course.count({ where })
|
||||
|
||||
const courses = await prisma.course.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip: Math.max(0, skip),
|
||||
take: limit,
|
||||
include: {
|
||||
_count: { select: { modules: true } },
|
||||
progress: {
|
||||
where: { userId: 'local-user', lessonId: null },
|
||||
},
|
||||
courseTags: {
|
||||
include: { tag: true },
|
||||
orderBy: { tag: { name: 'asc' } },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const courseIds = courses.map(c => c.id)
|
||||
const lessonCounts = await prisma.lesson.groupBy({
|
||||
by: ['moduleId'],
|
||||
where: { module: { courseId: { in: courseIds } } },
|
||||
_count: true,
|
||||
})
|
||||
|
||||
const moduleToCourse = new Map<string, string>()
|
||||
const modules = await prisma.module.findMany({
|
||||
where: { courseId: { in: courseIds } },
|
||||
select: { id: true, courseId: true },
|
||||
})
|
||||
modules.forEach(m => moduleToCourse.set(m.id, m.courseId))
|
||||
|
||||
const moduleLessonCounts = new Map<string, number>()
|
||||
lessonCounts.forEach(lc => {
|
||||
const courseId = moduleToCourse.get(lc.moduleId as string)
|
||||
if (courseId) {
|
||||
moduleLessonCounts.set(courseId, (moduleLessonCounts.get(courseId) || 0) + lc._count)
|
||||
}
|
||||
})
|
||||
|
||||
const coursesWithProgress = await Promise.all(courses.map(async (course) => {
|
||||
const courseProgress = course.progress[0]
|
||||
const totalLessons = moduleLessonCounts.get(course.id) || 0
|
||||
let completedLessons = 0
|
||||
let percentage = 0
|
||||
let lastWatched = null
|
||||
|
||||
if (courseProgress) {
|
||||
completedLessons = courseProgress.completed ? totalLessons : 0
|
||||
percentage = courseProgress.completed ? 100 : 0
|
||||
lastWatched = courseProgress.lastWatched.toISOString()
|
||||
}
|
||||
|
||||
return {
|
||||
...course,
|
||||
_count: { ...course._count, lessons: totalLessons },
|
||||
progress: { completedLessons, totalLessons, percentage, lastWatched },
|
||||
thumbnail: await getLocalThumbnailUrl(course.slug) ?? null,
|
||||
description: course.description,
|
||||
tags: course.courseTags.map((connection) => connection.tag),
|
||||
}
|
||||
}))
|
||||
|
||||
if (sortBy === 'progress') {
|
||||
coursesWithProgress.sort((a, b) =>
|
||||
sortOrder === 'asc'
|
||||
? a.progress.percentage - b.progress.percentage
|
||||
: b.progress.percentage - a.progress.percentage
|
||||
)
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
const tags = await prisma.tag.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: { _count: { select: { courseTags: true } } },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
courses: coursesWithProgress,
|
||||
tags,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Courses API error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch courses', details: String(error) },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user