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:
@@ -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 {
|
||||
|
||||
@@ -13,19 +13,22 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/shadcn/ui/dialog'
|
||||
import { loadGroups, GroupsData } from '@/shared/data/groups-loader'
|
||||
import { loadSettings, AppSettings } from '@/shared/data/settings-loader'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shadcn/ui/select'
|
||||
import Head from 'next/head'
|
||||
|
||||
type AdminPageProps = {
|
||||
groups: GroupsData
|
||||
settings: AppSettings
|
||||
}
|
||||
|
||||
export default function AdminPage({ groups: initialGroups }: AdminPageProps) {
|
||||
export default function AdminPage({ groups: initialGroups, settings: initialSettings }: AdminPageProps) {
|
||||
const [authenticated, setAuthenticated] = React.useState<boolean | null>(null)
|
||||
const [password, setPassword] = React.useState('')
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [groups, setGroups] = React.useState<GroupsData>(initialGroups)
|
||||
const [settings, setSettings] = React.useState<AppSettings>(initialSettings)
|
||||
const [editingGroup, setEditingGroup] = React.useState<{ id: string; parseId: number; name: string; course: number } | null>(null)
|
||||
const [showAddDialog, setShowAddDialog] = React.useState(false)
|
||||
const [showEditDialog, setShowEditDialog] = React.useState(false)
|
||||
@@ -72,8 +75,9 @@ export default function AdminPage({ groups: initialGroups }: AdminPageProps) {
|
||||
if (res.ok && data.success) {
|
||||
setAuthenticated(true)
|
||||
setPassword('')
|
||||
// Обновляем список групп после авторизации
|
||||
// Обновляем список групп и настроек после авторизации
|
||||
await loadGroupsList()
|
||||
await loadSettingsList()
|
||||
} else {
|
||||
setError(data.error || 'Ошибка авторизации')
|
||||
}
|
||||
@@ -96,6 +100,43 @@ export default function AdminPage({ groups: initialGroups }: AdminPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const loadSettingsList = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings')
|
||||
const data = await res.json()
|
||||
if (data.settings) {
|
||||
setSettings(data.settings)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading settings:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateSettings = async (newSettings: AppSettings) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newSettings)
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (res.ok && data.success) {
|
||||
setSettings(data.settings)
|
||||
} else {
|
||||
setError(data.error || 'Ошибка при обновлении настроек')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Ошибка соединения с сервером')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddGroup = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
@@ -287,6 +328,35 @@ export default function AdminPage({ groups: initialGroups }: AdminPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Настройки</CardTitle>
|
||||
<CardDescription>Управление настройками приложения</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<div className="font-semibold">Навигация по неделям</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Включить или выключить навигацию по неделям в расписании
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.weekNavigationEnabled}
|
||||
onChange={(e) => handleUpdateSettings({ ...settings, weekNavigationEnabled: e.target.checked })}
|
||||
disabled={loading}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -516,9 +586,11 @@ export default function AdminPage({ groups: initialGroups }: AdminPageProps) {
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<AdminPageProps> = async () => {
|
||||
const groups = loadGroups()
|
||||
const settings = loadSettings()
|
||||
return {
|
||||
props: {
|
||||
groups
|
||||
groups,
|
||||
settings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
54
src/pages/api/admin/settings.ts
Normal file
54
src/pages/api/admin/settings.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { requireAuth } from '@/shared/utils/auth'
|
||||
import { loadSettings, saveSettings, AppSettings } from '@/shared/data/settings-loader'
|
||||
|
||||
type ResponseData = {
|
||||
settings?: AppSettings
|
||||
success?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>
|
||||
) {
|
||||
if (req.method === 'GET') {
|
||||
// Получение настроек
|
||||
const settings = loadSettings()
|
||||
res.status(200).json({ settings })
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
// Обновление настроек
|
||||
const { weekNavigationEnabled } = req.body
|
||||
|
||||
if (typeof weekNavigationEnabled !== 'boolean') {
|
||||
res.status(400).json({ error: 'weekNavigationEnabled must be a boolean' })
|
||||
return
|
||||
}
|
||||
|
||||
const settings: AppSettings = {
|
||||
weekNavigationEnabled
|
||||
}
|
||||
|
||||
try {
|
||||
saveSettings(settings)
|
||||
res.status(200).json({ success: true, settings })
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error)
|
||||
res.status(500).json({ error: 'Failed to save settings' })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
res.status(405).json({ error: 'Method not allowed' })
|
||||
}
|
||||
|
||||
export default function protectedHandler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>
|
||||
) {
|
||||
return requireAuth(req, res, handler)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user