8 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
7 changed files with 684 additions and 219 deletions
+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 }
)
}
}
}
+25 -9
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: {
@@ -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>
)
}
}
+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>
+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.
+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,
}
}
}