mirror of
https://github.com/nicetry247/offlineacademy.git
synced 2026-08-31 04:47:56 +00:00
perf: cache and prefetch course pages
This commit is contained in:
+145
-42
@@ -1,14 +1,24 @@
|
|||||||
'use client'
|
'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 {
|
export interface Course {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
displayName?: string | null
|
||||||
slug: string
|
slug: string
|
||||||
thumbnail: string | null
|
thumbnail: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
path: string
|
path: string
|
||||||
|
favorited?: boolean
|
||||||
|
tags?: CourseTag[]
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
_count: { modules: number; lessons: number }
|
_count: { modules: number; lessons: number }
|
||||||
@@ -22,7 +32,8 @@ export interface Course {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CoursesResponse {
|
interface CoursesResponse {
|
||||||
courses: any[]
|
courses: Course[]
|
||||||
|
tags: CourseTag[]
|
||||||
pagination: {
|
pagination: {
|
||||||
page: number
|
page: number
|
||||||
limit: number
|
limit: number
|
||||||
@@ -42,9 +53,36 @@ interface UseCoursesOptions {
|
|||||||
initialSortOrder?: string
|
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 = {}) {
|
export function useCourses(options: UseCoursesOptions = {}) {
|
||||||
const [courses, setCourses] = useState<any[]>([])
|
const [courses, setCourses] = useState<Course[]>([])
|
||||||
const [tags, setTags] = useState<any[]>([])
|
const [tags, setTags] = useState<CourseTag[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [pagination, setPagination] = useState({
|
const [pagination, setPagination] = useState({
|
||||||
@@ -60,19 +98,38 @@ export function useCourses(options: UseCoursesOptions = {}) {
|
|||||||
const [sortBy, setSortBy] = useState(options.initialSortBy || 'updatedAt')
|
const [sortBy, setSortBy] = useState(options.initialSortBy || 'updatedAt')
|
||||||
const [sortOrder, setSortOrder] = useState(options.initialSortOrder || 'desc')
|
const [sortOrder, setSortOrder] = useState(options.initialSortOrder || 'desc')
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState(options.initialSearch || '')
|
const [debouncedSearch, setDebouncedSearch] = useState(options.initialSearch || '')
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
const requestIdRef = useRef(0)
|
||||||
|
const hasDataRef = useRef(false)
|
||||||
|
|
||||||
// Debounce search
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
setDebouncedSearch(search)
|
setDebouncedSearch(search)
|
||||||
setPagination(prev => ({ ...prev, page: 1 }))
|
setPagination((previous) => ({ ...previous, page: 1 }))
|
||||||
}, 300)
|
}, 250)
|
||||||
return () => clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [search])
|
}, [search])
|
||||||
|
|
||||||
const fetchCourses = useCallback(async () => {
|
const applyResponse = useCallback((data: CoursesResponse) => {
|
||||||
setLoading(true)
|
hasDataRef.current = true
|
||||||
setError(null)
|
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({
|
const params = new URLSearchParams({
|
||||||
page: pagination.page.toString(),
|
page: pagination.page.toString(),
|
||||||
@@ -82,72 +139,118 @@ export function useCourses(options: UseCoursesOptions = {}) {
|
|||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
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 {
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch courses')
|
throw new Error('Failed to fetch courses')
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
|
||||||
setCourses(data.courses)
|
const data = await response.json() as CoursesResponse
|
||||||
setTags(data.tags || [])
|
if (requestId !== requestIdRef.current) return
|
||||||
setPagination(prev => ({
|
|
||||||
...prev,
|
writeCache(requestKey, data)
|
||||||
total: data.pagination.total,
|
applyResponse(data)
|
||||||
totalPages: data.pagination.totalPages,
|
|
||||||
hasNext: data.pagination.hasNext,
|
if (data.pagination.hasNext) {
|
||||||
hasPrev: data.pagination.hasPrev,
|
const nextParams = new URLSearchParams(params)
|
||||||
}))
|
nextParams.set('page', String(data.pagination.page + 1))
|
||||||
} catch (err) {
|
const nextKey = nextParams.toString()
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch courses')
|
|
||||||
|
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 {
|
} 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(() => {
|
useEffect(() => {
|
||||||
fetchCourses()
|
void fetchCourses()
|
||||||
|
return () => abortRef.current?.abort()
|
||||||
}, [fetchCourses])
|
}, [fetchCourses])
|
||||||
|
|
||||||
const goToPage = (page: number) => {
|
const goToPage = (page: number) => {
|
||||||
setPagination(prev => ({ ...prev, page }))
|
setPagination((previous) => ({ ...previous, page: Math.max(1, page) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextPage = () => {
|
const nextPage = () => {
|
||||||
if (pagination.hasNext) {
|
if (pagination.hasNext) {
|
||||||
setPagination(prev => ({ ...prev, page: prev.page + 1 }))
|
setPagination((previous) => ({ ...previous, page: previous.page + 1 }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevPage = () => {
|
const prevPage = () => {
|
||||||
if (pagination.hasPrev) {
|
if (pagination.hasPrev) {
|
||||||
setPagination(prev => ({ ...prev, page: prev.page - 1 }))
|
setPagination((previous) => ({ ...previous, page: Math.max(1, previous.page - 1) }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const changeLimit = (limit: number) => {
|
const changeLimit = (limit: number) => {
|
||||||
setPagination(prev => ({ ...prev, limit, page: 1 }))
|
setPagination((previous) => ({ ...previous, limit, page: 1 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearch = (value: string) => {
|
const handleSearch = (value: string) => setSearch(value)
|
||||||
setSearch(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFilter = (value: string) => {
|
const handleFilter = (value: string) => {
|
||||||
setFilter(value)
|
setFilter(value)
|
||||||
setPagination(prev => ({ ...prev, page: 1 }))
|
setPagination((previous) => ({ ...previous, page: 1 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSort = (field: string) => {
|
const handleSort = (field: string) => {
|
||||||
setSortBy(field)
|
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 = () => {
|
const toggleSortOrder = () => {
|
||||||
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')
|
setSortOrder((previous) => previous === 'asc' ? 'desc' : 'asc')
|
||||||
setPagination(prev => ({ ...prev, page: 1 }))
|
setPagination((previous) => ({ ...previous, page: 1 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refetch = useCallback(() => fetchCourses(true), [fetchCourses])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
courses,
|
courses,
|
||||||
tags,
|
tags,
|
||||||
@@ -161,12 +264,12 @@ export function useCourses(options: UseCoursesOptions = {}) {
|
|||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortBy: handleSort,
|
setSortBy: handleSort,
|
||||||
setSortOrder: toggleSortOrder,
|
setSortOrder: handleSortOrder,
|
||||||
toggleSortOrder,
|
toggleSortOrder,
|
||||||
goToPage,
|
goToPage,
|
||||||
nextPage,
|
nextPage,
|
||||||
prevPage,
|
prevPage,
|
||||||
changeLimit,
|
changeLimit,
|
||||||
refetch: fetchCourses,
|
refetch,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user