Add loading indicator to group switcher
This commit is contained in:
64
src/app/agregator/old-schedule.txt
Normal file
64
src/app/agregator/old-schedule.txt
Normal 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')
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,11 @@ type PageProps = {
|
||||
name: string
|
||||
}
|
||||
parsedAt: Date
|
||||
cacheAvailableFor: string[]
|
||||
}
|
||||
|
||||
export default function HomePage(props: NextSerialized<PageProps>) {
|
||||
const { schedule, group, parsedAt } = nextDeserialized<PageProps>(props)
|
||||
const { schedule, group, cacheAvailableFor, parsedAt } = nextDeserialized<PageProps>(props)
|
||||
|
||||
React.useEffect(() => {
|
||||
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:description" content={`Расписание занятий группы ${group.name} на неделю в Колледже Связи ПГУТИ. Расписание пар, материалы для подготовки и изменения в расписании.`} />
|
||||
</Head>
|
||||
<NavBar />
|
||||
<NavBar cacheAvailableFor={cacheAvailableFor} />
|
||||
<LastUpdateAt date={parsedAt} />
|
||||
<Schedule days={schedule} />
|
||||
</>
|
||||
@@ -102,6 +103,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
|
||||
return { props: {} }
|
||||
}
|
||||
|
||||
const cacheAvailableFor = Array.from(cachedSchedules.entries())
|
||||
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
|
||||
.map(([k]) => k)
|
||||
|
||||
context.res.setHeader('ETag', `"${etag}"`)
|
||||
return {
|
||||
props: nextSerialized({
|
||||
@@ -110,7 +115,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
|
||||
group: {
|
||||
id: group,
|
||||
name: groups[group][1]
|
||||
}
|
||||
},
|
||||
cacheAvailableFor
|
||||
})
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/shared/utils"
|
||||
import { Spinner } from "@/shared/ui/spinner"
|
||||
|
||||
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",
|
||||
@@ -37,17 +38,26 @@ export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
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"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
<>
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
disabled={loading || disabled}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<Spinner />
|
||||
)}
|
||||
{children}
|
||||
</Comp>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
19
src/shared/context/nav-context.tsx
Normal file
19
src/shared/context/nav-context.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
src/shared/ui/spinner.tsx
Normal file
7
src/shared/ui/spinner.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import styles from './styles.module.scss'
|
||||
|
||||
export function Spinner() {
|
||||
return (
|
||||
<div className={styles.spinner} />
|
||||
)
|
||||
}
|
||||
15
src/shared/ui/styles.module.scss
Normal file
15
src/shared/ui/styles.module.scss
Normal 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); }
|
||||
}
|
||||
@@ -7,8 +7,11 @@ import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { FaGithub } from 'react-icons/fa'
|
||||
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 [schemeTheme, setSchemeTheme] = React.useState<string>()
|
||||
const navRef = React.useRef<HTMLDivElement>(null)
|
||||
@@ -37,23 +40,25 @@ export function NavBar() {
|
||||
}, [theme])
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 w-full p-2 bg-background z-[1] pb-0 mb-2 shadow-header">
|
||||
<nav className={cx('rounded-lg p-2 w-full flex justify-between', { 'bg-slate-200': theme === 'light', 'bg-slate-900': theme === 'dark' })} ref={navRef}>
|
||||
<ul className="flex gap-2">
|
||||
<NavBarItem url="/ps7">ПС-7</NavBarItem>
|
||||
<NavBarItem url="/pks35k">ПКС-35к</NavBarItem>
|
||||
<AddGroupButton />
|
||||
</ul>
|
||||
<div className='flex gap-1 min-[500px]:gap-2'>
|
||||
<Link href='https://github.com/VityaSchel/kspguti-schedule' target='_blank' rel='nofollower noreferrer'>
|
||||
<Button variant='outline' size='icon' tabIndex={-1}>
|
||||
<FaGithub />
|
||||
</Button>
|
||||
</Link>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<NavContextProvider cacheAvailableFor={cacheAvailableFor}>
|
||||
<header className="sticky top-0 w-full p-2 bg-background z-[1] pb-0 mb-2 shadow-header">
|
||||
<nav className={cx('rounded-lg p-2 w-full flex justify-between', { 'bg-slate-200': theme === 'light', 'bg-slate-900': theme === 'dark' })} ref={navRef}>
|
||||
<ul className="flex gap-2">
|
||||
<NavBarItem url="/ps7">ПС-7</NavBarItem>
|
||||
<NavBarItem url="/pks35k">ПКС-35к</NavBarItem>
|
||||
<AddGroupButton />
|
||||
</ul>
|
||||
<div className='flex gap-1 min-[500px]:gap-2'>
|
||||
<Link href='https://github.com/VityaSchel/kspguti-schedule' target='_blank' rel='nofollower noreferrer'>
|
||||
<Button variant='outline' size='icon' tabIndex={-1}>
|
||||
<FaGithub />
|
||||
</Button>
|
||||
</Link>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</NavContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,12 +67,50 @@ function NavBarItem({ url, children }: React.PropsWithChildren<{
|
||||
}>) {
|
||||
const router = useRouter()
|
||||
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 (
|
||||
<li>
|
||||
<Link href={url}>
|
||||
<Button tabIndex={-1} variant={isActive ? 'default' : 'secondary'}>{children}</Button>
|
||||
</Link>
|
||||
{isLoading ? (
|
||||
button
|
||||
) : (
|
||||
<Link href={url} onClick={handleStartLoading}>
|
||||
{button}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user