mirror of
https://github.com/nicetry247/offlineacademy.git
synced 2026-07-26 11:58:44 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47a2690ec7 | |||
| fe22dafe00 | |||
| c027d18d21 | |||
| 0f9bb69b18 | |||
| 2b8705902a | |||
| 159e07543d | |||
| ea72934716 | |||
| 16fea94bb4 |
+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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-9
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user