Add loading indicator to group switcher

This commit is contained in:
VityaSchel
2023-10-15 00:58:12 +04:00
parent 95f1b8914f
commit 7e440c9bff
7 changed files with 194 additions and 30 deletions

View File

@@ -0,0 +1,64 @@
import { Day } from '@/shared/model/day'
import { parsePage } from '@/app/parser/schedule'
import contentTypeParser from 'content-type'
import { JSDOM } from 'jsdom'
// import { content as mockContent } from './mock'
import { reportParserError } from '@/app/logger'
// import { groups } from '@/shared/data/groups'
// const fetchingGroups: {
// [groupID: number]: boolean
// } = Object.fromEntries(Object.values(groups).map(([gId]) => [gId, false]))
// const callbacks: {
// [groupID: number]: Set<{ resolve: (days: Day[]) => void, reject: (e: unknown) => void }>
// } = Object.fromEntries(Object.values(groups).map(([gId]) => [gId, new Set()]))
export async function getSchedule(groupID: number, groupName: string): Promise<Day[]> {
// if (fetchingGroups[groupID]) {
// return new Promise((resolve, reject) => {
// callbacks[groupID].add({
// resolve: (days: Day[]) => resolve(days),
// reject
// })
// })
// } else {
// fetchingGroups[groupID] = true
// }
// try {
// const result = await parseSchedule(groupID, groupName)
// fetchingGroups[groupID] = false
// Array.from(callbacks[groupID].values()).forEach(({ resolve }) => resolve(result))
// callbacks[groupID].clear()
// return result
// } catch(e) {
// fetchingGroups[groupID] = false
// console.log(Array.from(callbacks[groupID].values()).length)
// Array.from(callbacks[groupID].values()).forEach(({ reject }) => reject(e))
// callbacks[groupID].clear()
// throw e
// }
}
export async function parseSchedule(groupID: number, groupName: string) {
const page = await fetch(`${process.env.PROXY_URL ?? 'https://lk.ks.psuti.ru'}/?mn=2&obj=${groupID}`)
// const page = { text: async () => mockContent, status: 200, headers: { get: (s: string) => s && 'text/html' } }
const content = await page.text()
const contentType = page.headers.get('content-type')
if (page.status === 200 && contentType && contentTypeParser.parse(contentType).type === 'text/html') {
try {
const root = new JSDOM(content).window.document
return parsePage(root, groupName)
} catch (e) {
console.error('Error while parsing lk.ks.psuti.ru')
reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName)
throw e
}
} else {
console.error(page.status, contentType)
console.error(content.length > 500 ? content.slice(0, 500 - 3) + '...' : content)
reportParserError(new Date().toISOString(), 'Не удалось получить страницу для группы', groupName)
throw new Error('Error while fetching lk.ks.psuti.ru')
}
}

View File

@@ -18,10 +18,11 @@ type PageProps = {
name: string name: string
} }
parsedAt: Date parsedAt: Date
cacheAvailableFor: string[]
} }
export default function HomePage(props: NextSerialized<PageProps>) { export default function HomePage(props: NextSerialized<PageProps>) {
const { schedule, group, parsedAt } = nextDeserialized<PageProps>(props) const { schedule, group, cacheAvailableFor, parsedAt } = nextDeserialized<PageProps>(props)
React.useEffect(() => { React.useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -51,7 +52,7 @@ export default function HomePage(props: NextSerialized<PageProps>) {
<meta property="og:title" content={`Группа ${group.name} — Расписание занятий в Колледже Связи`} /> <meta property="og:title" content={`Группа ${group.name} — Расписание занятий в Колледже Связи`} />
<meta property="og:description" content={`Расписание занятий группы ${group.name} на неделю в Колледже Связи ПГУТИ. Расписание пар, материалы для подготовки и изменения в расписании.`} /> <meta property="og:description" content={`Расписание занятий группы ${group.name} на неделю в Колледже Связи ПГУТИ. Расписание пар, материалы для подготовки и изменения в расписании.`} />
</Head> </Head>
<NavBar /> <NavBar cacheAvailableFor={cacheAvailableFor} />
<LastUpdateAt date={parsedAt} /> <LastUpdateAt date={parsedAt} />
<Schedule days={schedule} /> <Schedule days={schedule} />
</> </>
@@ -102,6 +103,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
return { props: {} } return { props: {} }
} }
const cacheAvailableFor = Array.from(cachedSchedules.entries())
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
.map(([k]) => k)
context.res.setHeader('ETag', `"${etag}"`) context.res.setHeader('ETag', `"${etag}"`)
return { return {
props: nextSerialized({ props: nextSerialized({
@@ -110,7 +115,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
group: { group: {
id: group, id: group,
name: groups[group][1] name: groups[group][1]
} },
cacheAvailableFor
}) })
} }
} else { } else {

View File

@@ -3,6 +3,7 @@ import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/shared/utils" import { cn } from "@/shared/utils"
import { Spinner } from "@/shared/ui/spinner"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
@@ -37,17 +38,26 @@ export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean
loading?: boolean
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, disabled, loading, children, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button"
return ( return (
<Comp <>
className={cn(buttonVariants({ variant, size, className }))} <Comp
ref={ref} className={cn(buttonVariants({ variant, size, className }))}
{...props} ref={ref}
/> disabled={loading || disabled}
{...props}
>
{loading && (
<Spinner />
)}
{children}
</Comp>
</>
) )
} }
) )

View File

@@ -0,0 +1,19 @@
import React from 'react'
type ContextState = { cacheAvailableFor: string[], isLoading: false | string, setIsLoading: (isLoading: false | string) => void }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
export const NavContext = React.createContext<ContextState>(null)
export function NavContextProvider({ cacheAvailableFor, children }: React.PropsWithChildren<{
cacheAvailableFor: string[]
}>) {
const [isLoading, setIsLoading] = React.useState<ContextState['isLoading']>(false)
return (
<NavContext.Provider value={{ cacheAvailableFor, isLoading, setIsLoading }}>
{children}
</NavContext.Provider>
)
}

View File

@@ -0,0 +1,7 @@
import styles from './styles.module.scss'
export function Spinner() {
return (
<div className={styles.spinner} />
)
}

View File

@@ -0,0 +1,15 @@
.spinner {
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-left: 2px solid transparent;
border-top: 2px solid transparent;
width: 16px;
height: 16px;
animation: spin 1s linear infinite;
margin-right: 8px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View File

@@ -7,8 +7,11 @@ import Link from 'next/link'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { FaGithub } from 'react-icons/fa' import { FaGithub } from 'react-icons/fa'
import cx from 'classnames' import cx from 'classnames'
import { NavContext, NavContextProvider } from '@/shared/context/nav-context'
export function NavBar() { export function NavBar({ cacheAvailableFor }: {
cacheAvailableFor: string[]
}) {
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
const [schemeTheme, setSchemeTheme] = React.useState<string>() const [schemeTheme, setSchemeTheme] = React.useState<string>()
const navRef = React.useRef<HTMLDivElement>(null) const navRef = React.useRef<HTMLDivElement>(null)
@@ -37,23 +40,25 @@ export function NavBar() {
}, [theme]) }, [theme])
return ( return (
<header className="sticky top-0 w-full p-2 bg-background z-[1] pb-0 mb-2 shadow-header"> <NavContextProvider cacheAvailableFor={cacheAvailableFor}>
<nav className={cx('rounded-lg p-2 w-full flex justify-between', { 'bg-slate-200': theme === 'light', 'bg-slate-900': theme === 'dark' })} ref={navRef}> <header className="sticky top-0 w-full p-2 bg-background z-[1] pb-0 mb-2 shadow-header">
<ul className="flex gap-2"> <nav className={cx('rounded-lg p-2 w-full flex justify-between', { 'bg-slate-200': theme === 'light', 'bg-slate-900': theme === 'dark' })} ref={navRef}>
<NavBarItem url="/ps7">ПС-7</NavBarItem> <ul className="flex gap-2">
<NavBarItem url="/pks35k">ПКС-35к</NavBarItem> <NavBarItem url="/ps7">ПС-7</NavBarItem>
<AddGroupButton /> <NavBarItem url="/pks35k">ПКС-35к</NavBarItem>
</ul> <AddGroupButton />
<div className='flex gap-1 min-[500px]:gap-2'> </ul>
<Link href='https://github.com/VityaSchel/kspguti-schedule' target='_blank' rel='nofollower noreferrer'> <div className='flex gap-1 min-[500px]:gap-2'>
<Button variant='outline' size='icon' tabIndex={-1}> <Link href='https://github.com/VityaSchel/kspguti-schedule' target='_blank' rel='nofollower noreferrer'>
<FaGithub /> <Button variant='outline' size='icon' tabIndex={-1}>
</Button> <FaGithub />
</Link> </Button>
<ThemeSwitcher /> </Link>
</div> <ThemeSwitcher />
</nav> </div>
</header> </nav>
</header>
</NavContextProvider>
) )
} }
@@ -62,12 +67,50 @@ function NavBarItem({ url, children }: React.PropsWithChildren<{
}>) { }>) {
const router = useRouter() const router = useRouter()
const isActive = router.asPath === url const isActive = router.asPath === url
const { cacheAvailableFor, isLoading, setIsLoading } = React.useContext(NavContext)
const handleStartLoading = async () => {
let isLoaded = false
const loadEnd = () => {
isLoaded = true
setIsLoading(false)
}
router.events.on('routeChangeComplete', loadEnd)
router.events.on('routeChangeError', loadEnd)
if (cacheAvailableFor.includes(url.slice(1))) {
await new Promise(resolve => setTimeout(resolve, 500))
if(isLoaded) return
}
setIsLoading(url)
return () => {
router.events.off('routeChangeComplete', loadEnd)
router.events.off('routeChangeError', loadEnd)
}
}
const button = (
<Button
tabIndex={-1} variant={isActive ? 'default' : 'secondary'}
disabled={Boolean(isLoading)}
loading={isLoading === url}
>
{children}
</Button>
)
return ( return (
<li> <li>
<Link href={url}> {isLoading ? (
<Button tabIndex={-1} variant={isActive ? 'default' : 'secondary'}>{children}</Button> button
</Link> ) : (
<Link href={url} onClick={handleStartLoading}>
{button}
</Link>
)}
</li> </li>
) )
} }