diff --git a/src/app/agregator/old-schedule.txt b/src/app/agregator/old-schedule.txt new file mode 100644 index 0000000..82143fe --- /dev/null +++ b/src/app/agregator/old-schedule.txt @@ -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 { + // 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') + } +} \ No newline at end of file diff --git a/src/pages/[group].tsx b/src/pages/[group].tsx index f3cef5f..e13eadf 100644 --- a/src/pages/[group].tsx +++ b/src/pages/[group].tsx @@ -18,10 +18,11 @@ type PageProps = { name: string } parsedAt: Date + cacheAvailableFor: string[] } export default function HomePage(props: NextSerialized) { - const { schedule, group, parsedAt } = nextDeserialized(props) + const { schedule, group, cacheAvailableFor, parsedAt } = nextDeserialized(props) React.useEffect(() => { if (typeof window !== 'undefined') { @@ -51,7 +52,7 @@ export default function HomePage(props: NextSerialized) { - + @@ -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 { diff --git a/src/shadcn/ui/button.tsx b/src/shadcn/ui/button.tsx index a1df668..e9f2a5a 100644 --- a/src/shadcn/ui/button.tsx +++ b/src/shadcn/ui/button.tsx @@ -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, VariantProps { asChild?: boolean + loading?: boolean } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ({ className, variant, size, asChild = false, disabled, loading, children, ...props }, ref) => { const Comp = asChild ? Slot : "button" return ( - + <> + + {loading && ( + + )} + {children} + + ) } ) diff --git a/src/shared/context/nav-context.tsx b/src/shared/context/nav-context.tsx new file mode 100644 index 0000000..d7444e6 --- /dev/null +++ b/src/shared/context/nav-context.tsx @@ -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(null) + +export function NavContextProvider({ cacheAvailableFor, children }: React.PropsWithChildren<{ + cacheAvailableFor: string[] +}>) { + const [isLoading, setIsLoading] = React.useState(false) + + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/src/shared/ui/spinner.tsx b/src/shared/ui/spinner.tsx new file mode 100644 index 0000000..cdb3f37 --- /dev/null +++ b/src/shared/ui/spinner.tsx @@ -0,0 +1,7 @@ +import styles from './styles.module.scss' + +export function Spinner() { + return ( +
+ ) +} \ No newline at end of file diff --git a/src/shared/ui/styles.module.scss b/src/shared/ui/styles.module.scss new file mode 100644 index 0000000..04917c2 --- /dev/null +++ b/src/shared/ui/styles.module.scss @@ -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); } +} \ No newline at end of file diff --git a/src/widgets/navbar/index.tsx b/src/widgets/navbar/index.tsx index e8d6296..041ed6b 100644 --- a/src/widgets/navbar/index.tsx +++ b/src/widgets/navbar/index.tsx @@ -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() const navRef = React.useRef(null) @@ -37,23 +40,25 @@ export function NavBar() { }, [theme]) return ( -
- -
+ +
+ +
+
) } @@ -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 = ( + + ) return (
  • - - - + {isLoading ? ( + button + ) : ( + + {button} + + )}
  • ) } \ No newline at end of file