feat: добавлена навигация по неделям с возможностью отключения через админ-панель

feat: добавлена навигация по неделям с возможностью отключения через админ-панель

Реализована навигация по неделям в расписании с парсингом ссылок из HTML страницы
оригинального сайта. Добавлена возможность управления навигацией через админ-панель
с сохранением настроек в файл.

Основные изменения:

- Парсинг навигации по неделям:
  * Добавлены типы WeekInfo и ParseResult в парсер
  * Реализована функция parseWeekNavigation для извлечения ссылок с параметром wk
  * Парсер ищет ссылки в href, onclick, формах и других атрибутах
  * Автоматическое определение номеров недель из текста ссылок и контекста
  * Вычисление соседних недель на основе найденных данных

- API и функции:
  * Обновлена функция getSchedule для поддержки параметра wk в URL
  * Обновлен getServerSideProps для чтения параметра wk из query string
  * Кэширование расписания с учетом недели (ключ включает group + wk)

- Компоненты:
  * Создан компонент WeekNavigation с кнопками навигации
  * Интегрирована навигация в компонент Schedule
  * Навигация работает через изменение URL параметра wk

- Система настроек:
  * Создан settings-loader для загрузки/сохранения настроек в JSON
  * Добавлен API endpoint /api/admin/settings для управления настройками
  * Добавлен переключатель в админ-панели для включения/выключения навигации
  * Настройки сохраняются в src/shared/data/settings.json и переживают перезапуски

- Файлы:
  * src/app/parser/schedule.ts - парсинг навигации по неделям
  * src/app/agregator/schedule.ts - поддержка параметра wk
  * src/pages/[group].tsx - чтение wk из query и передача настроек
  * src/widgets/schedule/week-navigation.tsx - компонент навигации
  * src/widgets/schedule/index.tsx - интеграция навигации
  * src/pages/admin.tsx - управление настройками
  * src/shared/data/settings-loader.ts - загрузка/сохранение настроек
  * src/pages/api/admin/settings.ts - API для настроек
  * src/shared/data/settings.json - файл с настройками
This commit is contained in:
kilyabin
2025-11-23 02:24:27 +04:00
parent cf0137a8d6
commit 2893a9fd18
10 changed files with 681 additions and 27 deletions

View File

@@ -1,16 +1,18 @@
import { Schedule } from '@/widgets/schedule'
import { Day } from '@/shared/model/day'
import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'
import { getSchedule } from '@/app/agregator/schedule'
import { getSchedule, ScheduleResult } from '@/app/agregator/schedule'
import { NextSerialized, nextDeserialized, nextSerialized } from '@/app/utils/date-serializer'
import { NavBar } from '@/widgets/navbar'
import { LastUpdateAt } from '@/entities/last-update-at'
import { loadGroups, GroupsData } from '@/shared/data/groups-loader'
import { loadSettings, AppSettings } from '@/shared/data/settings-loader'
import { SITE_URL } from '@/shared/constants/urls'
import crypto from 'crypto'
import React from 'react'
import { getDayOfWeek } from '@/shared/utils'
import Head from 'next/head'
import { WeekInfo } from '@/app/parser/schedule'
type PageProps = {
schedule: Day[]
@@ -21,10 +23,13 @@ type PageProps = {
parsedAt: Date
cacheAvailableFor: string[]
groups: GroupsData
currentWk: number | null
availableWeeks: WeekInfo[] | null
settings: AppSettings
}
export default function HomePage(props: NextSerialized<PageProps>) {
const { schedule, group, cacheAvailableFor, parsedAt, groups } = nextDeserialized<PageProps>(props)
const { schedule, group, cacheAvailableFor, parsedAt, groups, currentWk, availableWeeks, settings } = nextDeserialized<PageProps>(props)
React.useEffect(() => {
if (typeof window === 'undefined') return
@@ -34,10 +39,30 @@ export default function HomePage(props: NextSerialized<PageProps>) {
history.scrollRestoration = 'auto'
}
// Отключаем автоматическую прокрутку на мобильных, чтобы избежать зависаний
// Пользователь может прокрутить страницу вручную
// Автоматическая прокрутка может блокировать рендеринг и вызывать зависания
}, [])
let attempts = 0
const MAX_ATTEMPTS = 50 // Максимум 5 секунд (50 * 100ms)
const interval = setInterval(() => {
attempts++
const today = getDayOfWeek(new Date())
const todayBlock = document.getElementById(today)
if (todayBlock) {
const GAP = 48
const HEADER_HEIGHT = 64
window.scrollTo({ top: todayBlock.offsetTop - GAP - HEADER_HEIGHT, behavior: 'smooth' })
clearInterval(interval)
} else if (attempts >= MAX_ATTEMPTS) {
// Прекращаем попытки после максимального количества
clearInterval(interval)
}
}, 100)
// Cleanup функция для очистки интервала при размонтировании
return () => {
clearInterval(interval)
}
}, [schedule])
return (
<>
@@ -50,39 +75,48 @@ export default function HomePage(props: NextSerialized<PageProps>) {
</Head>
<NavBar cacheAvailableFor={cacheAvailableFor} groups={groups} />
<LastUpdateAt date={parsedAt} />
<Schedule days={schedule} />
<Schedule days={schedule} currentWk={currentWk} availableWeeks={availableWeeks} weekNavigationEnabled={settings.weekNavigationEnabled} />
</>
)
}
const cachedSchedules = new Map<string, { lastFetched: Date, results: Day[] }>()
const cachedSchedules = new Map<string, { lastFetched: Date, results: ScheduleResult }>()
const maxCacheDurationInMS = 1000 * 60 * 60
export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise<GetServerSidePropsResult<NextSerialized<PageProps>>> {
const groups = loadGroups()
const settings = loadSettings()
const group = context.params?.group
const wkParam = context.query.wk
const wk = wkParam ? Number(wkParam) : undefined
if (group && Object.hasOwn(groups, group) && group in groups) {
let schedule
let scheduleResult: ScheduleResult
let parsedAt
const cachedSchedule = cachedSchedules.get(group)
// Ключ кэша включает группу и неделю
const cacheKey = wk ? `${group}_${wk}` : group
const cachedSchedule = cachedSchedules.get(cacheKey)
if (cachedSchedule?.lastFetched && Date.now() - cachedSchedule.lastFetched.getTime() < maxCacheDurationInMS) {
schedule = cachedSchedule.results
scheduleResult = cachedSchedule.results
parsedAt = cachedSchedule.lastFetched
} else {
try {
const groupInfo = groups[group]
schedule = await getSchedule(groupInfo.parseId, groupInfo.name)
scheduleResult = await getSchedule(groupInfo.parseId, groupInfo.name, wk)
parsedAt = new Date()
cachedSchedules.set(group, { lastFetched: new Date(), results: schedule })
cachedSchedules.set(cacheKey, { lastFetched: new Date(), results: scheduleResult })
} catch(e) {
if (cachedSchedule?.lastFetched) {
schedule = cachedSchedule.results
scheduleResult = cachedSchedule.results
parsedAt = cachedSchedule.lastFetched
} else {
throw e
}
}
}
const schedule = scheduleResult.days
const getSha256Hash = (input: string) => {
const hash = crypto.createHash('sha256')
@@ -103,7 +137,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
const cacheAvailableFor = Array.from(cachedSchedules.entries())
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
.map(([k]) => k)
.map(([k]) => k.split('_')[0]) // Берем только группу из ключа кэша
context.res.setHeader('ETag', `"${etag}"`)
return {
@@ -115,7 +149,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
name: groups[group].name
},
cacheAvailableFor,
groups
groups,
currentWk: scheduleResult.currentWk ?? null,
availableWeeks: scheduleResult.availableWeeks ?? null,
settings
})
}
} else {