18 Commits

Author SHA1 Message Date
afterhours247 47a2690ec7 refactor: keep course response types inferred 2026-07-22 05:29:09 +08:00
afterhours247 fe22dafe00 docs: document offlineacademy v2 improvements 2026-07-22 05:26:17 +08:00
afterhours247 c027d18d21 style: add premium midnight theme 2026-07-22 05:25:34 +08:00
afterhours247 0f9bb69b18 style: bootstrap the premium v2 theme 2026-07-22 05:24:51 +08:00
afterhours247 2b8705902a style: refine the v2 launch experience 2026-07-22 05:24:30 +08:00
afterhours247 159e07543d perf: make splash non-blocking and session-aware 2026-07-22 05:24:17 +08:00
afterhours247 ea72934716 perf: cache and prefetch course pages 2026-07-22 05:22:46 +08:00
afterhours247 16fea94bb4 perf: streamline course library queries 2026-07-22 05:22:13 +08:00
nicetry247 39544c57ce Fix subtitle filename patterns and update port number
Updated subtitle filename matching patterns and local development port.
2026-06-28 17:23:43 +08:00
Nice Try 95aa68b121 docs: add v1.0.2 changelog 2026-06-28 09:21:58 +00:00
Nice Try cc6ec4bdbe fix: order lesson subtitles in API response 2026-06-26 21:05:27 +00:00
Nice Try b614295629 fix: embed language segments like -english-onehack.us.srt too 2026-06-26 21:05:07 +00:00
Nice Try 7d0f976da0 feat: multi-language subtitle support (SRT/VTT) 2026-06-26 20:12:48 +00:00
Nice Try 33e5e61b11 fix: read analytics data directly on server 2026-06-26 19:07:39 +00:00
Nice Try 3effbdcb57 test: verify stale course deletion after missing folders 2026-06-26 17:18:15 +00:00
Nice Try 6076267abd fix: delete stale courses from DB when folders are missing 2026-06-26 17:16:55 +00:00
Nice Try 0ed055b83b fix: remove hardcoded 6767 from app metadata and analytics fetch 2026-06-26 15:48:39 +00:00
Nice Try ae093a62bf Merge multilingual subtitle implementation 2026-06-26 14:34:25 +00:00
20 changed files with 1049 additions and 348 deletions
+31 -2
View File
@@ -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
```
@@ -457,7 +458,7 @@ Supported video extensions include common browser-playable formats such as `.mp4
### Subtitles
OfflineAcademy supports subtitle files stored beside the matching video. Use `.vtt` for the best browser compatibility; `.srt` files are detected as best-effort tracks.
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:
@@ -577,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
View File
@@ -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
View File
@@ -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 }
)
}
}
}
+18 -1
View File
@@ -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) {
+3
View File
@@ -18,6 +18,9 @@ export async function GET(
progress: {
where: { userId: 'local-user' },
},
subtitles: {
orderBy: { lang: 'asc' },
},
},
})
+2 -83
View File
@@ -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
View File
@@ -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>
)
}
}
+27 -6
View File
@@ -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>
)
}
}
+4 -1
View File
@@ -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`)
+281
View File
@@ -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;
}
}
+31 -62
View File
@@ -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>
+28
View File
@@ -410,6 +410,18 @@ export function VideoPlayer({
}
}, [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"
@@ -563,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"
+29
View File
@@ -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.
+101
View File
@@ -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
View File
@@ -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,
}
}
}
+13
View File
@@ -135,6 +135,7 @@ async function downloadCourseThumbnail(courseName: string, courseSlug: string):
interface ScanResult {
coursesCreated: number
coursesUpdated: number
coursesDeleted: number
modulesCreated: number
modulesUpdated: number
lessonsCreated: number
@@ -249,6 +250,7 @@ interface ScanOptions {
interface ScanResult {
coursesCreated: number
coursesUpdated: number
coursesDeleted: number
modulesCreated: number
modulesUpdated: number
lessonsCreated: number
@@ -314,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,
@@ -446,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)
}
+91 -8
View File
@@ -69,16 +69,12 @@ export function getSubtitleFormat(fileName: string): SubtitleFormat | null {
return ext === 'vtt' || ext === 'srt' ? ext : null
}
export function parseSubtitleForVideo(
function tryExactBasename(
videoBaseName: string,
subtitleFileName: string,
src: string
subtitleBaseName: string,
src: string,
format: SubtitleFormat
): SubtitleTrackInput | null {
const format = getSubtitleFormat(subtitleFileName)
if (!format) return null
const subtitleBaseName = basename(subtitleFileName, extname(subtitleFileName))
if (subtitleBaseName === videoBaseName) {
return {
src,
@@ -88,7 +84,15 @@ export function parseSubtitleForVideo(
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
@@ -105,6 +109,59 @@ export function parseSubtitleForVideo(
}
}
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[],
@@ -142,3 +199,29 @@ export function sortSubtitleTracks(tracks: SubtitleTrackInput[]): SubtitleTrackI
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`
}
+19 -3
View File
@@ -1,7 +1,7 @@
// OfflineAcademy Service Worker v3Minimal, Chrome-install-criteria focused
// OfflineAcademy Service Worker v4Offline 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')
+47
View File
@@ -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()
})
+6 -1
View File
@@ -1,4 +1,4 @@
import { buildSubtitleTrackMap, languageCodeToLabel, normalizeSubtitleLang } from '@/lib/subtitles'
import { buildSubtitleTrackMap, convertSrtToVtt, languageCodeToLabel, normalizeSubtitleLang } from '@/lib/subtitles'
function assert(condition: unknown, message: string) {
if (!condition) {
@@ -34,4 +34,9 @@ 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')