Added last update, cache strategy, telegram fail notifications, teachers photos
This commit is contained in:
@@ -1 +1,2 @@
|
|||||||
PROXY_HOST=
|
PROXY_HOST=
|
||||||
|
PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN=
|
||||||
11
README.md
11
README.md
@@ -1,5 +1,10 @@
|
|||||||
# Schedule for колледж связи пгути
|
# Schedule for колледж связи пгути
|
||||||
|
|
||||||
|
- [Schedule for колледж связи пгути](#schedule-for-колледж-связи-пгути)
|
||||||
|
- [Tech stack](#tech-stack)
|
||||||
|
- [Hire me!](#hire-me)
|
||||||
|
|
||||||
|
|
||||||
Reskin of https://lk.ks.psuti.ru/ since it lacks mobile support and is generally ugly.
|
Reskin of https://lk.ks.psuti.ru/ since it lacks mobile support and is generally ugly.
|
||||||
|
|
||||||

|

|
||||||
@@ -9,12 +14,14 @@ Reskin of https://lk.ks.psuti.ru/ since it lacks mobile support and is generally
|
|||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
- React with Next.js v13.5 (pages router)
|
- React with Next.js v13.5 (pages router)
|
||||||
- Tailwind CSS
|
- Tailwind CSS. This is my first project using it, after using SCSS Modules for many years
|
||||||
- @shadcn/ui components (built with Radix UI)
|
- @shadcn/ui components (built with Radix UI)
|
||||||
- node-html-parser for scraping, rehydration strategy for cache
|
- node-html-parser for scraping, rehydration strategy for cache
|
||||||
- TypeScript with types for each package
|
- TypeScript with types for each package
|
||||||
|
- Telegram Bot API (via [node-telegram-bot-api]) for parsing failure notifications
|
||||||
|
- Custom [js parser for teachers' photos](https://gist.github.com/VityaSchel/28f1a360ee7798511765910b39c6086c)
|
||||||
|
|
||||||
Built in 1 day. Tools used: pnpm, eslint, react-icons.
|
Built under 1 day. Tools used: pnpm, eslint, react-icons.
|
||||||
|
|
||||||
## Hire me!
|
## Hire me!
|
||||||
|
|
||||||
|
|||||||
@@ -20,21 +20,25 @@
|
|||||||
"classnames": "^2.3.2",
|
"classnames": "^2.3.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^1.0.5",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
"jsdom": "^22.1.0",
|
"jsdom": "^22.1.0",
|
||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"next": "latest",
|
"next": "latest",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"node-html-parser": "^6.1.10",
|
"node-html-parser": "^6.1.10",
|
||||||
|
"node-telegram-bot-api": "^0.63.0",
|
||||||
"react": "latest",
|
"react": "latest",
|
||||||
"react-dom": "latest",
|
"react-dom": "latest",
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
"sharp": "^0.32.6",
|
"sharp": "^0.32.6",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
|
"tailwind-scrollbar-hide": "^1.1.7",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jsdom": "^21.1.3",
|
"@types/jsdom": "^21.1.3",
|
||||||
"@types/node": "latest",
|
"@types/node": "latest",
|
||||||
|
"@types/node-telegram-bot-api": "^0.61.8",
|
||||||
"@types/react": "latest",
|
"@types/react": "latest",
|
||||||
"@types/react-dom": "latest",
|
"@types/react-dom": "latest",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.3",
|
"@typescript-eslint/eslint-plugin": "^6.7.3",
|
||||||
|
|||||||
452
pnpm-lock.yaml
generated
452
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/teachers/larionova.jpg
Normal file
BIN
public/teachers/larionova.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/teachers/амукова.jpg
Normal file
BIN
public/teachers/амукова.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/teachers/арефьев.jpg
Normal file
BIN
public/teachers/арефьев.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
@@ -4,6 +4,7 @@ import contentTypeParser from 'content-type'
|
|||||||
// import { parse } from 'node-html-parser'
|
// import { parse } from 'node-html-parser'
|
||||||
import { JSDOM } from 'jsdom'
|
import { JSDOM } from 'jsdom'
|
||||||
import { content as mockContent } from './mock'
|
import { content as mockContent } from './mock'
|
||||||
|
import { reportParserError } from '@/app/logger'
|
||||||
|
|
||||||
// ПС-7: 146
|
// ПС-7: 146
|
||||||
export async function getSchedule(groupID: number, groupName: string): Promise<Day[]> {
|
export async function getSchedule(groupID: number, groupName: string): Promise<Day[]> {
|
||||||
@@ -17,11 +18,13 @@ export async function getSchedule(groupID: number, groupName: string): Promise<D
|
|||||||
return parsePage(root, groupName)
|
return parsePage(root, groupName)
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('Error while parsing lk.ks.psuti.ru')
|
console.error('Error while parsing lk.ks.psuti.ru')
|
||||||
|
reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName)
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error(page.status, contentType)
|
console.error(page.status, contentType)
|
||||||
console.error(content.length > 500 ? content.slice(0, 500 - 3) + '...' : content)
|
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')
|
throw new Error('Error while fetching lk.ks.psuti.ru')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
18
src/app/logger.ts
Normal file
18
src/app/logger.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import TelegramBot from 'node-telegram-bot-api'
|
||||||
|
|
||||||
|
const token = process.env.PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN
|
||||||
|
const ownerID = process.env.PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID
|
||||||
|
|
||||||
|
let bot: TelegramBot
|
||||||
|
if (!token || !ownerID) {
|
||||||
|
console.warn('Telegram Token is not specified. This means you won\'t get any notifications about parsing failures.')
|
||||||
|
} else {
|
||||||
|
bot = new TelegramBot(token, { polling: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function reportParserError(...text: string[]) {
|
||||||
|
if (!token || !ownerID) return
|
||||||
|
|
||||||
|
await bot.sendMessage(ownerID, text.join(' '))
|
||||||
|
}
|
||||||
14
src/entities/last-update-at/index.tsx
Normal file
14
src/entities/last-update-at/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { formatDistanceStrict } from 'date-fns'
|
||||||
|
import { ru as dateFnsRuLocale } from 'date-fns/locale'
|
||||||
|
|
||||||
|
export function LastUpdateAt({ date }: {
|
||||||
|
date: Date
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className='flex md:justify-end px-4 md:h-0'>
|
||||||
|
<span className='text-sm text-border md:whitespace-pre-wrap md:text-right'>
|
||||||
|
Последнее обновление:{'\n'}{formatDistanceStrict(date, Date.now(), { locale: dateFnsRuLocale, addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
src/features/add-group/1925.png
Normal file
BIN
src/features/add-group/1925.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
63
src/features/add-group/index.tsx
Normal file
63
src/features/add-group/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Button } from '@/shadcn/ui/button'
|
||||||
|
import { MdAdd } from 'react-icons/md'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/shadcn/ui/dialog'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import coolEmoji from './1925.png'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { BsTelegram } from 'react-icons/bs'
|
||||||
|
import { SlSocialVkontakte } from 'react-icons/sl'
|
||||||
|
|
||||||
|
export function AddGroupButton() {
|
||||||
|
const [popupVisible, setPopupVisible] = React.useState(false)
|
||||||
|
|
||||||
|
const handleOpenPopup = () => {
|
||||||
|
setPopupVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant='secondary' onClick={handleOpenPopup}><MdAdd /></Button>
|
||||||
|
<Popup open={popupVisible} onClose={() => setPopupVisible(false)} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Popup({ open, onClose }: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => any
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={isOpen => !isOpen && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Добавить группу</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogDescription>
|
||||||
|
Если вы хотите добавить свою группу на сайт, скиньтесь всей группой и задонатьте мне 500 ₽
|
||||||
|
</DialogDescription>
|
||||||
|
<DialogDescription>
|
||||||
|
На сайте не только появится ваше расписание, но оно будет обновляться каждую неделю. А если во время парсинга произойдет ошибка — я сразу получу об этом уведомление в телеграм, потому что у меня настроен бот.
|
||||||
|
</DialogDescription>
|
||||||
|
<DialogDescription>
|
||||||
|
Для меня добавить вашу группу это даже не одна строка кода, а одно нажатие клавиши, но я хочу чтобы этот сайт был доступен только самым лучшим и избранным, достойных престижа <Image src={coolEmoji} width={14} height={14} alt='' className='inline align-text-bottom' />
|
||||||
|
</DialogDescription>
|
||||||
|
<DialogFooter className='!justify-start !flex-row mt-3 gap-3'>
|
||||||
|
<Link href='https://t.me/hloth'>
|
||||||
|
<Button tabIndex={-1} className='gap-3'><BsTelegram /> Мой телеграм</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href='https://vk.com/hloth'>
|
||||||
|
<Button tabIndex={-1} className='gap-3'><SlSocialVkontakte /> Мой ВКонтакте</Button>
|
||||||
|
</Link>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,22 +4,27 @@ import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'
|
|||||||
import { getSchedule } from '@/app/agregator/schedule'
|
import { getSchedule } from '@/app/agregator/schedule'
|
||||||
import { NextSerialized, nextDeserializer, nextSerialized } from '@/app/utils/date-serializer'
|
import { NextSerialized, nextDeserializer, nextSerialized } from '@/app/utils/date-serializer'
|
||||||
import { NavBar } from '@/widgets/navbar'
|
import { NavBar } from '@/widgets/navbar'
|
||||||
|
import { LastUpdateAt } from '@/entities/last-update-at'
|
||||||
|
|
||||||
type PageProps = NextSerialized<{
|
type PageProps = NextSerialized<{
|
||||||
schedule: Day[]
|
schedule: Day[]
|
||||||
|
parsedAt: Date
|
||||||
}>
|
}>
|
||||||
|
|
||||||
export default function HomePage(props: PageProps) {
|
export default function HomePage(props: PageProps) {
|
||||||
const { schedule } = nextDeserializer(props)
|
const { schedule, parsedAt } = nextDeserializer(props)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NavBar />
|
<NavBar />
|
||||||
|
<LastUpdateAt date={parsedAt} />
|
||||||
<Schedule days={schedule} />
|
<Schedule days={schedule} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cachedSchedules = new Map<string, { lastFetched: Date, results: Day[] }>()
|
||||||
|
const maxCacheDurationInMS = 1000 * 60 * 60
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise<GetServerSidePropsResult<PageProps>> {
|
export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise<GetServerSidePropsResult<PageProps>> {
|
||||||
const groups: { [group: string]: [number, string] } = {
|
const groups: { [group: string]: [number, string] } = {
|
||||||
ps7: [146, 'ПС-7'],
|
ps7: [146, 'ПС-7'],
|
||||||
@@ -27,11 +32,24 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
|
|||||||
}
|
}
|
||||||
const group = context.params?.group
|
const group = context.params?.group
|
||||||
if (group && Object.hasOwn(groups, group) && group in groups) {
|
if (group && Object.hasOwn(groups, group) && group in groups) {
|
||||||
const schedule = await getSchedule(...groups[group])
|
let schedule
|
||||||
|
let parsedAt
|
||||||
|
|
||||||
|
const cachedSchedule = cachedSchedules.get(group)
|
||||||
|
if (cachedSchedule?.lastFetched && Date.now() - cachedSchedule.lastFetched.getTime() < maxCacheDurationInMS) {
|
||||||
|
schedule = cachedSchedule.results
|
||||||
|
parsedAt = cachedSchedule.lastFetched
|
||||||
|
} else {
|
||||||
|
schedule = await getSchedule(...groups[group])
|
||||||
|
parsedAt = new Date()
|
||||||
|
cachedSchedules.set(group, { lastFetched: new Date(), results: schedule })
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: nextSerialized({
|
||||||
schedule: nextSerialized(schedule)
|
schedule: schedule,
|
||||||
}
|
parsedAt: parsedAt
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const teachers = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Амукова Светлана Николаевна',
|
name: 'Амукова Светлана Николаевна',
|
||||||
picture: 'https://ks.psuti.ru/images/stories/emp/',
|
picture: 'https://ks.psuti.ru/images/stories/emp/амукова.jpg',
|
||||||
pronouns: 'she'
|
pronouns: 'she'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -36,7 +36,7 @@ export const teachers = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Арефьев Андрей Андреевич',
|
name: 'Арефьев Андрей Андреевич',
|
||||||
picture: 'https://ks.psuti.ru/images/stories/ks-news/2016/prepodavateli-foto/%20class=',
|
picture: 'https://ks.psuti.ru/images/stories/ks-news/2016/prepodavateli-foto/арефьев.jpg',
|
||||||
pronouns: 'he'
|
pronouns: 'he'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -107,7 +107,7 @@ export const teachers = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Ларионова Софья Николаевна',
|
name: 'Ларионова Софья Николаевна',
|
||||||
picture: 'https://ks.psuti.ru/images/stories/ks-news/2016/prepodavateli-foto/.jpg',
|
picture: 'https://ks.psuti.ru/images/stories/ks-news/2016/prepodavateli-foto/larionova.jpg',
|
||||||
pronouns: 'she'
|
pronouns: 'she'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
|
import { AddGroupButton } from '@/features/add-group'
|
||||||
import { ThemeSwitcher } from '@/features/theme-switch'
|
import { ThemeSwitcher } from '@/features/theme-switch'
|
||||||
import { Button } from '@/shadcn/ui/button'
|
import { Button } from '@/shadcn/ui/button'
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import cx from 'classnames'
|
|
||||||
|
|
||||||
export function NavBar() {
|
export function NavBar() {
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme, theme } = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="w-full p-2">
|
<header className="w-full p-2">
|
||||||
<nav className={cx('rounded-lg p-2 w-full flex justify-between', { 'bg-slate-200': resolvedTheme === 'light', 'bg-slate-900': resolvedTheme === 'dark' })}>
|
<nav className={`rounded-lg p-2 w-full flex justify-between ${(resolvedTheme || theme) === 'light' ? 'bg-slate-200' : ''} ${(resolvedTheme || theme) === 'dark' ? 'bg-slate-900' : ''}`}>
|
||||||
<ul className="flex gap-2">
|
<ul className="flex gap-2">
|
||||||
<NavBarItem url="/ps7">ПС-7</NavBarItem>
|
<NavBarItem url="/ps7">ПС-7</NavBarItem>
|
||||||
<NavBarItem url="/pks35k">ПКС-35к</NavBarItem>
|
<NavBarItem url="/pks35k">ПКС-35к</NavBarItem>
|
||||||
|
<AddGroupButton />
|
||||||
</ul>
|
</ul>
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
</nav>
|
</nav>
|
||||||
@@ -30,7 +31,7 @@ function NavBarItem({ url, children }: React.PropsWithChildren<{
|
|||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
<Link href={url}>
|
<Link href={url}>
|
||||||
<Button tabIndex={-1} variant={isActive ? 'default' : 'outline'}>{children}</Button>
|
<Button tabIndex={-1} variant={isActive ? 'default' : 'secondary'}>{children}</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,17 +18,18 @@ export function Day({ day }: {
|
|||||||
.some(lesson => 'subject' in lesson && lesson.subject.length > 20)
|
.some(lesson => 'subject' in lesson && lesson.subject.length > 20)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-3 md:gap-5">
|
||||||
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
|
<h1 className="scroll-m-20 text-2xl md:text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||||
{dayOfWeek} <span className='text-muted ml-3'>{Intl.DateTimeFormat('ru-RU', {
|
{dayOfWeek} <span className='text-border ml-3'>{Intl.DateTimeFormat('ru-RU', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
// year: 'numeric'
|
// year: 'numeric'
|
||||||
}).format(day.date)}</span>
|
}).format(day.date)}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<div className='overflow-hidden'>
|
<div>
|
||||||
<div className='overflow-auto'>
|
<div className='overflow-auto md:snap-x md:snap-proximity md:-translate-x-16 md:w-[calc(100%+8rem)] scrollbar-hide'>
|
||||||
<div className="flex flex-row gap-4 w-max">
|
<div className="flex flex-col md:flex-row gap-4 w-full md:w-max">
|
||||||
|
<div className='snap-start hidden md:block' style={{ flex: '0 0 3rem' }} />
|
||||||
{day.lessons.map((lesson, i) => (
|
{day.lessons.map((lesson, i) => (
|
||||||
<Lesson
|
<Lesson
|
||||||
width={longNames ? 450 : 350}
|
width={longNames ? 450 : 350}
|
||||||
@@ -36,6 +37,7 @@ export function Day({ day }: {
|
|||||||
key={i}
|
key={i}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
<div className='snap-start hidden md:block' style={{ flex: `0 0 calc(100vw - 4rem - ${longNames ? 450 : 350}px - 1rem)` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import type { Day as DayType } from '@/shared/model/day'
|
import type { Day as DayType } from '@/shared/model/day'
|
||||||
import { Day } from '@/widgets/schedule/day'
|
import { Day } from '@/widgets/schedule/day'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
export function Schedule({ days }: {
|
export function Schedule({ days }: {
|
||||||
days: DayType[]
|
days: DayType[]
|
||||||
}) {
|
}) {
|
||||||
|
const group = useRouter().query['group']
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col p-16 gap-14">
|
<div className="flex flex-col p-8 md:p-16 gap-12 md:gap-14">
|
||||||
{days.map((day, i) => (
|
{days.map((day, i) => (
|
||||||
<Day day={day} key={i} />
|
<Day day={day} key={`${group}_day${i}`} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ export function Lesson({ lesson, width = 350 }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`w-[${width}px] min-w-[${width}px] max-w-[${width}px] flex flex-col relative overflow-hidden`} style={{ minWidth: width, maxWidth: width }}>
|
<Card className={`w-full ${width === 450 ? `md:w-[450px] md:min-w-[450px] md:max-w-[450px]` : `md:w-[350px] md:min-w-[350px] md:max-w-[350px]`} flex flex-col relative overflow-hidden snap-start scroll-ml-16 shrink-0`}>
|
||||||
{lesson.isChange && <div className='absolute top-0 left-0 w-full h-full bg-gradient-to-br from-[#ffc60026] to-[#95620026]'></div>}
|
{lesson.isChange && <div className='absolute top-0 left-0 w-full h-full bg-gradient-to-br from-[#ffc60026] to-[#95620026] pointer-events-none'></div>}
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className='flex gap-4'>
|
<div className='flex gap-4'>
|
||||||
{hasTeacher ? (
|
{hasTeacher ? (
|
||||||
@@ -97,7 +97,7 @@ export function Lesson({ lesson, width = 350 }: {
|
|||||||
{lesson.topic ? (
|
{lesson.topic ? (
|
||||||
<span className='leading-relaxed hyphens-auto'>{lesson.topic}</span>
|
<span className='leading-relaxed hyphens-auto'>{lesson.topic}</span>
|
||||||
) : (
|
) : (
|
||||||
!isFallbackDiscipline && <span className='text-muted font-semibold'>Нет описания пары</span>
|
!isFallbackDiscipline && <span className='text-border font-semibold'>Нет описания пары</span>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
{(Boolean(lesson.resources.length) || hasPlace) && (
|
{(Boolean(lesson.resources.length) || hasPlace) && (
|
||||||
|
|||||||
@@ -1,76 +1,76 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
darkMode: ["class"],
|
darkMode: ['class'],
|
||||||
content: [
|
content: [
|
||||||
'./pages/**/*.{ts,tsx}',
|
'./pages/**/*.{ts,tsx}',
|
||||||
'./components/**/*.{ts,tsx}',
|
'./components/**/*.{ts,tsx}',
|
||||||
'./app/**/*.{ts,tsx}',
|
'./app/**/*.{ts,tsx}',
|
||||||
'./src/**/*.{ts,tsx}',
|
'./src/**/*.{ts,tsx}',
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
container: {
|
container: {
|
||||||
center: true,
|
center: true,
|
||||||
padding: "2rem",
|
padding: '2rem',
|
||||||
screens: {
|
screens: {
|
||||||
"2xl": "1400px",
|
'2xl': '1400px',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
border: "hsl(var(--border))",
|
border: 'hsl(var(--border))',
|
||||||
input: "hsl(var(--input))",
|
input: 'hsl(var(--input))',
|
||||||
ring: "hsl(var(--ring))",
|
ring: 'hsl(var(--ring))',
|
||||||
background: "hsl(var(--background))",
|
background: 'hsl(var(--background))',
|
||||||
foreground: "hsl(var(--foreground))",
|
foreground: 'hsl(var(--foreground))',
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: "hsl(var(--primary))",
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
foreground: "hsl(var(--primary-foreground))",
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: "hsl(var(--secondary))",
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
foreground: "hsl(var(--secondary-foreground))",
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
},
|
},
|
||||||
destructive: {
|
destructive: {
|
||||||
DEFAULT: "hsl(var(--destructive))",
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
foreground: "hsl(var(--destructive-foreground))",
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: "hsl(var(--muted))",
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
foreground: "hsl(var(--muted-foreground))",
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: "hsl(var(--accent))",
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
foreground: "hsl(var(--accent-foreground))",
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
},
|
},
|
||||||
popover: {
|
popover: {
|
||||||
DEFAULT: "hsl(var(--popover))",
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
foreground: "hsl(var(--popover-foreground))",
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
DEFAULT: "hsl(var(--card))",
|
DEFAULT: 'hsl(var(--card))',
|
||||||
foreground: "hsl(var(--card-foreground))",
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: 'var(--radius)',
|
||||||
md: "calc(var(--radius) - 2px)",
|
md: 'calc(var(--radius) - 2px)',
|
||||||
sm: "calc(var(--radius) - 4px)",
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
"accordion-down": {
|
'accordion-down': {
|
||||||
from: { height: 0 },
|
from: { height: 0 },
|
||||||
to: { height: "var(--radix-accordion-content-height)" },
|
to: { height: 'var(--radix-accordion-content-height)' },
|
||||||
},
|
},
|
||||||
"accordion-up": {
|
'accordion-up': {
|
||||||
from: { height: "var(--radix-accordion-content-height)" },
|
from: { height: 'var(--radix-accordion-content-height)' },
|
||||||
to: { height: 0 },
|
to: { height: 0 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [require('tailwindcss-animate'), require('tailwind-scrollbar-hide')],
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user