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,21 +1,33 @@
|
|||||||
import { Day } from '@/shared/model/day'
|
import { Day } from '@/shared/model/day'
|
||||||
import { parsePage } from '@/app/parser/schedule'
|
import { parsePage, ParseResult, WeekInfo } from '@/app/parser/schedule'
|
||||||
import contentTypeParser from 'content-type'
|
import contentTypeParser from 'content-type'
|
||||||
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'
|
import { reportParserError } from '@/app/logger'
|
||||||
import { PROXY_URL } from '@/shared/constants/urls'
|
import { PROXY_URL } from '@/shared/constants/urls'
|
||||||
|
|
||||||
|
export type ScheduleResult = {
|
||||||
|
days: Day[]
|
||||||
|
currentWk?: number
|
||||||
|
availableWeeks?: WeekInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
// ПС-7: 146
|
// ПС-7: 146
|
||||||
export async function getSchedule(groupID: number, groupName: string): Promise<Day[]> {
|
export async function getSchedule(groupID: number, groupName: string, wk?: number): Promise<ScheduleResult> {
|
||||||
const page = await fetch(`${PROXY_URL}/?mn=2&obj=${groupID}`)
|
const url = `${PROXY_URL}/?mn=2&obj=${groupID}${wk ? `&wk=${wk}` : ''}`
|
||||||
|
const page = await fetch(url)
|
||||||
// const page = { text: async () => mockContent, status: 200, headers: { get: (s: string) => s && 'text/html' } }
|
// const page = { text: async () => mockContent, status: 200, headers: { get: (s: string) => s && 'text/html' } }
|
||||||
const content = await page.text()
|
const content = await page.text()
|
||||||
const contentType = page.headers.get('content-type')
|
const contentType = page.headers.get('content-type')
|
||||||
if (page.status === 200 && contentType && contentTypeParser.parse(contentType).type === 'text/html') {
|
if (page.status === 200 && contentType && contentTypeParser.parse(contentType).type === 'text/html') {
|
||||||
try {
|
try {
|
||||||
const root = new JSDOM(content).window.document
|
const root = new JSDOM(content, { url }).window.document
|
||||||
return parsePage(root, groupName)
|
const result = parsePage(root, groupName, url)
|
||||||
|
return {
|
||||||
|
days: result.days,
|
||||||
|
currentWk: result.currentWk || wk,
|
||||||
|
availableWeeks: result.availableWeeks
|
||||||
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error(`Error while parsing ${PROXY_URL}`)
|
console.error(`Error while parsing ${PROXY_URL}`)
|
||||||
reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName)
|
reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName)
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { Day } from '@/shared/model/day'
|
import { Day } from '@/shared/model/day'
|
||||||
import { Lesson } from '@/shared/model/lesson'
|
import { Lesson } from '@/shared/model/lesson'
|
||||||
|
|
||||||
|
export type WeekInfo = {
|
||||||
|
wk: number
|
||||||
|
weekNumber: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParseResult = {
|
||||||
|
days: Day[]
|
||||||
|
currentWk?: number
|
||||||
|
availableWeeks?: WeekInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
const dayTitleParser = (text: string) => {
|
const dayTitleParser = (text: string) => {
|
||||||
const [dateString, week] = text.trim().split(' / ')
|
const [dateString, week] = text.trim().split(' / ')
|
||||||
const weekNumber = Number(week.trim().match(/^(\d+) неделя$/)![1])
|
const weekNumber = Number(week.trim().match(/^(\d+) неделя$/)![1])
|
||||||
@@ -9,6 +20,228 @@ const dayTitleParser = (text: string) => {
|
|||||||
return { date, weekNumber }
|
return { date, weekNumber }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит ссылки навигации по неделям из HTML страницы
|
||||||
|
* Ищет ссылки вида ?mn=2&obj=XXX&wk=YYY и извлекает wk и weekNumber
|
||||||
|
*/
|
||||||
|
function parseWeekNavigation(document: Document, currentWeekNumber: number, currentWk?: number): WeekInfo[] {
|
||||||
|
const weeks: WeekInfo[] = []
|
||||||
|
const wkToWeekNumber = new Map<number, number>()
|
||||||
|
|
||||||
|
// Ищем все ссылки, которые содержат параметр wk
|
||||||
|
const links = Array.from(document.querySelectorAll('a[href*="wk="]'))
|
||||||
|
|
||||||
|
// Также ищем ссылки в onclick и других атрибутах
|
||||||
|
const linksWithOnclick = Array.from(document.querySelectorAll('a[onclick*="wk="], a[onclick*="wk"]'))
|
||||||
|
|
||||||
|
// Ищем в формах
|
||||||
|
const forms = Array.from(document.querySelectorAll('form[action*="wk="], form input[name="wk"]'))
|
||||||
|
|
||||||
|
// Ищем во всех элементах, которые могут содержать URL с wk
|
||||||
|
const allElements = Array.from(document.querySelectorAll('*'))
|
||||||
|
const elementsWithWk: Element[] = []
|
||||||
|
|
||||||
|
for (const el of allElements) {
|
||||||
|
const href = el.getAttribute('href')
|
||||||
|
const onclick = el.getAttribute('onclick')
|
||||||
|
const action = el.getAttribute('action')
|
||||||
|
const dataHref = el.getAttribute('data-href')
|
||||||
|
|
||||||
|
if ((href && href.includes('wk=')) ||
|
||||||
|
(onclick && onclick.includes('wk=')) ||
|
||||||
|
(action && action.includes('wk=')) ||
|
||||||
|
(dataHref && dataHref.includes('wk='))) {
|
||||||
|
elementsWithWk.push(el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Объединяем все найденные элементы
|
||||||
|
const allLinkElements = [...links, ...linksWithOnclick, ...elementsWithWk]
|
||||||
|
|
||||||
|
for (const link of allLinkElements) {
|
||||||
|
// Пробуем извлечь wk из разных атрибутов
|
||||||
|
const href = link.getAttribute('href')
|
||||||
|
const onclick = link.getAttribute('onclick')
|
||||||
|
const action = link.getAttribute('action')
|
||||||
|
const dataHref = link.getAttribute('data-href')
|
||||||
|
|
||||||
|
const urlString = href || onclick || action || dataHref || ''
|
||||||
|
if (!urlString) continue
|
||||||
|
|
||||||
|
// Парсим URL вида ?mn=2&obj=145&wk=308 или /?mn=2&obj=145&wk=308
|
||||||
|
const wkMatch = urlString.match(/[?&]wk=(\d+)/)
|
||||||
|
if (wkMatch) {
|
||||||
|
const wk = Number(wkMatch[1])
|
||||||
|
|
||||||
|
// Пытаемся найти номер недели из текста ссылки
|
||||||
|
const linkText = link.textContent?.trim() || ''
|
||||||
|
const parentText = link.parentElement?.textContent?.trim() || ''
|
||||||
|
const combinedText = `${linkText} ${parentText}`
|
||||||
|
|
||||||
|
// Ищем номер недели в тексте
|
||||||
|
const weekNumberMatch = combinedText.match(/(\d+)\s*недел/i)
|
||||||
|
let weekNumber = weekNumberMatch ? Number(weekNumberMatch[1]) : undefined
|
||||||
|
|
||||||
|
// Если не нашли в тексте, пытаемся определить по контексту
|
||||||
|
if (!weekNumber) {
|
||||||
|
// Проверяем, есть ли указание на "следующую" или "предыдущую" неделю
|
||||||
|
const isNext = /следующ/i.test(combinedText) || /вперёд/i.test(combinedText) || /next/i.test(combinedText) || /→/i.test(combinedText)
|
||||||
|
const isPrev = /предыдущ/i.test(combinedText) || /назад/i.test(combinedText) || /prev/i.test(combinedText) || /←/i.test(combinedText)
|
||||||
|
|
||||||
|
if (isNext && currentWeekNumber) {
|
||||||
|
weekNumber = currentWeekNumber + 1
|
||||||
|
} else if (isPrev && currentWeekNumber) {
|
||||||
|
weekNumber = currentWeekNumber - 1
|
||||||
|
} else {
|
||||||
|
// Если не можем определить, используем текущий номер недели как fallback
|
||||||
|
weekNumber = currentWeekNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем связь wk -> weekNumber
|
||||||
|
if (!wkToWeekNumber.has(wk)) {
|
||||||
|
wkToWeekNumber.set(wk, weekNumber)
|
||||||
|
weeks.push({ wk, weekNumber })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обрабатываем формы
|
||||||
|
for (const form of forms) {
|
||||||
|
if (form instanceof HTMLFormElement) {
|
||||||
|
const action = form.getAttribute('action') || ''
|
||||||
|
const wkMatch = action.match(/[?&]wk=(\d+)/)
|
||||||
|
if (wkMatch) {
|
||||||
|
const wk = Number(wkMatch[1])
|
||||||
|
if (!wkToWeekNumber.has(wk)) {
|
||||||
|
// Пытаемся найти номер недели в форме
|
||||||
|
const formText = form.textContent?.trim() || ''
|
||||||
|
const weekNumberMatch = formText.match(/(\d+)\s*недел/i)
|
||||||
|
const weekNumber = weekNumberMatch ? Number(weekNumberMatch[1]) : currentWeekNumber
|
||||||
|
wkToWeekNumber.set(wk, weekNumber)
|
||||||
|
weeks.push({ wk, weekNumber })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (form instanceof HTMLInputElement) {
|
||||||
|
const value = form.value
|
||||||
|
if (value) {
|
||||||
|
const wk = Number(value)
|
||||||
|
if (!isNaN(wk) && !wkToWeekNumber.has(wk)) {
|
||||||
|
const weekNumber = currentWeekNumber
|
||||||
|
wkToWeekNumber.set(wk, weekNumber)
|
||||||
|
weeks.push({ wk, weekNumber })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если currentWk не определен, но нашли недели, пытаемся определить текущую
|
||||||
|
if (!currentWk && weeks.length > 0) {
|
||||||
|
// Ищем неделю с weekNumber равным currentWeekNumber
|
||||||
|
const currentWeekInList = weeks.find(w => w.weekNumber === currentWeekNumber)
|
||||||
|
if (currentWeekInList) {
|
||||||
|
// Используем найденную неделю как текущую
|
||||||
|
currentWk = currentWeekInList.wk
|
||||||
|
} else {
|
||||||
|
// Если не нашли точное совпадение, но есть недели с соседними номерами,
|
||||||
|
// пытаемся определить текущую на основе позиции
|
||||||
|
const sortedByWeekNumber = [...weeks].sort((a, b) => a.weekNumber - b.weekNumber)
|
||||||
|
const currentIndex = sortedByWeekNumber.findIndex(w => w.weekNumber === currentWeekNumber)
|
||||||
|
|
||||||
|
if (currentIndex < 0 && sortedByWeekNumber.length > 0) {
|
||||||
|
// Если текущая неделя не найдена, но есть соседние, вычисляем
|
||||||
|
const firstWeek = sortedByWeekNumber[0]
|
||||||
|
if (firstWeek.weekNumber === currentWeekNumber + 1) {
|
||||||
|
// Первая найденная - следующая, значит текущая должна быть на 1 меньше по wk
|
||||||
|
// Но мы не знаем разницу, поэтому используем первую найденную как следующую
|
||||||
|
} else if (firstWeek.weekNumber === currentWeekNumber - 1) {
|
||||||
|
// Первая найденная - предыдущая, значит текущая должна быть на 1 больше по wk
|
||||||
|
// Вычисляем текущую неделю
|
||||||
|
const wkDiff = sortedByWeekNumber.length > 1
|
||||||
|
? sortedByWeekNumber[1].wk - firstWeek.wk
|
||||||
|
: 1 // Предполагаем разницу в 1
|
||||||
|
currentWk = firstWeek.wk + wkDiff
|
||||||
|
weeks.push({ wk: currentWk, weekNumber: currentWeekNumber })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Всегда добавляем текущую неделю, если она еще не добавлена
|
||||||
|
if (currentWk && !weeks.find(w => w.wk === currentWk)) {
|
||||||
|
weeks.push({ wk: currentWk, weekNumber: currentWeekNumber })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если нашли только одну соседнюю неделю, пытаемся вычислить другую
|
||||||
|
if (weeks.length === 1 && currentWk && currentWeekNumber) {
|
||||||
|
const foundWeek = weeks[0]
|
||||||
|
|
||||||
|
// Если найденная неделя - следующая, пытаемся вычислить предыдущую
|
||||||
|
if (foundWeek.weekNumber === currentWeekNumber + 1) {
|
||||||
|
if (!weeks.find(w => w.wk === currentWk)) {
|
||||||
|
weeks.push({ wk: currentWk, weekNumber: currentWeekNumber })
|
||||||
|
}
|
||||||
|
// Вычисляем wk для предыдущей недели на основе разницы
|
||||||
|
const wkDiff = foundWeek.wk - currentWk
|
||||||
|
if (wkDiff !== 0) {
|
||||||
|
const estimatedPrevWk = currentWk - wkDiff
|
||||||
|
if (estimatedPrevWk > 0 && !weeks.find(w => w.wk === estimatedPrevWk)) {
|
||||||
|
weeks.push({ wk: estimatedPrevWk, weekNumber: currentWeekNumber - 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Если найденная неделя - предыдущая, пытаемся вычислить следующую
|
||||||
|
else if (foundWeek.weekNumber === currentWeekNumber - 1) {
|
||||||
|
if (!weeks.find(w => w.wk === currentWk)) {
|
||||||
|
weeks.push({ wk: currentWk, weekNumber: currentWeekNumber })
|
||||||
|
}
|
||||||
|
// Вычисляем wk для следующей недели на основе разницы
|
||||||
|
const wkDiff = currentWk - foundWeek.wk
|
||||||
|
if (wkDiff !== 0) {
|
||||||
|
const estimatedNextWk = currentWk + wkDiff
|
||||||
|
if (estimatedNextWk > 0 && !weeks.find(w => w.wk === estimatedNextWk)) {
|
||||||
|
weeks.push({ wk: estimatedNextWk, weekNumber: currentWeekNumber + 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Если это текущая неделя, пытаемся найти соседние
|
||||||
|
else if (foundWeek.wk === currentWk) {
|
||||||
|
// Уже есть текущая неделя, ничего не делаем
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если нашли несколько недель, но нет текущей, добавляем её
|
||||||
|
if (weeks.length > 0 && currentWk && !weeks.find(w => w.wk === currentWk)) {
|
||||||
|
weeks.push({ wk: currentWk, weekNumber: currentWeekNumber })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если нашли недели, но не можем определить их weekNumber точно,
|
||||||
|
// пытаемся вычислить на основе разницы в wk
|
||||||
|
if (weeks.length > 1 && currentWk && currentWeekNumber) {
|
||||||
|
const currentWeekInList = weeks.find(w => w.wk === currentWk)
|
||||||
|
if (currentWeekInList) {
|
||||||
|
// Сортируем по wk и пытаемся определить weekNumber для недель без него
|
||||||
|
const sortedByWk = [...weeks].sort((a, b) => a.wk - b.wk)
|
||||||
|
const currentIndex = sortedByWk.findIndex(w => w.wk === currentWk)
|
||||||
|
|
||||||
|
if (currentIndex >= 0) {
|
||||||
|
for (let i = 0; i < sortedByWk.length; i++) {
|
||||||
|
const week = sortedByWk[i]
|
||||||
|
const weekInResult = weeks.find(w => w.wk === week.wk)
|
||||||
|
if (weekInResult && weekInResult.weekNumber === currentWeekNumber) {
|
||||||
|
// Если weekNumber совпадает с текущим, но это не текущая неделя,
|
||||||
|
// пересчитываем на основе позиции
|
||||||
|
const diff = i - currentIndex
|
||||||
|
weekInResult.weekNumber = currentWeekNumber + diff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return weeks.sort((a, b) => a.weekNumber - b.weekNumber)
|
||||||
|
}
|
||||||
|
|
||||||
const parseLesson = (row: Element): Lesson | null => {
|
const parseLesson = (row: Element): Lesson | null => {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
@@ -99,7 +332,7 @@ const parseLesson = (row: Element): Lesson | null => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parsePage(document: Document, groupName: string): Day[] {
|
export function parsePage(document: Document, groupName: string, url?: string): ParseResult {
|
||||||
const tables = Array.from(document.querySelectorAll('body > table'))
|
const tables = Array.from(document.querySelectorAll('body > table'))
|
||||||
const table = tables.find(table => table.querySelector(':scope > tbody > tr:first-child')?.textContent?.trim() === groupName)
|
const table = tables.find(table => table.querySelector(':scope > tbody > tr:first-child')?.textContent?.trim() === groupName)
|
||||||
const rows = Array.from(table!.children[0].children).filter(el => el.tagName === 'TR').slice(2)
|
const rows = Array.from(table!.children[0].children).filter(el => el.tagName === 'TR').slice(2)
|
||||||
@@ -110,6 +343,13 @@ export function parsePage(document: Document, groupName: string): Day[] {
|
|||||||
let dayInfo: Day = {}
|
let dayInfo: Day = {}
|
||||||
let dayLessons: Lesson[] = []
|
let dayLessons: Lesson[] = []
|
||||||
let previousRowIsDayTitle = false
|
let previousRowIsDayTitle = false
|
||||||
|
let currentWeekNumber: number | undefined
|
||||||
|
|
||||||
|
// Пытаемся извлечь текущий wk из URL
|
||||||
|
const currentUrl = url || document.location?.href || ''
|
||||||
|
const wkMatch = currentUrl.match(/[?&]wk=(\d+)/)
|
||||||
|
const currentWk = wkMatch ? Number(wkMatch[1]) : undefined
|
||||||
|
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const row = rows[i]
|
const row = rows[i]
|
||||||
|
|
||||||
@@ -131,6 +371,9 @@ export function parsePage(document: Document, groupName: string): Day[] {
|
|||||||
const { date, weekNumber } = dayTitleParser(row.querySelector('h3')!.textContent!)
|
const { date, weekNumber } = dayTitleParser(row.querySelector('h3')!.textContent!)
|
||||||
dayInfo.date = date
|
dayInfo.date = date
|
||||||
dayInfo.weekNumber = weekNumber
|
dayInfo.weekNumber = weekNumber
|
||||||
|
if (!currentWeekNumber) {
|
||||||
|
currentWeekNumber = weekNumber
|
||||||
|
}
|
||||||
previousRowIsDayTitle = true
|
previousRowIsDayTitle = true
|
||||||
} else {
|
} else {
|
||||||
const lesson = parseLesson(row)
|
const lesson = parseLesson(row)
|
||||||
@@ -139,5 +382,34 @@ export function parsePage(document: Document, groupName: string): Day[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return days
|
// Парсим навигацию по неделям
|
||||||
|
let availableWeeks: WeekInfo[] | undefined
|
||||||
|
let finalCurrentWk = currentWk
|
||||||
|
|
||||||
|
if (currentWeekNumber) {
|
||||||
|
availableWeeks = parseWeekNavigation(document, currentWeekNumber, currentWk)
|
||||||
|
|
||||||
|
// Если не нашли ссылки, но есть текущий wk, добавляем текущую неделю
|
||||||
|
if (availableWeeks.length === 0 && currentWk) {
|
||||||
|
availableWeeks.push({ wk: currentWk, weekNumber: currentWeekNumber })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если currentWk не определен, но нашли недели, пытаемся определить текущую
|
||||||
|
if (!currentWk && availableWeeks.length > 0) {
|
||||||
|
// Ищем неделю с weekNumber равным currentWeekNumber
|
||||||
|
const currentWeekInList = availableWeeks.find(w => w.weekNumber === currentWeekNumber)
|
||||||
|
if (currentWeekInList) {
|
||||||
|
finalCurrentWk = currentWeekInList.wk
|
||||||
|
} else {
|
||||||
|
// Если не нашли точное совпадение, берем первую неделю как текущую
|
||||||
|
finalCurrentWk = availableWeeks[0].wk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
days,
|
||||||
|
currentWk: finalCurrentWk,
|
||||||
|
availableWeeks
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import { Schedule } from '@/widgets/schedule'
|
import { Schedule } from '@/widgets/schedule'
|
||||||
import { Day } from '@/shared/model/day'
|
import { Day } from '@/shared/model/day'
|
||||||
import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'
|
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 { NextSerialized, nextDeserialized, nextSerialized } from '@/app/utils/date-serializer'
|
||||||
import { NavBar } from '@/widgets/navbar'
|
import { NavBar } from '@/widgets/navbar'
|
||||||
import { LastUpdateAt } from '@/entities/last-update-at'
|
import { LastUpdateAt } from '@/entities/last-update-at'
|
||||||
import { loadGroups, GroupsData } from '@/shared/data/groups-loader'
|
import { loadGroups, GroupsData } from '@/shared/data/groups-loader'
|
||||||
|
import { loadSettings, AppSettings } from '@/shared/data/settings-loader'
|
||||||
import { SITE_URL } from '@/shared/constants/urls'
|
import { SITE_URL } from '@/shared/constants/urls'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { getDayOfWeek } from '@/shared/utils'
|
import { getDayOfWeek } from '@/shared/utils'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
|
import { WeekInfo } from '@/app/parser/schedule'
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
schedule: Day[]
|
schedule: Day[]
|
||||||
@@ -21,10 +23,13 @@ type PageProps = {
|
|||||||
parsedAt: Date
|
parsedAt: Date
|
||||||
cacheAvailableFor: string[]
|
cacheAvailableFor: string[]
|
||||||
groups: GroupsData
|
groups: GroupsData
|
||||||
|
currentWk: number | null
|
||||||
|
availableWeeks: WeekInfo[] | null
|
||||||
|
settings: AppSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage(props: NextSerialized<PageProps>) {
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
@@ -34,10 +39,30 @@ export default function HomePage(props: NextSerialized<PageProps>) {
|
|||||||
history.scrollRestoration = 'auto'
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -50,39 +75,48 @@ export default function HomePage(props: NextSerialized<PageProps>) {
|
|||||||
</Head>
|
</Head>
|
||||||
<NavBar cacheAvailableFor={cacheAvailableFor} groups={groups} />
|
<NavBar cacheAvailableFor={cacheAvailableFor} groups={groups} />
|
||||||
<LastUpdateAt date={parsedAt} />
|
<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
|
const maxCacheDurationInMS = 1000 * 60 * 60
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise<GetServerSidePropsResult<NextSerialized<PageProps>>> {
|
export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise<GetServerSidePropsResult<NextSerialized<PageProps>>> {
|
||||||
const groups = loadGroups()
|
const groups = loadGroups()
|
||||||
|
const settings = loadSettings()
|
||||||
const group = context.params?.group
|
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) {
|
if (group && Object.hasOwn(groups, group) && group in groups) {
|
||||||
let schedule
|
let scheduleResult: ScheduleResult
|
||||||
let parsedAt
|
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) {
|
if (cachedSchedule?.lastFetched && Date.now() - cachedSchedule.lastFetched.getTime() < maxCacheDurationInMS) {
|
||||||
schedule = cachedSchedule.results
|
scheduleResult = cachedSchedule.results
|
||||||
parsedAt = cachedSchedule.lastFetched
|
parsedAt = cachedSchedule.lastFetched
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const groupInfo = groups[group]
|
const groupInfo = groups[group]
|
||||||
schedule = await getSchedule(groupInfo.parseId, groupInfo.name)
|
scheduleResult = await getSchedule(groupInfo.parseId, groupInfo.name, wk)
|
||||||
parsedAt = new Date()
|
parsedAt = new Date()
|
||||||
cachedSchedules.set(group, { lastFetched: new Date(), results: schedule })
|
cachedSchedules.set(cacheKey, { lastFetched: new Date(), results: scheduleResult })
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
if (cachedSchedule?.lastFetched) {
|
if (cachedSchedule?.lastFetched) {
|
||||||
schedule = cachedSchedule.results
|
scheduleResult = cachedSchedule.results
|
||||||
parsedAt = cachedSchedule.lastFetched
|
parsedAt = cachedSchedule.lastFetched
|
||||||
} else {
|
} else {
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const schedule = scheduleResult.days
|
||||||
|
|
||||||
const getSha256Hash = (input: string) => {
|
const getSha256Hash = (input: string) => {
|
||||||
const hash = crypto.createHash('sha256')
|
const hash = crypto.createHash('sha256')
|
||||||
@@ -103,7 +137,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
|
|||||||
|
|
||||||
const cacheAvailableFor = Array.from(cachedSchedules.entries())
|
const cacheAvailableFor = Array.from(cachedSchedules.entries())
|
||||||
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
|
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
|
||||||
.map(([k]) => k)
|
.map(([k]) => k.split('_')[0]) // Берем только группу из ключа кэша
|
||||||
|
|
||||||
context.res.setHeader('ETag', `"${etag}"`)
|
context.res.setHeader('ETag', `"${etag}"`)
|
||||||
return {
|
return {
|
||||||
@@ -115,7 +149,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
|
|||||||
name: groups[group].name
|
name: groups[group].name
|
||||||
},
|
},
|
||||||
cacheAvailableFor,
|
cacheAvailableFor,
|
||||||
groups
|
groups,
|
||||||
|
currentWk: scheduleResult.currentWk ?? null,
|
||||||
|
availableWeeks: scheduleResult.availableWeeks ?? null,
|
||||||
|
settings
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -13,19 +13,22 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/shadcn/ui/dialog'
|
} from '@/shadcn/ui/dialog'
|
||||||
import { loadGroups, GroupsData } from '@/shared/data/groups-loader'
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shadcn/ui/select'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
|
|
||||||
type AdminPageProps = {
|
type AdminPageProps = {
|
||||||
groups: GroupsData
|
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 [authenticated, setAuthenticated] = React.useState<boolean | null>(null)
|
||||||
const [password, setPassword] = React.useState('')
|
const [password, setPassword] = React.useState('')
|
||||||
const [loading, setLoading] = React.useState(false)
|
const [loading, setLoading] = React.useState(false)
|
||||||
const [error, setError] = React.useState<string | null>(null)
|
const [error, setError] = React.useState<string | null>(null)
|
||||||
const [groups, setGroups] = React.useState<GroupsData>(initialGroups)
|
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 [editingGroup, setEditingGroup] = React.useState<{ id: string; parseId: number; name: string; course: number } | null>(null)
|
||||||
const [showAddDialog, setShowAddDialog] = React.useState(false)
|
const [showAddDialog, setShowAddDialog] = React.useState(false)
|
||||||
const [showEditDialog, setShowEditDialog] = 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) {
|
if (res.ok && data.success) {
|
||||||
setAuthenticated(true)
|
setAuthenticated(true)
|
||||||
setPassword('')
|
setPassword('')
|
||||||
// Обновляем список групп после авторизации
|
// Обновляем список групп и настроек после авторизации
|
||||||
await loadGroupsList()
|
await loadGroupsList()
|
||||||
|
await loadSettingsList()
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'Ошибка авторизации')
|
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) => {
|
const handleAddGroup = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -287,6 +328,35 @@ export default function AdminPage({ groups: initialGroups }: AdminPageProps) {
|
|||||||
</div>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
@@ -516,9 +586,11 @@ export default function AdminPage({ groups: initialGroups }: AdminPageProps) {
|
|||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps<AdminPageProps> = async () => {
|
export const getServerSideProps: GetServerSideProps<AdminPageProps> = async () => {
|
||||||
const groups = loadGroups()
|
const groups = loadGroups()
|
||||||
|
const settings = loadSettings()
|
||||||
return {
|
return {
|
||||||
props: {
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -18,5 +18,10 @@
|
|||||||
"parseId": 172,
|
"parseId": 172,
|
||||||
"name": "ИБ-7к",
|
"name": "ИБ-7к",
|
||||||
"course": 3
|
"course": 3
|
||||||
|
},
|
||||||
|
"ib3": {
|
||||||
|
"parseId": 123,
|
||||||
|
"name": "ИБ-3",
|
||||||
|
"course": 4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
106
src/shared/data/settings-loader.ts
Normal file
106
src/shared/data/settings-loader.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export type AppSettings = {
|
||||||
|
weekNavigationEnabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedSettings: AppSettings | null = null
|
||||||
|
|
||||||
|
const defaultSettings: AppSettings = {
|
||||||
|
weekNavigationEnabled: true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает настройки из JSON файла
|
||||||
|
* Использует кеш для оптимизации в production
|
||||||
|
*/
|
||||||
|
export function loadSettings(): AppSettings {
|
||||||
|
if (cachedSettings) {
|
||||||
|
return cachedSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
// В production Next.js может использовать другую структуру директорий
|
||||||
|
// Пробуем несколько путей
|
||||||
|
const possiblePaths = [
|
||||||
|
path.join(process.cwd(), 'src/shared/data/settings.json'),
|
||||||
|
path.join(process.cwd(), '.next/standalone/src/shared/data/settings.json'),
|
||||||
|
path.join(process.cwd(), 'settings.json'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const filePath of possiblePaths) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const fileContents = fs.readFileSync(filePath, 'utf8')
|
||||||
|
const settings = JSON.parse(fileContents) as AppSettings
|
||||||
|
|
||||||
|
// Убеждаемся, что все обязательные поля присутствуют
|
||||||
|
const mergedSettings: AppSettings = {
|
||||||
|
...defaultSettings,
|
||||||
|
...settings
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedSettings = mergedSettings
|
||||||
|
return mergedSettings
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Пробуем следующий путь
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если файл не найден, создаем его с настройками по умолчанию
|
||||||
|
const mainPath = path.join(process.cwd(), 'src/shared/data/settings.json')
|
||||||
|
try {
|
||||||
|
// Создаем директорию, если её нет
|
||||||
|
const dir = path.dirname(mainPath)
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(mainPath, JSON.stringify(defaultSettings, null, 2), 'utf8')
|
||||||
|
cachedSettings = defaultSettings
|
||||||
|
return defaultSettings
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating settings.json:', error)
|
||||||
|
// Возвращаем настройки по умолчанию
|
||||||
|
return defaultSettings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохраняет настройки в JSON файл
|
||||||
|
*/
|
||||||
|
export function saveSettings(settings: AppSettings): void {
|
||||||
|
// Всегда сохраняем в основной путь
|
||||||
|
const filePath = path.join(process.cwd(), 'src/shared/data/settings.json')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Создаем директорию, если её нет
|
||||||
|
const dir = path.dirname(filePath)
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Объединяем с настройками по умолчанию для сохранения всех полей
|
||||||
|
const mergedSettings: AppSettings = {
|
||||||
|
...defaultSettings,
|
||||||
|
...settings
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(mergedSettings, null, 2), 'utf8')
|
||||||
|
// Сбрасываем кеш
|
||||||
|
cachedSettings = null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving settings.json:', error)
|
||||||
|
throw new Error('Failed to save settings')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сбрасывает кеш настроек (полезно после обновления файла)
|
||||||
|
*/
|
||||||
|
export function clearSettingsCache(): void {
|
||||||
|
cachedSettings = null
|
||||||
|
}
|
||||||
|
|
||||||
3
src/shared/data/settings.json
Normal file
3
src/shared/data/settings.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"weekNavigationEnabled": false
|
||||||
|
}
|
||||||
@@ -1,14 +1,27 @@
|
|||||||
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 { WeekNavigation } from '@/widgets/schedule/week-navigation'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { getDayOfWeek } from '@/shared/utils'
|
import { getDayOfWeek } from '@/shared/utils'
|
||||||
|
import { WeekInfo } from '@/app/parser/schedule'
|
||||||
|
|
||||||
export function Schedule({ days }: {
|
export function Schedule({
|
||||||
|
days,
|
||||||
|
currentWk,
|
||||||
|
availableWeeks,
|
||||||
|
weekNavigationEnabled = true
|
||||||
|
}: {
|
||||||
days: DayType[]
|
days: DayType[]
|
||||||
|
currentWk: number | null | undefined
|
||||||
|
availableWeeks: WeekInfo[] | null | undefined
|
||||||
|
weekNavigationEnabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const group = useRouter().query['group']
|
const group = useRouter().query['group']
|
||||||
const hasScrolledRef = React.useRef(false)
|
const hasScrolledRef = React.useRef(false)
|
||||||
|
|
||||||
|
// Определяем текущий номер недели из дней
|
||||||
|
const currentWeekNumber = days.length > 0 ? days[0]?.weekNumber : undefined
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (hasScrolledRef.current || typeof window === 'undefined') return
|
if (hasScrolledRef.current || typeof window === 'undefined') return
|
||||||
@@ -49,6 +62,13 @@ export function Schedule({ days }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col p-4 md:p-8 lg:p-16 gap-6 md:gap-12 lg:gap-14">
|
<div className="flex flex-col p-4 md:p-8 lg:p-16 gap-6 md:gap-12 lg:gap-14">
|
||||||
|
{weekNavigationEnabled && (
|
||||||
|
<WeekNavigation
|
||||||
|
currentWk={currentWk}
|
||||||
|
availableWeeks={availableWeeks}
|
||||||
|
currentWeekNumber={currentWeekNumber}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{days.map((day, i) => (
|
{days.map((day, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${group}_day${i}`}
|
key={`${group}_day${i}`}
|
||||||
|
|||||||
73
src/widgets/schedule/week-navigation.tsx
Normal file
73
src/widgets/schedule/week-navigation.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { Button } from '@/shadcn/ui/button'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
import { WeekInfo } from '@/app/parser/schedule'
|
||||||
|
|
||||||
|
export function WeekNavigation({
|
||||||
|
currentWk,
|
||||||
|
availableWeeks,
|
||||||
|
currentWeekNumber
|
||||||
|
}: {
|
||||||
|
currentWk: number | null | undefined
|
||||||
|
availableWeeks: WeekInfo[] | null | undefined
|
||||||
|
currentWeekNumber?: number
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const group = router.query.group as string
|
||||||
|
|
||||||
|
if (!availableWeeks || availableWeeks.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим текущую неделю в списке
|
||||||
|
const currentIndex = currentWk
|
||||||
|
? availableWeeks.findIndex(w => w.wk === currentWk)
|
||||||
|
: currentWeekNumber
|
||||||
|
? availableWeeks.findIndex(w => w.weekNumber === currentWeekNumber)
|
||||||
|
: -1
|
||||||
|
|
||||||
|
const currentWeek = currentIndex >= 0 ? availableWeeks[currentIndex] : availableWeeks[0]
|
||||||
|
const prevWeek = currentIndex > 0 ? availableWeeks[currentIndex - 1] : null
|
||||||
|
const nextWeek = currentIndex >= 0 && currentIndex < availableWeeks.length - 1
|
||||||
|
? availableWeeks[currentIndex + 1]
|
||||||
|
: null
|
||||||
|
|
||||||
|
const navigateToWeek = (wk: number) => {
|
||||||
|
router.push({
|
||||||
|
pathname: `/${group}`,
|
||||||
|
query: { wk }
|
||||||
|
}, undefined, { scroll: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-4 p-4 bg-background/50 rounded-lg border">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => prevWeek && navigateToWeek(prevWeek.wk)}
|
||||||
|
disabled={!prevWeek}
|
||||||
|
aria-label="Предыдущая неделя"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center min-w-[120px]">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{currentWeek ? `Неделя ${currentWeek.weekNumber}` : 'Неделя'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => nextWeek && navigateToWeek(nextWeek.wk)}
|
||||||
|
disabled={!nextWeek}
|
||||||
|
aria-label="Следующая неделя"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user