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
|
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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
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 { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user