mirror of
https://github.com/nicetry247/offlineacademy.git
synced 2026-07-26 20:09:57 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47a2690ec7 | |||
| fe22dafe00 | |||
| c027d18d21 | |||
| 0f9bb69b18 | |||
| 2b8705902a | |||
| 159e07543d | |||
| ea72934716 | |||
| 16fea94bb4 | |||
| 39544c57ce | |||
| 95aa68b121 | |||
| cc6ec4bdbe | |||
| b614295629 | |||
| 7d0f976da0 | |||
| 33e5e61b11 | |||
| 3effbdcb57 | |||
| 6076267abd | |||
| 0ed055b83b | |||
| ae093a62bf | |||
| 22871f6679 | |||
| 7ce7941712 |
@@ -42,6 +42,7 @@
|
||||
- [Security Notes](#security-notes)
|
||||
- [Support the Project](#support-the-project)
|
||||
- [License](#license)
|
||||
- [Changelog](#changelog)
|
||||
|
||||
---
|
||||
|
||||
@@ -354,7 +355,7 @@ For a NAS, USB datastore, or other external drive, change only the left side of
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /mnt/usb-datastore/courses:/app/My_Courses
|
||||
- /path/to/your/courses:/app/My_Courses
|
||||
- ./prisma_data:/app/prisma/data
|
||||
```
|
||||
|
||||
@@ -445,6 +446,9 @@ My_Courses/
|
||||
│ └── 01 - Welcome.mp4
|
||||
├── 02 - Core Concepts/
|
||||
│ ├── 01 - Lesson One.mp4
|
||||
│ ├── 01 - Lesson One.en.vtt
|
||||
│ ├── 01 - Lesson One.es.vtt
|
||||
│ ├── 01 - Lesson One.ja.vtt
|
||||
│ └── 02 - Lesson Two.mp4
|
||||
└── 03 - Practice/
|
||||
└── 01 - Lab Walkthrough.mp4
|
||||
@@ -452,6 +456,29 @@ My_Courses/
|
||||
|
||||
Supported video extensions include common browser-playable formats such as `.mp4`, plus additional local media formats depending on browser support.
|
||||
|
||||
### Subtitles
|
||||
|
||||
OfflineAcademy supports subtitle files stored beside the matching video. `.vtt` files are served directly; `.srt` files are detected and converted to browser-compatible WebVTT when served to the video player.
|
||||
|
||||
Single subtitle track:
|
||||
|
||||
```text
|
||||
01 - Lesson.mp4
|
||||
01 - Lesson.vtt
|
||||
```
|
||||
|
||||
Multiple language tracks:
|
||||
|
||||
```text
|
||||
01 - Lesson.mp4
|
||||
01 - Lesson.en.vtt
|
||||
01 - Lesson.es.vtt
|
||||
01 - Lesson.ja.vtt
|
||||
01 - Lesson.fil.vtt
|
||||
```
|
||||
|
||||
Language suffixes become selectable tracks in the video player settings menu. Examples: `en`, `es`, `fr`, `ja`, `fil`, `zh-CN`, `pt-BR`.
|
||||
|
||||
---
|
||||
|
||||
## Data & Persistence
|
||||
@@ -551,3 +578,31 @@ OfflineAcademy is released under the **MIT License**.
|
||||
You are free to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the software, subject to the terms of the MIT License.
|
||||
|
||||
See [`LICENSE`](LICENSE) for the full license text.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### v1.0.2 — Subtitle & Analytics Polish
|
||||
|
||||
This release focuses on making OfflineAcademy smoother for real course libraries, especially Udemy-style folders with external subtitle files.
|
||||
|
||||
#### Added
|
||||
|
||||
- Multi-language subtitle support for local course videos.
|
||||
- SRT and VTT subtitle detection during course scans.
|
||||
- Visible subtitle controls in the video player when tracks are available.
|
||||
- Support for multiple subtitle tracks per lesson, such as English and Persian.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Same-folder subtitle imports for files stored outside the app container and mounted into the course library.
|
||||
- Subtitle filename matching for common Udemy-style exports, including patterns like `-english-test.us.srt` and `-persian-test.us.srt`.
|
||||
- Lesson API subtitle ordering so tracks load consistently in the player.
|
||||
- Analytics page data loading by reading analytics directly on the server instead of relying on a fragile internal fetch.
|
||||
|
||||
#### Improved
|
||||
|
||||
- Course scanning now better handles real-world course folder layouts.
|
||||
- Subtitle files are served as browser-compatible `text/vtt`, including converted SRT files.
|
||||
- Local development workflow was verified on port `6969` after cache cleanup and rebuild.
|
||||
|
||||
+1
-29
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Header } from '@/components/Header'
|
||||
import { getAnalyticsData } from '@/lib/analytics'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -23,35 +24,6 @@ export const metadata: Metadata = {
|
||||
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" />,
|
||||
|
||||
+146
-100
@@ -1,38 +1,48 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getLocalThumbnailUrl } from '@/lib/thumbnail-index-server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10
|
||||
const MAX_PAGE_SIZE = 100
|
||||
const SORT_FIELDS = new Set(['updatedAt', 'createdAt', 'name', 'progress'])
|
||||
|
||||
type SortOrder = 'asc' | 'desc'
|
||||
|
||||
function parsePositiveInteger(value: string | null, fallback: number): number {
|
||||
const parsed = Number.parseInt(value ?? '', 10)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const startedAt = performance.now()
|
||||
|
||||
try {
|
||||
const { searchParams } = request.nextUrl
|
||||
|
||||
// Pagination
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 100)
|
||||
const page = parsePositiveInteger(searchParams.get('page'), 1)
|
||||
const limit = Math.min(
|
||||
parsePositiveInteger(searchParams.get('limit'), DEFAULT_PAGE_SIZE),
|
||||
MAX_PAGE_SIZE
|
||||
)
|
||||
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 search = (searchParams.get('search') || '').trim()
|
||||
const filter = searchParams.get('filter') || 'all'
|
||||
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 }
|
||||
const requestedSort = searchParams.get('sortBy') || 'updatedAt'
|
||||
const sortBy = SORT_FIELDS.has(requestedSort) ? requestedSort : 'updatedAt'
|
||||
const sortOrder: SortOrder = searchParams.get('sortOrder') === 'asc' ? 'asc' : 'desc'
|
||||
|
||||
const where: Prisma.CourseWhereInput = { hidden: false }
|
||||
|
||||
if (search) {
|
||||
const term = search.trim()
|
||||
where.OR = [
|
||||
{ name: { contains: term } },
|
||||
{ displayName: { contains: term } },
|
||||
{ slug: { contains: term } },
|
||||
{ name: { contains: search } },
|
||||
{ displayName: { contains: search } },
|
||||
{ slug: { contains: search } },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -58,96 +68,120 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 secondaryOrderBy: Prisma.CourseOrderByWithRelationInput =
|
||||
sortBy === 'name'
|
||||
? { name: sortOrder }
|
||||
: { updatedAt: sortOrder }
|
||||
|
||||
const total = await prisma.course.count({ where })
|
||||
const orderBy: Prisma.CourseOrderByWithRelationInput[] = [
|
||||
{ favorited: 'desc' },
|
||||
secondaryOrderBy,
|
||||
]
|
||||
|
||||
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 },
|
||||
const databaseStartedAt = performance.now()
|
||||
const [total, courses, tags] = await Promise.all([
|
||||
prisma.course.count({ where }),
|
||||
prisma.course.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip: Math.max(0, skip),
|
||||
take: limit,
|
||||
include: {
|
||||
_count: { select: { modules: true } },
|
||||
progress: {
|
||||
where: { userId: 'local-user', lessonId: null, moduleId: null },
|
||||
orderBy: { lastWatched: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
courseTags: {
|
||||
include: { tag: true },
|
||||
orderBy: { tag: { name: 'asc' } },
|
||||
},
|
||||
},
|
||||
courseTags: {
|
||||
include: { tag: true },
|
||||
orderBy: { tag: { name: 'asc' } },
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
prisma.tag.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: { _count: { select: { courseTags: true } } },
|
||||
}),
|
||||
])
|
||||
const initialDatabaseDuration = performance.now() - databaseStartedAt
|
||||
|
||||
const courseIds = courses.map(c => c.id)
|
||||
const lessonCounts = await prisma.lesson.groupBy({
|
||||
by: ['moduleId'],
|
||||
where: { module: { courseId: { in: courseIds } } },
|
||||
_count: true,
|
||||
})
|
||||
const courseIds = courses.map((course) => course.id)
|
||||
const enrichStartedAt = performance.now()
|
||||
|
||||
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 [modulesWithCounts, completedProgress] = courseIds.length > 0
|
||||
? await Promise.all([
|
||||
prisma.module.findMany({
|
||||
where: { courseId: { in: courseIds } },
|
||||
select: {
|
||||
courseId: true,
|
||||
_count: { select: { lessons: true } },
|
||||
},
|
||||
}),
|
||||
prisma.progress.groupBy({
|
||||
by: ['courseId'],
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
courseId: { in: courseIds },
|
||||
lessonId: { not: null },
|
||||
completed: true,
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
])
|
||||
: [[], []]
|
||||
|
||||
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 lessonCountByCourse = new Map<string, number>()
|
||||
for (const module of modulesWithCounts) {
|
||||
lessonCountByCourse.set(
|
||||
module.courseId,
|
||||
(lessonCountByCourse.get(module.courseId) || 0) + module._count.lessons
|
||||
)
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
const tags = await prisma.tag.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: { _count: { select: { courseTags: true } } },
|
||||
})
|
||||
const completedCountByCourse = new Map<string, number>(
|
||||
completedProgress.map((entry) => [entry.courseId, Number(entry._count._all)])
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
const coursesWithProgress = await Promise.all(
|
||||
courses.map(async (course) => {
|
||||
const totalLessons = lessonCountByCourse.get(course.id) || 0
|
||||
const completedLessons = Math.min(
|
||||
completedCountByCourse.get(course.id) || 0,
|
||||
totalLessons
|
||||
)
|
||||
const percentage = totalLessons > 0
|
||||
? Math.round((completedLessons / totalLessons) * 1000) / 10
|
||||
: 0
|
||||
const summaryProgress = course.progress[0]
|
||||
const thumbnail = course.thumbnail || await getLocalThumbnailUrl(course.slug)
|
||||
const { courseTags, progress: _progressRows, ...courseData } = course
|
||||
|
||||
return {
|
||||
...courseData,
|
||||
_count: { ...course._count, lessons: totalLessons },
|
||||
progress: {
|
||||
completedLessons,
|
||||
totalLessons,
|
||||
percentage,
|
||||
lastWatched: summaryProgress?.lastWatched.toISOString() ?? null,
|
||||
},
|
||||
thumbnail: thumbnail ?? null,
|
||||
tags: courseTags.map((connection) => connection.tag),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if (sortBy === 'progress') {
|
||||
coursesWithProgress.sort((a, b) => {
|
||||
const difference = a.progress.percentage - b.progress.percentage
|
||||
return sortOrder === 'asc' ? difference : -difference
|
||||
})
|
||||
}
|
||||
|
||||
const enrichDuration = performance.now() - enrichStartedAt
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
const response = NextResponse.json({
|
||||
courses: coursesWithProgress,
|
||||
tags,
|
||||
pagination: {
|
||||
@@ -159,6 +193,18 @@ export async function GET(request: NextRequest) {
|
||||
hasPrev: page > 1,
|
||||
},
|
||||
})
|
||||
|
||||
response.headers.set(
|
||||
'Server-Timing',
|
||||
[
|
||||
`db;dur=${initialDatabaseDuration.toFixed(1)}`,
|
||||
`enrich;dur=${enrichDuration.toFixed(1)}`,
|
||||
`total;dur=${(performance.now() - startedAt).toFixed(1)}`,
|
||||
].join(', ')
|
||||
)
|
||||
response.headers.set('Cache-Control', 'private, no-store')
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Courses API error:', error)
|
||||
return NextResponse.json(
|
||||
@@ -166,4 +212,4 @@ export async function GET(request: NextRequest) {
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { existsSync, statSync, createReadStream } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { join, resolve } from 'path'
|
||||
import { getCoursesRootPath } from '@/lib/scanner'
|
||||
import { convertSrtToVtt } from '@/lib/subtitles'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -79,6 +80,22 @@ export async function GET(
|
||||
txt: 'text/plain; charset=utf-8',
|
||||
}
|
||||
const contentType = mimeTypes[actualExt || ''] || 'application/octet-stream'
|
||||
|
||||
// Browsers expect WebVTT for <track>. Serve .srt subtitles as converted WebVTT.
|
||||
if (actualExt === 'srt') {
|
||||
const srtContent = await readFile(actualPath, 'utf8')
|
||||
const vttContent = convertSrtToVtt(srtContent)
|
||||
return new NextResponse(vttContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Length': Buffer.byteLength(vttContent, 'utf8').toString(),
|
||||
'Content-Type': 'text/vtt; charset=utf-8',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Content-Disposition': `inline; filename="${actualPath.split('/').pop()?.replace(/\.srt$/i, '.vtt')}"`,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Handle range requests (video seeking)
|
||||
if (range) {
|
||||
|
||||
@@ -18,6 +18,9 @@ export async function GET(
|
||||
progress: {
|
||||
where: { userId: 'local-user' },
|
||||
},
|
||||
subtitles: {
|
||||
orderBy: { lang: 'asc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getAnalyticsData } from '@/lib/analytics'
|
||||
import { z } from 'zod'
|
||||
|
||||
const progressSchema = z.object({
|
||||
@@ -165,89 +166,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
return NextResponse.json(await getAnalyticsData())
|
||||
}
|
||||
|
||||
if (lessonId) {
|
||||
|
||||
+26
-10
@@ -1,10 +1,26 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import './v2.css'
|
||||
import { Providers } from './providers'
|
||||
import Script from 'next/script'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
|
||||
const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' })
|
||||
|
||||
const themeBootstrap = `
|
||||
(function () {
|
||||
try {
|
||||
var preference = localStorage.getItem('theme') || 'system';
|
||||
var dark = preference === 'dark' || (preference === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
var root = document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(dark ? 'dark' : 'light');
|
||||
root.style.colorScheme = dark ? 'dark' : 'light';
|
||||
} catch (_) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.colorScheme = 'dark';
|
||||
}
|
||||
})();`
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -20,7 +36,7 @@ export const metadata: Metadata = {
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'en_US',
|
||||
url: 'http://localhost:6767',
|
||||
url: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:6969',
|
||||
siteName: 'OfflineAcademy',
|
||||
title: 'OfflineAcademy — Your Personal Offline Learning Center',
|
||||
description: 'A self-hosted, privacy-first learning platform for offline courses.',
|
||||
@@ -46,10 +62,10 @@ export const metadata: Metadata = {
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#0a0e14' },
|
||||
{ media: '(prefers-color-scheme: light)', color: '#f8fafc' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#090d16' },
|
||||
],
|
||||
colorScheme: 'dark',
|
||||
colorScheme: 'dark light',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
@@ -61,16 +77,16 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeBootstrap }} />
|
||||
<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)" />
|
||||
<meta name="theme-color" content="#090d16" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#f8fafc" media="(prefers-color-scheme: light)" />
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased min-h-screen bg-background`}>
|
||||
<body className={`${inter.variable} min-h-screen bg-background font-sans antialiased`}>
|
||||
<Providers>{children}</Providers>
|
||||
<Script id="sw-registration" strategy="lazyOnload">
|
||||
{`if ('serviceWorker' in navigator) {
|
||||
@@ -82,4 +98,4 @@ export default function RootLayout({
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ const liveGroups: FeatureGroup[] = [
|
||||
items: [
|
||||
'Bookmarks with timestamp notes',
|
||||
'Jump-to-time bookmark playback',
|
||||
'Subtitle support for .srt and .vtt',
|
||||
'Multi-language subtitle tracks for .vtt/.srt files',
|
||||
'Inline document viewing for lessons',
|
||||
],
|
||||
},
|
||||
|
||||
+27
-6
@@ -5,23 +5,44 @@ import { ThemeProvider } from '@/components/ThemeToggle'
|
||||
import { ToastProvider } from '@/hooks/use-toast'
|
||||
import { SplashScreen } from '@/components/SplashScreen'
|
||||
|
||||
const SPLASH_SESSION_KEY = 'offlineacademy:splash-seen'
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [showSplash, setShowSplash] = React.useState(true)
|
||||
|
||||
// Safety fallback: force hide after 5 seconds max
|
||||
React.useLayoutEffect(() => {
|
||||
try {
|
||||
if (sessionStorage.getItem(SPLASH_SESSION_KEY) === 'true') {
|
||||
setShowSplash(false)
|
||||
}
|
||||
} catch {
|
||||
setShowSplash(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setShowSplash(false), 5000)
|
||||
return () => clearTimeout(timer)
|
||||
if (!showSplash) return
|
||||
const timer = window.setTimeout(() => setShowSplash(false), 2500)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [showSplash])
|
||||
|
||||
const completeSplash = React.useCallback(() => {
|
||||
try {
|
||||
sessionStorage.setItem(SPLASH_SESSION_KEY, 'true')
|
||||
} catch {
|
||||
// Storage can be unavailable in hardened/private browser modes.
|
||||
}
|
||||
setShowSplash(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ToastProvider>
|
||||
{children}
|
||||
{showSplash && (
|
||||
<SplashScreen onComplete={() => setShowSplash(false)} minDuration={1500} />
|
||||
<SplashScreen onComplete={completeSplash} minDuration={550} />
|
||||
)}
|
||||
{!showSplash && children}
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -15,6 +15,7 @@ export default function ScanPage() {
|
||||
const [result, setResult] = useState<{
|
||||
coursesCreated: number
|
||||
coursesUpdated: number
|
||||
coursesDeleted: number
|
||||
modulesCreated: number
|
||||
modulesUpdated: number
|
||||
lessonsCreated: number
|
||||
@@ -53,7 +54,9 @@ export default function ScanPage() {
|
||||
|
||||
setResult(data)
|
||||
addLog(`Scan completed in ${data.duration}ms`)
|
||||
addLog(`Courses: ${data.coursesCreated} created, ${data.coursesUpdated} updated`)
|
||||
addLog(
|
||||
`Courses: ${data.coursesCreated} created, ${data.coursesUpdated} updated, ${data.coursesDeleted} deleted`
|
||||
)
|
||||
addLog(`Modules: ${data.modulesCreated} created, ${data.modulesUpdated} updated`)
|
||||
addLog(`Lessons: ${data.lessonsCreated} created, ${data.lessonsUpdated} updated`)
|
||||
|
||||
@@ -182,7 +185,8 @@ export default function ScanPage() {
|
||||
├── Course Name 1/
|
||||
│ ├── 01 - Introduction/
|
||||
│ │ ├── 01 - Introduction.mp4
|
||||
│ │ ├── 01 - Introduction.srt
|
||||
│ │ ├── 01 - Introduction.en.vtt
|
||||
│ │ ├── 01 - Introduction.es.vtt
|
||||
│ │ ├── 02 - Overview.pdf
|
||||
│ │ └── 03 - Notes.txt
|
||||
│ ├── 02 - Advanced/
|
||||
@@ -204,7 +208,8 @@ export default function ScanPage() {
|
||||
</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>Matching rule:</strong> subtitle files can either share the exact video base name, 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.vtt</code>, or use language suffixes for multiple tracks, like <code className="bg-background px-1.5 py-0.5 rounded">lesson.en.vtt</code> + <code className="bg-background px-1.5 py-0.5 rounded">lesson.es.vtt</code>.</p>
|
||||
<p><strong>Subtitle formats:</strong> <code className="bg-background px-1.5 py-0.5 rounded">.vtt</code> is preferred for browser playback; <code className="bg-background px-1.5 py-0.5 rounded">.srt</code> files are detected and listed as best-effort tracks.</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>
|
||||
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
/* OfflineAcademy v2: a calmer, higher-contrast dark workspace for long study sessions. */
|
||||
|
||||
html:not(.light),
|
||||
html.dark {
|
||||
--background: 224 39% 6%;
|
||||
--background-elevated: 222 31% 9%;
|
||||
--background-glass: 222 28% 10% / 0.82;
|
||||
--foreground: 210 40% 98%;
|
||||
--foreground-muted: 215 18% 70%;
|
||||
--foreground-subtle: 215 14% 50%;
|
||||
--card: 222 26% 10% / 0.94;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--card-border: 219 24% 21% / 0.78;
|
||||
--card-border-hover: 158 58% 43% / 0.48;
|
||||
--popover: 222 28% 9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 158 67% 47%;
|
||||
--primary-glow: 158 72% 46% / 0.24;
|
||||
--primary-foreground: 224 42% 6%;
|
||||
--primary-muted: 158 67% 47% / 0.12;
|
||||
--accent: 39 92% 61%;
|
||||
--accent-glow: 39 92% 61% / 0.2;
|
||||
--accent-foreground: 224 42% 6%;
|
||||
--accent-muted: 39 92% 61% / 0.12;
|
||||
--secondary: 222 22% 13%;
|
||||
--secondary-foreground: 210 32% 96%;
|
||||
--secondary-border: 220 20% 20%;
|
||||
--muted: 222 20% 12%;
|
||||
--muted-foreground: 215 16% 66%;
|
||||
--border: 220 20% 18%;
|
||||
--input: 222 22% 12%;
|
||||
--ring: 158 67% 47%;
|
||||
--radius: 0.9rem;
|
||||
--radius-lg: 1.15rem;
|
||||
--radius-xl: 1.5rem;
|
||||
}
|
||||
|
||||
html:not(.light) body,
|
||||
html.dark body {
|
||||
min-height: 100vh;
|
||||
background-color: hsl(var(--background));
|
||||
background-image:
|
||||
radial-gradient(circle at 14% -8%, hsl(var(--primary) / 0.1), transparent 32rem),
|
||||
radial-gradient(circle at 88% 2%, hsl(214 75% 52% / 0.07), transparent 30rem),
|
||||
linear-gradient(180deg, hsl(224 37% 7%), hsl(var(--background)) 42rem);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
html:not(.light) body::before,
|
||||
html.dark body::before {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
opacity: 0.2;
|
||||
background-image: linear-gradient(hsl(0 0% 100% / 0.012) 1px, transparent 1px),
|
||||
linear-gradient(90deg, hsl(0 0% 100% / 0.012) 1px, transparent 1px);
|
||||
background-size: 36px 36px;
|
||||
mask-image: linear-gradient(to bottom, black, transparent 75%);
|
||||
}
|
||||
|
||||
html:not(.light) header.sticky,
|
||||
html.dark header.sticky {
|
||||
border-color: hsl(var(--border) / 0.9) !important;
|
||||
background: hsl(var(--background) / 0.84) !important;
|
||||
box-shadow: 0 10px 36px -28px hsl(0 0% 0% / 0.92);
|
||||
backdrop-filter: blur(22px) saturate(135%);
|
||||
}
|
||||
|
||||
html:not(.light) header h1,
|
||||
html.dark header h1 {
|
||||
color: hsl(var(--foreground));
|
||||
background: none !important;
|
||||
letter-spacing: -0.035em;
|
||||
-webkit-text-fill-color: currentColor;
|
||||
}
|
||||
|
||||
html:not(.light) header img[alt='OfflineAcademy'],
|
||||
html.dark header img[alt='OfflineAcademy'] {
|
||||
filter: drop-shadow(0 7px 18px hsl(0 0% 0% / 0.35));
|
||||
}
|
||||
|
||||
html:not(.light) header + .border-b,
|
||||
html.dark header + .border-b {
|
||||
border-color: hsl(var(--border) / 0.82) !important;
|
||||
background: linear-gradient(180deg, hsl(var(--background-elevated) / 0.68), transparent);
|
||||
}
|
||||
|
||||
html:not(.light) .glass-card,
|
||||
html:not(.light) .glass-card-elevated,
|
||||
html.dark .glass-card,
|
||||
html.dark .glass-card-elevated {
|
||||
border: 1px solid hsl(var(--card-border));
|
||||
background:
|
||||
linear-gradient(180deg, hsl(0 0% 100% / 0.025), transparent 42%),
|
||||
hsl(var(--card));
|
||||
box-shadow:
|
||||
0 24px 58px -42px hsl(0 0% 0% / 0.95),
|
||||
0 1px 0 hsl(0 0% 100% / 0.035) inset;
|
||||
backdrop-filter: blur(18px) saturate(112%);
|
||||
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
html:not(.light) .glass-card:hover,
|
||||
html:not(.light) .glass-card-elevated:hover,
|
||||
html.dark .glass-card:hover,
|
||||
html.dark .glass-card-elevated:hover {
|
||||
border-color: hsl(var(--card-border-hover));
|
||||
box-shadow:
|
||||
0 28px 64px -38px hsl(0 0% 0% / 0.98),
|
||||
0 18px 42px -34px hsl(var(--primary) / 0.48),
|
||||
0 1px 0 hsl(0 0% 100% / 0.05) inset;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
html:not(.light) article,
|
||||
html.dark article {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 440px;
|
||||
}
|
||||
|
||||
html:not(.light) .glass-card-elevated .aspect-video img,
|
||||
html.dark .glass-card-elevated .aspect-video img {
|
||||
filter: saturate(0.9) contrast(1.04);
|
||||
}
|
||||
|
||||
html:not(.light) .stat-badge,
|
||||
html.dark .stat-badge {
|
||||
min-height: 2.35rem;
|
||||
border-color: hsl(var(--secondary-border) / 0.85);
|
||||
background: hsl(var(--secondary) / 0.72);
|
||||
color: hsl(var(--foreground-muted));
|
||||
box-shadow: 0 1px 0 hsl(0 0% 100% / 0.025) inset;
|
||||
}
|
||||
|
||||
html:not(.light) .stat-badge-icon,
|
||||
html:not(.light) .stat-badge-icon-accent,
|
||||
html:not(.light) .stat-badge-icon-green,
|
||||
html:not(.light) .stat-badge-icon-blue,
|
||||
html.dark .stat-badge-icon,
|
||||
html.dark .stat-badge-icon-accent,
|
||||
html.dark .stat-badge-icon-green,
|
||||
html.dark .stat-badge-icon-blue {
|
||||
border: 1px solid hsl(0 0% 100% / 0.045);
|
||||
}
|
||||
|
||||
html:not(.light) .header-control,
|
||||
html.dark .header-control {
|
||||
min-height: 2.35rem;
|
||||
border-color: transparent;
|
||||
color: hsl(var(--foreground-muted));
|
||||
}
|
||||
|
||||
html:not(.light) .header-control:hover,
|
||||
html.dark .header-control:hover {
|
||||
border-color: hsl(var(--border));
|
||||
background: hsl(var(--secondary) / 0.88);
|
||||
color: hsl(var(--foreground));
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
html:not(.light) .btn-premium-primary,
|
||||
html.dark .btn-premium-primary {
|
||||
border: 1px solid hsl(var(--primary) / 0.72);
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
font-weight: 650;
|
||||
box-shadow: 0 12px 28px -18px hsl(var(--primary) / 0.78);
|
||||
}
|
||||
|
||||
html:not(.light) .btn-premium-primary:hover,
|
||||
html.dark .btn-premium-primary:hover {
|
||||
background: hsl(158 70% 52%);
|
||||
box-shadow: 0 16px 34px -20px hsl(var(--primary) / 0.9);
|
||||
}
|
||||
|
||||
html:not(.light) input,
|
||||
html:not(.light) textarea,
|
||||
html:not(.light) button[role='combobox'],
|
||||
html.dark input,
|
||||
html.dark textarea,
|
||||
html.dark button[role='combobox'] {
|
||||
border-color: hsl(var(--border) / 0.95) !important;
|
||||
background: hsl(var(--input) / 0.82) !important;
|
||||
box-shadow: 0 1px 0 hsl(0 0% 100% / 0.025) inset;
|
||||
}
|
||||
|
||||
html:not(.light) input:focus,
|
||||
html:not(.light) textarea:focus,
|
||||
html:not(.light) button[role='combobox']:focus,
|
||||
html.dark input:focus,
|
||||
html.dark textarea:focus,
|
||||
html.dark button[role='combobox']:focus {
|
||||
border-color: hsl(var(--primary) / 0.7) !important;
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.12) !important;
|
||||
}
|
||||
|
||||
html:not(.light) [data-radix-popper-content-wrapper] > *,
|
||||
html.dark [data-radix-popper-content-wrapper] > * {
|
||||
border-color: hsl(var(--border));
|
||||
background: hsl(var(--popover) / 0.98);
|
||||
box-shadow: 0 24px 64px -30px hsl(0 0% 0% / 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
html:not(.light) .gradient-progress,
|
||||
html.dark .gradient-progress {
|
||||
overflow: hidden;
|
||||
background: hsl(var(--secondary));
|
||||
box-shadow: 0 0 0 1px hsl(var(--border) / 0.65) inset;
|
||||
}
|
||||
|
||||
html:not(.light) progress.gradient-progress::-webkit-progress-value,
|
||||
html.dark progress.gradient-progress::-webkit-progress-value,
|
||||
html:not(.light) .gradient-progress > div,
|
||||
html.dark .gradient-progress > div {
|
||||
background: linear-gradient(90deg, hsl(158 67% 43%), hsl(var(--primary)));
|
||||
box-shadow: 0 0 18px -7px hsl(var(--primary) / 0.7);
|
||||
}
|
||||
|
||||
html:not(.light) main section h2,
|
||||
html:not(.light) main > div > h2,
|
||||
html.dark main section h2,
|
||||
html.dark main > div > h2 {
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
html:not(.light) main .grid,
|
||||
html.dark main .grid {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
html:not(.light) code,
|
||||
html.dark code {
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--secondary) / 0.9) !important;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
html:not(.light) ::selection,
|
||||
html.dark ::selection {
|
||||
background: hsl(var(--primary) / 0.28);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
html:not(.light) header.sticky,
|
||||
html.dark header.sticky {
|
||||
background: hsl(var(--background) / 0.94) !important;
|
||||
}
|
||||
|
||||
html:not(.light) .stat-badge,
|
||||
html.dark .stat-badge {
|
||||
padding-inline: 0.65rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
html:not(.light) .glass-card-elevated button[aria-label^='Course actions'],
|
||||
html.dark .glass-card-elevated button[aria-label^='Course actions'] {
|
||||
opacity: 0.82;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html:not(.light) *,
|
||||
html.dark * {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
html:not(.light) .glass-card:hover,
|
||||
html:not(.light) .glass-card-elevated:hover,
|
||||
html.dark .glass-card:hover,
|
||||
html.dark .glass-card-elevated:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,15 @@ import Link from 'next/link'
|
||||
import { formatDuration, formatTime, cn } from '@/lib/utils'
|
||||
import { getCourseDisplayName } from '@/lib/course-display'
|
||||
|
||||
interface SubtitleTrackType {
|
||||
id?: string
|
||||
src: string
|
||||
lang: string
|
||||
label: string
|
||||
format?: string
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
interface LessonType {
|
||||
id: string
|
||||
title: string
|
||||
@@ -33,6 +42,7 @@ interface LessonType {
|
||||
filePath: string
|
||||
thumbnail: string | null
|
||||
subtitlePath: string | null
|
||||
subtitles?: SubtitleTrackType[]
|
||||
progress?: { completed: boolean; position: number; lastWatched: string } | null
|
||||
quiz?: {
|
||||
version: 1
|
||||
@@ -118,13 +128,28 @@ function markCompleteFn(lessonId: string, courseId: string, moduleId: string, po
|
||||
}
|
||||
|
||||
function VideoContent({ lesson, lessonProgress, onSave, onEnd, onTimeUpdate, seekRequest }: {
|
||||
lesson: { id: string; filePath: string; thumbnail: string | null; duration: number | null; subtitlePath: string | null }
|
||||
lesson: { id: string; filePath: string; thumbnail: string | null; duration: number | null; subtitlePath: string | null; subtitles?: SubtitleTrackType[] }
|
||||
lessonProgress: { completed: boolean; position: number; lastWatched: string }
|
||||
onSave: (position: number) => void
|
||||
onEnd: () => void
|
||||
onTimeUpdate: (time: number) => void
|
||||
seekRequest: { time: number; nonce: number } | null
|
||||
}) {
|
||||
const subtitleTracks = React.useMemo(() => {
|
||||
if (lesson.subtitles && lesson.subtitles.length > 0) {
|
||||
return lesson.subtitles.map(track => ({
|
||||
src: '/api/files/' + track.src,
|
||||
srcLang: track.lang,
|
||||
label: track.label,
|
||||
default: track.isDefault,
|
||||
}))
|
||||
}
|
||||
|
||||
return lesson.subtitlePath
|
||||
? [{ src: '/api/files/' + lesson.subtitlePath, srcLang: 'en', label: 'English', default: true }]
|
||||
: []
|
||||
}, [lesson.subtitlePath, lesson.subtitles])
|
||||
|
||||
return (
|
||||
<VideoPlayer
|
||||
src={'/api/files/' + lesson.filePath}
|
||||
@@ -135,7 +160,7 @@ function VideoContent({ lesson, lessonProgress, onSave, onEnd, onTimeUpdate, see
|
||||
onProgressSave={onSave}
|
||||
onEnded={onEnd}
|
||||
seekRequest={seekRequest}
|
||||
subtitles={lesson.subtitlePath ? '/api/files/' + lesson.subtitlePath : undefined}
|
||||
subtitles={subtitleTracks}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ export default async function WatchPage({ params, searchParams }: WatchPageProps
|
||||
progress: {
|
||||
where: { userId: 'local-user' },
|
||||
},
|
||||
subtitles: {
|
||||
orderBy: [
|
||||
{ isDefault: 'desc' },
|
||||
{ label: 'asc' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
+31
-62
@@ -7,88 +7,57 @@ interface SplashScreenProps {
|
||||
minDuration?: number
|
||||
}
|
||||
|
||||
export function SplashScreen({ onComplete, minDuration = 1500 }: SplashScreenProps) {
|
||||
const [visible, setVisible] = React.useState(true)
|
||||
const [logoScale, setLogoScale] = React.useState(0.8)
|
||||
const [textOpacity, setTextOpacity] = React.useState(0)
|
||||
export function SplashScreen({ onComplete, minDuration = 550 }: SplashScreenProps) {
|
||||
const [exiting, setExiting] = React.useState(false)
|
||||
const onCompleteRef = React.useRef(onComplete)
|
||||
|
||||
onCompleteRef.current = onComplete
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
const holdDuration = reduceMotion ? 100 : minDuration
|
||||
const exitDuration = reduceMotion ? 0 : 280
|
||||
|
||||
// Animation sequence
|
||||
const timer = setTimeout(() => {
|
||||
if (!mounted) return
|
||||
setLogoScale(1)
|
||||
setTextOpacity(1)
|
||||
|
||||
setTimeout(() => {
|
||||
if (!mounted) return
|
||||
setVisible(false)
|
||||
onCompleteRef.current()
|
||||
}, 800)
|
||||
}, minDuration)
|
||||
const exitTimer = window.setTimeout(() => setExiting(true), holdDuration)
|
||||
const completeTimer = window.setTimeout(
|
||||
() => onCompleteRef.current(),
|
||||
holdDuration + exitDuration
|
||||
)
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
clearTimeout(timer)
|
||||
window.clearTimeout(exitTimer)
|
||||
window.clearTimeout(completeTimer)
|
||||
}
|
||||
}, [minDuration])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background"
|
||||
style={{ backgroundColor: 'var(--background)' }}
|
||||
role="img"
|
||||
aria-label="OfflineAcademy loading"
|
||||
className={`fixed inset-0 z-[100] flex items-center justify-center bg-background transition-opacity duration-300 ${exiting ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="OfflineAcademy is opening"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
{/* Animated logo - using favicon.ico */}
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
transform: `scale(${logoScale})`,
|
||||
transition: 'transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
>
|
||||
<div className="relative flex flex-col items-center gap-5 px-6 text-center">
|
||||
<div className="absolute inset-0 -z-10 scale-150 rounded-full bg-primary/10 blur-3xl" />
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-[1.75rem] border border-primary/20 bg-card shadow-2xl shadow-black/30">
|
||||
<img
|
||||
src="/favicon16x16.ico"
|
||||
alt="OfflineAcademy"
|
||||
className="w-28 h-28 object-contain"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
src="/logo.png"
|
||||
alt=""
|
||||
className="h-16 w-16 object-contain"
|
||||
width="64"
|
||||
height="64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* App name */}
|
||||
<div
|
||||
className="text-center"
|
||||
style={{
|
||||
opacity: textOpacity,
|
||||
transition: 'opacity 0.4s ease 0.2s',
|
||||
}}
|
||||
>
|
||||
<h1 className="text-2xl font-bold tracking-tight bg-gradient-to-r from-primary to-emerald-400 bg-clip-text text-transparent">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-[-0.03em] text-foreground">
|
||||
OfflineAcademy
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Your Personal Offline Learning Center</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Your private learning library
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Loading indicator */}
|
||||
<div
|
||||
className="w-48 h-1.5 bg-muted rounded-full overflow-hidden"
|
||||
style={{
|
||||
opacity: textOpacity,
|
||||
transition: 'opacity 0.4s ease 0.4s',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-primary via-emerald-400 to-primary rounded-full animate-pulse"
|
||||
style={{ width: '60%' }}
|
||||
/>
|
||||
<div className="h-1 w-44 overflow-hidden rounded-full bg-secondary" aria-hidden="true">
|
||||
<div className="h-full w-2/3 animate-pulse rounded-full bg-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,10 +7,17 @@ import { cn, formatTime } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { KeyboardShortcutsOverlay } from '@/components/KeyboardShortcutsOverlay'
|
||||
|
||||
interface VideoSubtitleTrack {
|
||||
src: string
|
||||
srcLang: string
|
||||
label: string
|
||||
default?: boolean
|
||||
}
|
||||
|
||||
interface VideoPlayerProps {
|
||||
src: string
|
||||
poster?: string
|
||||
subtitles?: string
|
||||
subtitles?: VideoSubtitleTrack[]
|
||||
onTimeUpdate?: (time: number) => void
|
||||
onEnded?: () => void
|
||||
onProgressSave?: (time: number) => void
|
||||
@@ -46,6 +53,10 @@ export function VideoPlayer({
|
||||
const [isPiPActive, setIsPiPActive] = useState(false)
|
||||
const [hoverProgress, setHoverProgress] = useState<{ time: number; x: number } | null>(null)
|
||||
const [showSettingsMenu, setShowSettingsMenu] = useState(false)
|
||||
const [selectedSubtitle, setSelectedSubtitle] = useState<number | 'off'>(() => {
|
||||
const defaultIndex = subtitles?.findIndex(track => track.default) ?? -1
|
||||
return defaultIndex >= 0 ? defaultIndex : 'off'
|
||||
})
|
||||
const settingsMenuRef = useRef<HTMLDivElement>(null)
|
||||
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const progressSaveTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
@@ -385,6 +396,32 @@ export function VideoPlayer({
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showSettingsMenu])
|
||||
|
||||
useEffect(() => {
|
||||
const defaultIndex = subtitles?.findIndex(track => track.default) ?? -1
|
||||
setSelectedSubtitle(defaultIndex >= 0 ? defaultIndex : 'off')
|
||||
}, [subtitles])
|
||||
|
||||
useEffect(() => {
|
||||
const textTracks = videoRef.current?.textTracks
|
||||
if (!textTracks) return
|
||||
|
||||
for (let index = 0; index < textTracks.length; index += 1) {
|
||||
textTracks[index].mode = selectedSubtitle === index ? 'showing' : 'disabled'
|
||||
}
|
||||
}, [selectedSubtitle, subtitles])
|
||||
|
||||
const hasSubtitles = Boolean(subtitles && subtitles.length > 0)
|
||||
const captionsEnabled = selectedSubtitle !== 'off'
|
||||
const toggleCaptions = () => {
|
||||
if (!hasSubtitles) return
|
||||
if (captionsEnabled) {
|
||||
setSelectedSubtitle('off')
|
||||
return
|
||||
}
|
||||
const defaultIndex = subtitles?.findIndex(track => track.default) ?? -1
|
||||
setSelectedSubtitle(defaultIndex >= 0 ? defaultIndex : 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="video-player-container"
|
||||
@@ -410,9 +447,16 @@ export function VideoPlayer({
|
||||
onClick={togglePlay}
|
||||
className="w-full h-full cursor-pointer"
|
||||
>
|
||||
{subtitles && (
|
||||
<track kind="subtitles" src={subtitles} srcLang="en" label="English" default />
|
||||
)}
|
||||
{subtitles?.map((track, index) => (
|
||||
<track
|
||||
key={`${track.srcLang}-${track.src}`}
|
||||
kind="subtitles"
|
||||
src={track.src}
|
||||
srcLang={track.srcLang}
|
||||
label={track.label}
|
||||
default={selectedSubtitle === index}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
</div>
|
||||
|
||||
@@ -531,6 +575,22 @@ export function VideoPlayer({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasSubtitles && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'text-white hover:bg-white/20 font-bold text-xs',
|
||||
captionsEnabled && 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
)}
|
||||
onClick={toggleCaptions}
|
||||
aria-label={captionsEnabled ? 'Turn captions off' : 'Turn captions on'}
|
||||
title={captionsEnabled ? 'Captions on' : 'Captions off'}
|
||||
>
|
||||
CC
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Fullscreen - FIRST */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -589,7 +649,7 @@ export function VideoPlayer({
|
||||
|
||||
{showSettingsMenu && (
|
||||
<div
|
||||
className="absolute bottom-full right-0 mb-2 w-40 glass-card-elevated rounded-lg p-2 shadow-xl border border-white/10 animate-in zoom-in-95 z-20"
|
||||
className="absolute bottom-full right-0 mb-2 w-52 glass-card-elevated rounded-lg p-2 shadow-xl border border-white/10 animate-in zoom-in-95 z-20"
|
||||
role="menu"
|
||||
>
|
||||
<div className="px-3 py-2 text-xs font-semibold text-white/60 uppercase tracking-wider">Speed</div>
|
||||
@@ -611,6 +671,39 @@ export function VideoPlayer({
|
||||
{playbackRate === rate && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
))}
|
||||
{subtitles && subtitles.length > 0 && (
|
||||
<>
|
||||
<hr className="border-white/10 my-1" />
|
||||
<div className="px-3 py-2 text-xs font-semibold text-white/60 uppercase tracking-wider">Subtitles</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedSubtitle('off')
|
||||
setShowSettingsMenu(false)
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-white hover:bg-white/10 rounded ${selectedSubtitle === 'off' ? 'bg-primary/20 text-primary' : ''}`}
|
||||
role="menuitemradio"
|
||||
aria-checked={selectedSubtitle === 'off'}
|
||||
>
|
||||
<span>Off</span>
|
||||
{selectedSubtitle === 'off' && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
{subtitles.map((track, index) => (
|
||||
<button
|
||||
key={`${track.srcLang}-${track.src}`}
|
||||
onClick={() => {
|
||||
setSelectedSubtitle(index)
|
||||
setShowSettingsMenu(false)
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-white hover:bg-white/10 rounded ${selectedSubtitle === index ? 'bg-primary/20 text-primary' : ''}`}
|
||||
role="menuitemradio"
|
||||
aria-checked={selectedSubtitle === index}
|
||||
>
|
||||
<span className="truncate">{track.label}</span>
|
||||
{selectedSubtitle === index && <Check className="h-4 w-4 shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<hr className="border-white/10 my-1" />
|
||||
<button
|
||||
onClick={() => setShowShortcuts(true)}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# OfflineAcademy v2
|
||||
|
||||
The `dev/offlineacademy-v2` branch focuses on making the existing product feel faster and more deliberate without changing its local-first mission.
|
||||
|
||||
## Performance work
|
||||
|
||||
- The course API now runs independent database reads in parallel.
|
||||
- Lesson totals are collected through module relation counts instead of a separate lesson grouping plus module lookup.
|
||||
- Partial course progress is calculated from completed lesson records, fixing the previous 0-or-100 dashboard behavior.
|
||||
- Course thumbnails prefer the database value and only fall back to filesystem discovery when needed.
|
||||
- API responses expose `Server-Timing` metrics for database, enrichment, and total request time.
|
||||
- The course hook cancels obsolete requests, rejects stale responses, caches recent pages briefly, and prefetches the next page.
|
||||
- The splash screen renders over already-loaded content, appears once per browser session, and has a much shorter minimum duration.
|
||||
- Course cards use CSS content visibility to reduce rendering work below the viewport.
|
||||
|
||||
## Premium dark interface
|
||||
|
||||
The v2 visual layer is intentionally scoped to dark mode. Light mode keeps the established appearance.
|
||||
|
||||
- Deep ink background with restrained emerald and warm amber accents.
|
||||
- Higher-contrast text and quieter secondary labels.
|
||||
- More substantial cards with subtle elevation instead of heavy neon glow.
|
||||
- Cleaner controls, inputs, popovers, progress bars, and navigation surfaces.
|
||||
- Touch-friendly course actions and reduced-motion support.
|
||||
- Theme bootstrapping before hydration to prevent the light-to-dark flash.
|
||||
|
||||
## Product boundary
|
||||
|
||||
OfflineAcademy remains a single-user, self-hosted, LAN-first course library. This branch does not add accounts, cloud dependencies, subscriptions, or unrelated workflows.
|
||||
@@ -0,0 +1,101 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export async function getAnalyticsData(): Promise<AnalyticsData> {
|
||||
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 },
|
||||
},
|
||||
})
|
||||
|
||||
const totalWatchedSeconds = allProgress.reduce((sum, p) => sum + (p.position || 0), 0)
|
||||
|
||||
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
|
||||
|
||||
const lessonsByType = await prisma.lesson.groupBy({
|
||||
by: ['type'],
|
||||
_count: true,
|
||||
where: { module: { course: { hidden: false } } },
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const totalLessons = completedLessons.length + inProgressLessons.length + notStartedCount
|
||||
|
||||
return {
|
||||
totalCourses,
|
||||
completedCourses,
|
||||
inProgressCourses,
|
||||
totalLessons,
|
||||
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: totalLessons > 0 ? Math.round((completedLessons.length / totalLessons) * 100) : 0,
|
||||
}
|
||||
}
|
||||
+145
-42
@@ -1,14 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
export interface CourseTag {
|
||||
id: string
|
||||
name: string
|
||||
color?: string | null
|
||||
_count?: { courseTags: number }
|
||||
}
|
||||
|
||||
export interface Course {
|
||||
id: string
|
||||
name: string
|
||||
displayName?: string | null
|
||||
slug: string
|
||||
thumbnail: string | null
|
||||
description: string | null
|
||||
path: string
|
||||
favorited?: boolean
|
||||
tags?: CourseTag[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
_count: { modules: number; lessons: number }
|
||||
@@ -22,7 +32,8 @@ export interface Course {
|
||||
}
|
||||
|
||||
interface CoursesResponse {
|
||||
courses: any[]
|
||||
courses: Course[]
|
||||
tags: CourseTag[]
|
||||
pagination: {
|
||||
page: number
|
||||
limit: number
|
||||
@@ -42,9 +53,36 @@ interface UseCoursesOptions {
|
||||
initialSortOrder?: string
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
data: CoursesResponse
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 20_000
|
||||
const MAX_CACHE_ENTRIES = 24
|
||||
const responseCache = new Map<string, CacheEntry>()
|
||||
|
||||
function readCache(key: string): CoursesResponse | null {
|
||||
const cached = responseCache.get(key)
|
||||
if (!cached) return null
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
responseCache.delete(key)
|
||||
return null
|
||||
}
|
||||
return cached.data
|
||||
}
|
||||
|
||||
function writeCache(key: string, data: CoursesResponse) {
|
||||
if (responseCache.size >= MAX_CACHE_ENTRIES) {
|
||||
const oldestKey = responseCache.keys().next().value
|
||||
if (oldestKey) responseCache.delete(oldestKey)
|
||||
}
|
||||
responseCache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS })
|
||||
}
|
||||
|
||||
export function useCourses(options: UseCoursesOptions = {}) {
|
||||
const [courses, setCourses] = useState<any[]>([])
|
||||
const [tags, setTags] = useState<any[]>([])
|
||||
const [courses, setCourses] = useState<Course[]>([])
|
||||
const [tags, setTags] = useState<CourseTag[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pagination, setPagination] = useState({
|
||||
@@ -60,19 +98,38 @@ export function useCourses(options: UseCoursesOptions = {}) {
|
||||
const [sortBy, setSortBy] = useState(options.initialSortBy || 'updatedAt')
|
||||
const [sortOrder, setSortOrder] = useState(options.initialSortOrder || 'desc')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(options.initialSearch || '')
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
const hasDataRef = useRef(false)
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setDebouncedSearch(search)
|
||||
setPagination(prev => ({ ...prev, page: 1 }))
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
setPagination((previous) => ({ ...previous, page: 1 }))
|
||||
}, 250)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [search])
|
||||
|
||||
const fetchCourses = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const applyResponse = useCallback((data: CoursesResponse) => {
|
||||
hasDataRef.current = true
|
||||
setCourses(data.courses)
|
||||
setTags(data.tags || [])
|
||||
setPagination((previous) => ({
|
||||
...previous,
|
||||
page: data.pagination.page,
|
||||
limit: data.pagination.limit,
|
||||
total: data.pagination.total,
|
||||
totalPages: data.pagination.totalPages,
|
||||
hasNext: data.pagination.hasNext,
|
||||
hasPrev: data.pagination.hasPrev,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const fetchCourses = useCallback(async (force = false) => {
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
const requestId = ++requestIdRef.current
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: pagination.page.toString(),
|
||||
@@ -82,72 +139,118 @@ export function useCourses(options: UseCoursesOptions = {}) {
|
||||
sortBy,
|
||||
sortOrder,
|
||||
})
|
||||
const requestKey = params.toString()
|
||||
const cached = force ? null : readCache(requestKey)
|
||||
|
||||
if (cached) {
|
||||
applyResponse(cached)
|
||||
setLoading(false)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasDataRef.current) setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/courses?${params.toString()}`)
|
||||
const response = await fetch(`/api/courses?${requestKey}`, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch courses')
|
||||
}
|
||||
const data = await response.json()
|
||||
setCourses(data.courses)
|
||||
setTags(data.tags || [])
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: data.pagination.total,
|
||||
totalPages: data.pagination.totalPages,
|
||||
hasNext: data.pagination.hasNext,
|
||||
hasPrev: data.pagination.hasPrev,
|
||||
}))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch courses')
|
||||
|
||||
const data = await response.json() as CoursesResponse
|
||||
if (requestId !== requestIdRef.current) return
|
||||
|
||||
writeCache(requestKey, data)
|
||||
applyResponse(data)
|
||||
|
||||
if (data.pagination.hasNext) {
|
||||
const nextParams = new URLSearchParams(params)
|
||||
nextParams.set('page', String(data.pagination.page + 1))
|
||||
const nextKey = nextParams.toString()
|
||||
|
||||
if (!readCache(nextKey)) {
|
||||
window.setTimeout(() => {
|
||||
void fetch(`/api/courses?${nextKey}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
.then((nextResponse) => nextResponse.ok ? nextResponse.json() : null)
|
||||
.then((nextData: CoursesResponse | null) => {
|
||||
if (nextData) writeCache(nextKey, nextData)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
} catch (caughtError) {
|
||||
if (caughtError instanceof DOMException && caughtError.name === 'AbortError') return
|
||||
if (requestId !== requestIdRef.current) return
|
||||
setError(caughtError instanceof Error ? caughtError.message : 'Failed to fetch courses')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (requestId === requestIdRef.current) setLoading(false)
|
||||
}
|
||||
}, [pagination.page, pagination.limit, debouncedSearch, filter, sortBy, sortOrder])
|
||||
}, [
|
||||
applyResponse,
|
||||
pagination.page,
|
||||
pagination.limit,
|
||||
debouncedSearch,
|
||||
filter,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourses()
|
||||
void fetchCourses()
|
||||
return () => abortRef.current?.abort()
|
||||
}, [fetchCourses])
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
setPagination(prev => ({ ...prev, page }))
|
||||
setPagination((previous) => ({ ...previous, page: Math.max(1, page) }))
|
||||
}
|
||||
|
||||
const nextPage = () => {
|
||||
if (pagination.hasNext) {
|
||||
setPagination(prev => ({ ...prev, page: prev.page + 1 }))
|
||||
setPagination((previous) => ({ ...previous, page: previous.page + 1 }))
|
||||
}
|
||||
}
|
||||
|
||||
const prevPage = () => {
|
||||
if (pagination.hasPrev) {
|
||||
setPagination(prev => ({ ...prev, page: prev.page - 1 }))
|
||||
setPagination((previous) => ({ ...previous, page: Math.max(1, previous.page - 1) }))
|
||||
}
|
||||
}
|
||||
|
||||
const changeLimit = (limit: number) => {
|
||||
setPagination(prev => ({ ...prev, limit, page: 1 }))
|
||||
setPagination((previous) => ({ ...previous, limit, page: 1 }))
|
||||
}
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearch(value)
|
||||
}
|
||||
const handleSearch = (value: string) => setSearch(value)
|
||||
|
||||
const handleFilter = (value: string) => {
|
||||
setFilter(value)
|
||||
setPagination(prev => ({ ...prev, page: 1 }))
|
||||
setPagination((previous) => ({ ...previous, page: 1 }))
|
||||
}
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
setSortBy(field)
|
||||
setPagination(prev => ({ ...prev, page: 1 }))
|
||||
setPagination((previous) => ({ ...previous, page: 1 }))
|
||||
}
|
||||
|
||||
const handleSortOrder = (value: string) => {
|
||||
setSortOrder(value === 'asc' ? 'asc' : 'desc')
|
||||
setPagination((previous) => ({ ...previous, page: 1 }))
|
||||
}
|
||||
|
||||
const toggleSortOrder = () => {
|
||||
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')
|
||||
setPagination(prev => ({ ...prev, page: 1 }))
|
||||
setSortOrder((previous) => previous === 'asc' ? 'desc' : 'asc')
|
||||
setPagination((previous) => ({ ...previous, page: 1 }))
|
||||
}
|
||||
|
||||
const refetch = useCallback(() => fetchCourses(true), [fetchCourses])
|
||||
|
||||
return {
|
||||
courses,
|
||||
tags,
|
||||
@@ -161,12 +264,12 @@ export function useCourses(options: UseCoursesOptions = {}) {
|
||||
sortBy,
|
||||
sortOrder,
|
||||
setSortBy: handleSort,
|
||||
setSortOrder: toggleSortOrder,
|
||||
setSortOrder: handleSortOrder,
|
||||
toggleSortOrder,
|
||||
goToPage,
|
||||
nextPage,
|
||||
prevPage,
|
||||
changeLimit,
|
||||
refetch: fetchCourses,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+100
-79
@@ -8,6 +8,7 @@ import { getLocalThumbnailUrl } from '@/lib/thumbnail-index-server'
|
||||
import { ensureModuleQuizCache, syncQuizLessonFromCache, shouldSkipQuizGeneration } from '@/lib/quiz'
|
||||
import { applyCourseMetadata, isMetadataFileName, readCourseMetadataFile } from '@/lib/course-metadata'
|
||||
import { getLocalCourseThumbnailPath, ensurePublicThumbnail } from '@/lib/course-thumbnail-utils'
|
||||
import { buildSubtitleTrackMap } from '@/lib/subtitles'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
@@ -134,6 +135,7 @@ async function downloadCourseThumbnail(courseName: string, courseSlug: string):
|
||||
interface ScanResult {
|
||||
coursesCreated: number
|
||||
coursesUpdated: number
|
||||
coursesDeleted: number
|
||||
modulesCreated: number
|
||||
modulesUpdated: number
|
||||
lessonsCreated: number
|
||||
@@ -248,6 +250,7 @@ interface ScanOptions {
|
||||
interface ScanResult {
|
||||
coursesCreated: number
|
||||
coursesUpdated: number
|
||||
coursesDeleted: number
|
||||
modulesCreated: number
|
||||
modulesUpdated: number
|
||||
lessonsCreated: number
|
||||
@@ -313,6 +316,7 @@ async function scanCourses(options: ScanOptions = {}): Promise<ScanResult> {
|
||||
const result: ScanResult = {
|
||||
coursesCreated: 0,
|
||||
coursesUpdated: 0,
|
||||
coursesDeleted: 0,
|
||||
modulesCreated: 0,
|
||||
modulesUpdated: 0,
|
||||
lessonsCreated: 0,
|
||||
@@ -445,6 +449,16 @@ async function scanCourses(options: ScanOptions = {}): Promise<ScanResult> {
|
||||
}
|
||||
}
|
||||
|
||||
const foundSlugs = new Set(courses.map((c) => slugify(c.name)))
|
||||
const staleCourses = existingCourses.filter((c) => !c.hidden && !foundSlugs.has(c.slug))
|
||||
if (staleCourses.length > 0) {
|
||||
const staleIds = staleCourses.map((c) => c.id)
|
||||
await prisma.course.deleteMany({
|
||||
where: { id: { in: staleIds } },
|
||||
})
|
||||
result.coursesDeleted = staleCourses.length
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.errors.push('Failed to scan courses root: ' + error)
|
||||
}
|
||||
@@ -557,16 +571,31 @@ async function scanLessons(
|
||||
.filter(e => e.isFile() && !e.name.startsWith('.'))
|
||||
.sort((a, b) => naturalSort(a.name, b.name))
|
||||
|
||||
// Build a map of subtitle files by their base name (for matching with videos)
|
||||
const subtitleMap = new Map<string, string>()
|
||||
for (const file of files) {
|
||||
const ext = extname(file.name).slice(1).toLowerCase()
|
||||
if (['srt', 'vtt'].includes(ext)) {
|
||||
const baseName = basename(file.name, extname(file.name))
|
||||
const relativePath = relative(coursesRoot, join(modulePath, file.name)).replace(/\\/g, '/')
|
||||
subtitleMap.set(baseName, relativePath)
|
||||
const subtitleFiles = files.filter(file => ['srt', 'vtt'].includes(extname(file.name).slice(1).toLowerCase()))
|
||||
|
||||
// Filter files to only include supported lesson types. Subtitle files are attached to videos, not created as lessons.
|
||||
const supportedFiles = files.filter(file => {
|
||||
if (file.name === 'quiz_cache.json' || isMetadataFileName(file.name)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const mimeType = getMimeType(file.name)
|
||||
const lessonType = getLessonType(mimeType)
|
||||
if (lessonType === 'OTHER' && !['json', 'txt'].includes(extname(file.name).slice(1).toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
if (['srt', 'vtt'].includes(extname(file.name).slice(1).toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const selectedFiles = supportedFiles.slice(0, maxLessonsPerCourse)
|
||||
const subtitleMap = buildSubtitleTrackMap(
|
||||
selectedFiles.map(file => file.name),
|
||||
subtitleFiles.map(file => file.name),
|
||||
(fileName) => relative(coursesRoot, join(modulePath, fileName)).replace(/\\/g, '/')
|
||||
)
|
||||
|
||||
// Get existing lessons in one query
|
||||
const existingLessons = await prisma.lesson.findMany({
|
||||
@@ -575,90 +604,82 @@ async function scanLessons(
|
||||
})
|
||||
const existingLessonMap = new Map(existingLessons.map(l => [l.slug, l]))
|
||||
|
||||
// Filter files to only include supported types
|
||||
const supportedFiles = files.filter(file => {
|
||||
if (file.name === 'quiz_cache.json' || isMetadataFileName(file.name)) {
|
||||
return false
|
||||
}
|
||||
const lessonRecords: Array<{ slug: string; baseName: string }> = []
|
||||
|
||||
for (let index = 0; index < selectedFiles.length; index += 1) {
|
||||
const file = selectedFiles[index]
|
||||
const relativePath = relative(coursesRoot, join(modulePath, file.name)).replace(/\\/g, '/')
|
||||
const mimeType = getMimeType(file.name)
|
||||
const lessonType = getLessonType(mimeType)
|
||||
if (lessonType === 'OTHER' && !['json', 'txt', 'srt', 'vtt'].includes(extname(file.name).slice(1))) {
|
||||
return false
|
||||
}
|
||||
// Skip subtitle files for lesson creation - they'll be attached to videos
|
||||
if (['srt', 'vtt'].includes(extname(file.name).slice(1).toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
const baseName = basename(file.name, extname(file.name))
|
||||
const baseSlug = slugify(baseName)
|
||||
|
||||
// Prepare lesson upserts
|
||||
const lessonUpserts = supportedFiles
|
||||
.slice(0, maxLessonsPerCourse)
|
||||
.map((file, index) => {
|
||||
const relativePath = relative(coursesRoot, join(modulePath, file.name)).replace(/\\/g, '/')
|
||||
const mimeType = getMimeType(file.name)
|
||||
const lessonType = getLessonType(mimeType)
|
||||
const baseName = basename(file.name, extname(file.name))
|
||||
const baseSlug = slugify(baseName)
|
||||
|
||||
let lessonSlug = baseSlug
|
||||
if (moduleSeenSlugs.has(lessonSlug)) {
|
||||
const typedSlug = `${baseSlug}-${lessonType.toLowerCase()}`
|
||||
lessonSlug = typedSlug
|
||||
let suffix = 2
|
||||
while (moduleSeenSlugs.has(lessonSlug)) {
|
||||
lessonSlug = `${typedSlug}-${suffix}`
|
||||
suffix += 1
|
||||
}
|
||||
let lessonSlug = baseSlug
|
||||
if (moduleSeenSlugs.has(lessonSlug)) {
|
||||
const typedSlug = `${baseSlug}-${lessonType.toLowerCase()}`
|
||||
lessonSlug = typedSlug
|
||||
let suffix = 2
|
||||
while (moduleSeenSlugs.has(lessonSlug)) {
|
||||
lessonSlug = `${typedSlug}-${suffix}`
|
||||
suffix += 1
|
||||
}
|
||||
moduleSeenSlugs.add(lessonSlug)
|
||||
}
|
||||
moduleSeenSlugs.add(lessonSlug)
|
||||
|
||||
// Check if there's a matching subtitle file
|
||||
const subtitlePath = subtitleMap.get(baseName) || null
|
||||
const subtitleTracks = subtitleMap.get(baseName) || []
|
||||
const defaultSubtitle = subtitleTracks.find(track => track.isDefault) || subtitleTracks[0] || null
|
||||
|
||||
return {
|
||||
where: { moduleId_slug: { moduleId, slug: lessonSlug } },
|
||||
update: {
|
||||
title: baseName,
|
||||
order: index,
|
||||
filePath: relativePath,
|
||||
fileName: file.name,
|
||||
mimeType,
|
||||
type: lessonType,
|
||||
duration: null,
|
||||
thumbnail: null,
|
||||
subtitlePath,
|
||||
},
|
||||
create: {
|
||||
title: baseName,
|
||||
slug: lessonSlug,
|
||||
order: index,
|
||||
filePath: relativePath,
|
||||
fileName: file.name,
|
||||
mimeType,
|
||||
type: lessonType,
|
||||
duration: null,
|
||||
thumbnail: null,
|
||||
subtitlePath,
|
||||
moduleId,
|
||||
},
|
||||
}
|
||||
const lesson = await prisma.lesson.upsert({
|
||||
where: { moduleId_slug: { moduleId, slug: lessonSlug } },
|
||||
update: {
|
||||
title: baseName,
|
||||
order: index,
|
||||
filePath: relativePath,
|
||||
fileName: file.name,
|
||||
mimeType,
|
||||
type: lessonType,
|
||||
duration: null,
|
||||
thumbnail: null,
|
||||
subtitlePath: defaultSubtitle?.src || null,
|
||||
},
|
||||
create: {
|
||||
title: baseName,
|
||||
slug: lessonSlug,
|
||||
order: index,
|
||||
filePath: relativePath,
|
||||
fileName: file.name,
|
||||
mimeType,
|
||||
type: lessonType,
|
||||
duration: null,
|
||||
thumbnail: null,
|
||||
subtitlePath: defaultSubtitle?.src || null,
|
||||
moduleId,
|
||||
},
|
||||
})
|
||||
|
||||
if (lessonUpserts.length > 0) {
|
||||
await prisma.$transaction(
|
||||
lessonUpserts.map(upsert => prisma.lesson.upsert(upsert))
|
||||
)
|
||||
await prisma.$transaction([
|
||||
prisma.subtitleTrack.deleteMany({ where: { lessonId: lesson.id } }),
|
||||
...subtitleTracks.map(track => prisma.subtitleTrack.create({
|
||||
data: {
|
||||
lessonId: lesson.id,
|
||||
src: track.src,
|
||||
lang: track.lang,
|
||||
label: track.label,
|
||||
format: track.format,
|
||||
isDefault: track.src === defaultSubtitle?.src,
|
||||
},
|
||||
})),
|
||||
])
|
||||
|
||||
lessonRecords.push({ slug: lessonSlug, baseName })
|
||||
}
|
||||
|
||||
const moduleShouldSkipQuiz = await shouldSkipQuizGeneration(modulePath, basename(modulePath))
|
||||
|
||||
// Update counts for non-quiz lessons first
|
||||
const newLessons = lessonUpserts.filter(u => !existingLessonMap.has(u.where.moduleId_slug.slug))
|
||||
const newLessons = lessonRecords.filter(record => !existingLessonMap.has(record.slug))
|
||||
result.lessonsCreated += newLessons.length
|
||||
result.lessonsUpdated += lessonUpserts.length - newLessons.length
|
||||
result.lessonsUpdated += lessonRecords.length - newLessons.length
|
||||
|
||||
if (!moduleShouldSkipQuiz) {
|
||||
if (autoFetchQuizzes) {
|
||||
@@ -677,7 +698,7 @@ async function scanLessons(
|
||||
courseRoot: coursesRoot,
|
||||
topic: basename(modulePath),
|
||||
source: quizApiSource,
|
||||
lessonOrder: lessonUpserts.length,
|
||||
lessonOrder: lessonRecords.length,
|
||||
autoFetch: autoFetchQuizzes,
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { basename, extname } from 'path'
|
||||
|
||||
export type SubtitleFormat = 'vtt' | 'srt'
|
||||
|
||||
export type SubtitleTrackInput = {
|
||||
src: string
|
||||
lang: string
|
||||
label: string
|
||||
format: SubtitleFormat
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
const LANGUAGE_LABELS: Record<string, string> = {
|
||||
en: 'English',
|
||||
es: 'Spanish',
|
||||
fr: 'French',
|
||||
de: 'German',
|
||||
it: 'Italian',
|
||||
pt: 'Portuguese',
|
||||
'pt-BR': 'Portuguese (Brazil)',
|
||||
zh: 'Chinese',
|
||||
'zh-CN': 'Chinese (Simplified)',
|
||||
'zh-TW': 'Chinese (Traditional)',
|
||||
ja: 'Japanese',
|
||||
ko: 'Korean',
|
||||
fil: 'Filipino',
|
||||
tl: 'Tagalog',
|
||||
id: 'Indonesian',
|
||||
ms: 'Malay',
|
||||
th: 'Thai',
|
||||
vi: 'Vietnamese',
|
||||
ar: 'Arabic',
|
||||
hi: 'Hindi',
|
||||
ru: 'Russian',
|
||||
}
|
||||
|
||||
export function normalizeSubtitleLang(code: string): string {
|
||||
const trimmed = code.trim().replace(/_/g, '-')
|
||||
if (!trimmed) return 'en'
|
||||
|
||||
const [language, region, ...rest] = trimmed.split('-')
|
||||
const normalizedLanguage = language.toLowerCase()
|
||||
const normalizedRegion = region ? region.toUpperCase() : undefined
|
||||
const normalizedRest = rest.map(part => part.length === 2 ? part.toUpperCase() : part)
|
||||
|
||||
return [normalizedLanguage, normalizedRegion, ...normalizedRest]
|
||||
.filter(Boolean)
|
||||
.join('-')
|
||||
}
|
||||
|
||||
export function languageCodeToLabel(code: string): string {
|
||||
const normalized = normalizeSubtitleLang(code)
|
||||
if (LANGUAGE_LABELS[normalized]) return LANGUAGE_LABELS[normalized]
|
||||
|
||||
const baseLanguage = normalized.split('-')[0]
|
||||
if (LANGUAGE_LABELS[baseLanguage]) {
|
||||
const region = normalized.split('-')[1]
|
||||
return region ? `${LANGUAGE_LABELS[baseLanguage]} (${region})` : LANGUAGE_LABELS[baseLanguage]
|
||||
}
|
||||
|
||||
return normalized
|
||||
.split('-')
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function getSubtitleFormat(fileName: string): SubtitleFormat | null {
|
||||
const ext = extname(fileName).slice(1).toLowerCase()
|
||||
return ext === 'vtt' || ext === 'srt' ? ext : null
|
||||
}
|
||||
|
||||
function tryExactBasename(
|
||||
videoBaseName: string,
|
||||
subtitleBaseName: string,
|
||||
src: string,
|
||||
format: SubtitleFormat
|
||||
): SubtitleTrackInput | null {
|
||||
if (subtitleBaseName === videoBaseName) {
|
||||
return {
|
||||
src,
|
||||
lang: 'en',
|
||||
label: 'English',
|
||||
format,
|
||||
isDefault: true,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function tryDottedLangSuffix(
|
||||
videoBaseName: string,
|
||||
subtitleBaseName: string,
|
||||
src: string,
|
||||
format: SubtitleFormat
|
||||
): SubtitleTrackInput | null {
|
||||
const languagePrefix = `${videoBaseName}.`
|
||||
if (!subtitleBaseName.startsWith(languagePrefix)) return null
|
||||
|
||||
const rawLanguage = subtitleBaseName.slice(languagePrefix.length)
|
||||
if (!rawLanguage || rawLanguage.includes('/')) return null
|
||||
|
||||
const lang = normalizeSubtitleLang(rawLanguage)
|
||||
return {
|
||||
src,
|
||||
lang,
|
||||
label: languageCodeToLabel(lang),
|
||||
format,
|
||||
isDefault: lang === 'en',
|
||||
}
|
||||
}
|
||||
|
||||
function tryEmbeddedLangSegment(
|
||||
videoBaseName: string,
|
||||
subtitleBaseName: string,
|
||||
src: string,
|
||||
format: SubtitleFormat
|
||||
): SubtitleTrackInput | null {
|
||||
const subtitleParts = subtitleBaseName.split('-')
|
||||
const videoParts = videoBaseName.split('-')
|
||||
if (subtitleParts.length < videoParts.length + 1) return null
|
||||
|
||||
for (let index = 1; index < subtitleParts.length - 1; index += 1) {
|
||||
const candidateParts = [...subtitleParts]
|
||||
candidateParts.splice(index, 1)
|
||||
if (candidateParts.join('-') !== videoBaseName) continue
|
||||
|
||||
const rawLanguage = subtitleParts[index]
|
||||
if (!rawLanguage || rawLanguage.includes('/')) continue
|
||||
|
||||
const lang = normalizeSubtitleLang(rawLanguage)
|
||||
return {
|
||||
src,
|
||||
lang,
|
||||
label: languageCodeToLabel(lang),
|
||||
format,
|
||||
isDefault: lang === 'en',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function parseSubtitleForVideo(
|
||||
videoBaseName: string,
|
||||
subtitleFileName: string,
|
||||
src: string
|
||||
): SubtitleTrackInput | null {
|
||||
const format = getSubtitleFormat(subtitleFileName)
|
||||
if (!format) return null
|
||||
|
||||
const subtitleBaseName = basename(subtitleFileName, extname(subtitleFileName))
|
||||
|
||||
const direct = tryExactBasename(videoBaseName, subtitleBaseName, src, format)
|
||||
if (direct) return direct
|
||||
|
||||
const dotted = tryDottedLangSuffix(videoBaseName, subtitleBaseName, src, format)
|
||||
if (dotted) return dotted
|
||||
|
||||
const embedded = tryEmbeddedLangSegment(videoBaseName, subtitleBaseName, src, format)
|
||||
if (embedded) return embedded
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function buildSubtitleTrackMap(
|
||||
videoFileNames: string[],
|
||||
subtitleFileNames: string[],
|
||||
toRelativePath: (fileName: string) => string
|
||||
): Map<string, SubtitleTrackInput[]> {
|
||||
const map = new Map<string, SubtitleTrackInput[]>()
|
||||
const videoBaseNames = videoFileNames.map(fileName => basename(fileName, extname(fileName)))
|
||||
|
||||
for (const videoBaseName of videoBaseNames) {
|
||||
for (const subtitleFileName of subtitleFileNames) {
|
||||
const track = parseSubtitleForVideo(
|
||||
videoBaseName,
|
||||
subtitleFileName,
|
||||
toRelativePath(subtitleFileName)
|
||||
)
|
||||
if (!track) continue
|
||||
|
||||
const existingTracks = map.get(videoBaseName) || []
|
||||
const duplicate = existingTracks.some(existing => existing.lang === track.lang && existing.src === track.src)
|
||||
if (!duplicate) {
|
||||
existingTracks.push(track)
|
||||
}
|
||||
map.set(videoBaseName, sortSubtitleTracks(existingTracks))
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
export function sortSubtitleTracks(tracks: SubtitleTrackInput[]): SubtitleTrackInput[] {
|
||||
return [...tracks].sort((a, b) => {
|
||||
if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1
|
||||
if (a.lang === 'en' && b.lang !== 'en') return -1
|
||||
if (b.lang === 'en' && a.lang !== 'en') return 1
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
}
|
||||
|
||||
export function convertSrtToVtt(content: string): string {
|
||||
const normalized = content
|
||||
.replace(/^\uFEFF/, '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.trim()
|
||||
|
||||
if (!normalized) return 'WEBVTT\n\n'
|
||||
if (normalized.startsWith('WEBVTT')) return normalized.endsWith('\n') ? normalized : `${normalized}\n`
|
||||
|
||||
const vttBody = normalized
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
if (/^\d+$/.test(line.trim())) return ''
|
||||
return line.replace(
|
||||
/(\d{2}:\d{2}:\d{2}),(\d{3})\s+-->\s+(\d{2}:\d{2}:\d{2}),(\d{3})/g,
|
||||
'$1.$2 --> $3.$4'
|
||||
)
|
||||
})
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
|
||||
return `WEBVTT\n\n${vttBody}\n`
|
||||
}
|
||||
Generated
+7
@@ -29,6 +29,7 @@
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.35.1",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
@@ -6317,6 +6318,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/server-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
|
||||
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.35.1",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
@@ -71,6 +71,7 @@ model Lesson {
|
||||
|
||||
progress Progress[]
|
||||
bookmarks Bookmark[]
|
||||
subtitles SubtitleTrack[]
|
||||
quiz Quiz?
|
||||
quizAttempts QuizAttempt[]
|
||||
|
||||
@@ -79,6 +80,22 @@ model Lesson {
|
||||
@@index([filePath])
|
||||
}
|
||||
|
||||
model SubtitleTrack {
|
||||
id String @id @default(cuid())
|
||||
lessonId String
|
||||
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
||||
src String
|
||||
lang String
|
||||
label String
|
||||
format String
|
||||
isDefault Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([lessonId, lang, src])
|
||||
@@index([lessonId])
|
||||
}
|
||||
|
||||
model Progress {
|
||||
id String @id @default(cuid())
|
||||
userId String @default("local-user")
|
||||
|
||||
+19
-3
@@ -1,7 +1,7 @@
|
||||
// OfflineAcademy Service Worker v3 — Minimal, Chrome-install-criteria focused
|
||||
// OfflineAcademy Service Worker v4 — Offline support without stale Next.js dev chunks
|
||||
// Logs to console so you can verify it's controlling the page
|
||||
|
||||
const CACHE = "oa-v5"
|
||||
const CACHE = "oa-v6"
|
||||
const ASSETS = [
|
||||
'/',
|
||||
'/site.webmanifest',
|
||||
@@ -29,6 +29,22 @@ self.addEventListener('fetch', e => {
|
||||
const url = new URL(e.request.url)
|
||||
console.log('[SW] Fetch:', url.pathname)
|
||||
|
||||
// Never cache Next.js runtime/dev chunks or HMR/RSC payloads. Stale chunks can keep old runtime errors alive.
|
||||
if (
|
||||
url.pathname.startsWith('/_next/') ||
|
||||
url.pathname === '/__nextjs_original-stack-frame' ||
|
||||
url.pathname === '/__nextjs_launch-editor'
|
||||
) {
|
||||
e.respondWith(fetch(e.request))
|
||||
return
|
||||
}
|
||||
|
||||
// Keep analytics fully live while debugging/runtime data is dynamic.
|
||||
if (url.pathname === '/analytics') {
|
||||
e.respondWith(fetch(e.request))
|
||||
return
|
||||
}
|
||||
|
||||
// Navigation (main page) — network first, cache fallback
|
||||
if (e.request.mode === 'navigate') {
|
||||
e.respondWith(networkFirst(e.request))
|
||||
@@ -86,4 +102,4 @@ self.addEventListener('message', e => {
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[SW] Loaded - version 3')
|
||||
console.log('[SW] Loaded - version 4')
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { scanCoursesDirectory } from '@/lib/scanner'
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = '/tmp/offlineacademy-stale-scan'
|
||||
const coursesRoot = join(root, 'My_Courses')
|
||||
const coursePath = join(coursesRoot, 'Stale Course', '01 - Module')
|
||||
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await mkdir(coursePath, { recursive: true })
|
||||
await writeFile(join(coursePath, '01 - Lesson.mp4'), '')
|
||||
|
||||
await prisma.setting.upsert({
|
||||
where: { key: 'coursesRoot' },
|
||||
update: { value: coursesRoot },
|
||||
create: { key: 'coursesRoot', value: coursesRoot },
|
||||
})
|
||||
|
||||
await scanCoursesDirectory({ maxConcurrency: 1, maxLessonsPerCourse: 10, maxModulesPerCourse: 5 })
|
||||
|
||||
const before = await prisma.course.count()
|
||||
assert(before === 1, `expected 1 course before delete, got ${before}`)
|
||||
|
||||
await rm(join(coursesRoot, 'Stale Course'), { recursive: true, force: true })
|
||||
|
||||
await scanCoursesDirectory({ maxConcurrency: 1, maxLessonsPerCourse: 10, maxModulesPerCourse: 5 })
|
||||
|
||||
const after = await prisma.course.count()
|
||||
assert(after === 0, `expected 0 courses after delete, got ${after}`)
|
||||
|
||||
console.log('stale course deletion verification passed')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { buildSubtitleTrackMap, convertSrtToVtt, languageCodeToLabel, normalizeSubtitleLang } from '@/lib/subtitles'
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const tracks = buildSubtitleTrackMap(
|
||||
['01 - Lesson.mp4', '02 - Other.mp4'],
|
||||
[
|
||||
'01 - Lesson.vtt',
|
||||
'01 - Lesson.en.vtt',
|
||||
'01 - Lesson.es.vtt',
|
||||
'01 - Lesson.pt-BR.srt',
|
||||
'02 - Other.ja.vtt',
|
||||
'unrelated.en.vtt',
|
||||
],
|
||||
(fileName) => `Course/Module/${fileName}`
|
||||
)
|
||||
|
||||
const lessonTracks = tracks.get('01 - Lesson') || []
|
||||
assert(lessonTracks.length === 4, `expected 4 subtitle tracks for 01 - Lesson, got ${lessonTracks.length}`)
|
||||
assert(lessonTracks[0].lang === 'en', 'legacy subtitle should become default English track')
|
||||
assert(lessonTracks[0].isDefault === true, 'first/default English track should be default')
|
||||
assert(lessonTracks.some(track => track.lang === 'es' && track.label === 'Spanish'), 'Spanish track missing')
|
||||
assert(lessonTracks.some(track => track.lang === 'pt-BR' && track.label === 'Portuguese (Brazil)'), 'pt-BR track missing')
|
||||
|
||||
const otherTracks = tracks.get('02 - Other') || []
|
||||
assert(otherTracks.length === 1, `expected 1 subtitle track for 02 - Other, got ${otherTracks.length}`)
|
||||
assert(otherTracks[0].lang === 'ja' && otherTracks[0].label === 'Japanese', 'Japanese track missing')
|
||||
|
||||
assert(normalizeSubtitleLang('PT-br') === 'pt-BR', 'pt-BR normalization failed')
|
||||
assert(languageCodeToLabel('fil') === 'Filipino', 'Filipino label failed')
|
||||
assert(!tracks.has('unrelated'), 'unrelated subtitle should not create a track group')
|
||||
|
||||
const converted = convertSrtToVtt('1\n00:00:01,250 --> 00:00:04,500\nHello captions\n')
|
||||
assert(converted.startsWith('WEBVTT'), 'SRT conversion should emit WEBVTT header')
|
||||
assert(converted.includes('00:00:01.250 --> 00:00:04.500'), 'SRT conversion should rewrite comma timestamps')
|
||||
assert(!converted.includes('00:00:01,250'), 'SRT conversion should not keep comma timestamps')
|
||||
|
||||
console.log('subtitle parser verification passed')
|
||||
@@ -0,0 +1,59 @@
|
||||
import { mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { scanCoursesDirectory } from '@/lib/scanner'
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = '/tmp/offlineacademy-subtitle-scan-smoke'
|
||||
const coursesRoot = join(root, 'My_Courses')
|
||||
const modulePath = join(coursesRoot, 'Subtitle Smoke Course', '01 - Module')
|
||||
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await mkdir(modulePath, { recursive: true })
|
||||
|
||||
await writeFile(join(modulePath, '01 - Lesson.mp4'), '')
|
||||
await writeFile(join(modulePath, '01 - Lesson.vtt'), 'WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nLegacy English\n')
|
||||
await writeFile(join(modulePath, '01 - Lesson.en.vtt'), 'WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nEnglish\n')
|
||||
await writeFile(join(modulePath, '01 - Lesson.es.vtt'), 'WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nEspañol\n')
|
||||
await writeFile(join(modulePath, '01 - Lesson.ja.vtt'), 'WEBVTT\n\n00:00:00.000 --> 00:00:01.000\n日本語\n')
|
||||
await writeFile(join(modulePath, '01 - Lesson.pt-BR.srt'), '1\n00:00:00,000 --> 00:00:01,000\nPortuguês\n')
|
||||
|
||||
await prisma.setting.upsert({
|
||||
where: { key: 'coursesRoot' },
|
||||
update: { value: coursesRoot },
|
||||
create: { key: 'coursesRoot', value: coursesRoot },
|
||||
})
|
||||
|
||||
const result = await scanCoursesDirectory({ maxConcurrency: 1, maxLessonsPerCourse: 10, maxModulesPerCourse: 5 })
|
||||
assert(result.errors.length === 0, `expected no scan errors, got ${JSON.stringify(result.errors)}`)
|
||||
|
||||
const lesson = await prisma.lesson.findFirst({
|
||||
where: { fileName: '01 - Lesson.mp4' },
|
||||
include: { subtitles: { orderBy: [{ isDefault: 'desc' }, { label: 'asc' }] } },
|
||||
})
|
||||
|
||||
assert(lesson, 'expected scanned lesson to exist')
|
||||
assert(lesson?.subtitlePath, 'expected legacy subtitlePath fallback to be set')
|
||||
assert(lesson?.subtitles.length === 5, `expected 5 subtitle tracks, got ${lesson?.subtitles.length}`)
|
||||
assert(lesson?.subtitles.some(track => track.lang === 'es' && track.label === 'Spanish'), 'expected Spanish subtitle track')
|
||||
assert(lesson?.subtitles.some(track => track.lang === 'ja' && track.label === 'Japanese'), 'expected Japanese subtitle track')
|
||||
assert(lesson?.subtitles.some(track => track.lang === 'pt-BR' && track.label === 'Portuguese (Brazil)' && track.format === 'srt'), 'expected pt-BR SRT subtitle track')
|
||||
assert(lesson?.subtitles.filter(track => track.isDefault).length === 1, 'expected exactly one default subtitle track')
|
||||
|
||||
console.log('subtitle scan verification passed')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(error => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
Reference in New Issue
Block a user