diff --git a/src/app/agregator/schedule.ts b/src/app/agregator/schedule.ts index 93ca0f8..6791127 100644 --- a/src/app/agregator/schedule.ts +++ b/src/app/agregator/schedule.ts @@ -1,21 +1,33 @@ 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 { JSDOM } from 'jsdom' // import { content as mockContent } from './mock' import { reportParserError } from '@/app/logger' import { PROXY_URL } from '@/shared/constants/urls' +export type ScheduleResult = { + days: Day[] + currentWk?: number + availableWeeks?: WeekInfo[] +} + // ПС-7: 146 -export async function getSchedule(groupID: number, groupName: string): Promise { - const page = await fetch(`${PROXY_URL}/?mn=2&obj=${groupID}`) +export async function getSchedule(groupID: number, groupName: string, wk?: number): Promise { + 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 content = await page.text() const contentType = page.headers.get('content-type') if (page.status === 200 && contentType && contentTypeParser.parse(contentType).type === 'text/html') { try { - const root = new JSDOM(content).window.document - return parsePage(root, groupName) + const root = new JSDOM(content, { url }).window.document + const result = parsePage(root, groupName, url) + return { + days: result.days, + currentWk: result.currentWk || wk, + availableWeeks: result.availableWeeks + } } catch(e) { console.error(`Error while parsing ${PROXY_URL}`) reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName) diff --git a/src/app/parser/schedule.ts b/src/app/parser/schedule.ts index ab49086..c0badff 100644 --- a/src/app/parser/schedule.ts +++ b/src/app/parser/schedule.ts @@ -1,6 +1,17 @@ import { Day } from '@/shared/model/day' 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 [dateString, week] = text.trim().split(' / ') const weekNumber = Number(week.trim().match(/^(\d+) неделя$/)![1]) @@ -9,6 +20,228 @@ const dayTitleParser = (text: string) => { 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() + + // Ищем все ссылки, которые содержат параметр 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 => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @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 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) @@ -110,6 +343,13 @@ export function parsePage(document: Document, groupName: string): Day[] { let dayInfo: Day = {} let dayLessons: Lesson[] = [] 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++) { const row = rows[i] @@ -131,6 +371,9 @@ export function parsePage(document: Document, groupName: string): Day[] { const { date, weekNumber } = dayTitleParser(row.querySelector('h3')!.textContent!) dayInfo.date = date dayInfo.weekNumber = weekNumber + if (!currentWeekNumber) { + currentWeekNumber = weekNumber + } previousRowIsDayTitle = true } else { 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 + } } \ No newline at end of file diff --git a/src/pages/[group].tsx b/src/pages/[group].tsx index 88b46d3..6787ff5 100644 --- a/src/pages/[group].tsx +++ b/src/pages/[group].tsx @@ -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) { - const { schedule, group, cacheAvailableFor, parsedAt, groups } = nextDeserialized(props) + const { schedule, group, cacheAvailableFor, parsedAt, groups, currentWk, availableWeeks, settings } = nextDeserialized(props) React.useEffect(() => { if (typeof window === 'undefined') return @@ -34,10 +39,30 @@ export default function HomePage(props: NextSerialized) { 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) { - + ) } -const cachedSchedules = new Map() +const cachedSchedules = new Map() const maxCacheDurationInMS = 1000 * 60 * 60 export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise>> { 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 { diff --git a/src/pages/admin.tsx b/src/pages/admin.tsx index cd2f4ba..072cdf6 100644 --- a/src/pages/admin.tsx +++ b/src/pages/admin.tsx @@ -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(null) const [password, setPassword] = React.useState('') const [loading, setLoading] = React.useState(false) const [error, setError] = React.useState(null) const [groups, setGroups] = React.useState(initialGroups) + const [settings, setSettings] = React.useState(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) { )} + + + Настройки + Управление настройками приложения + + +
+
+
+
Навигация по неделям
+
+ Включить или выключить навигацию по неделям в расписании +
+
+ +
+
+
+
+
@@ -516,9 +586,11 @@ export default function AdminPage({ groups: initialGroups }: AdminPageProps) { export const getServerSideProps: GetServerSideProps = async () => { const groups = loadGroups() + const settings = loadSettings() return { props: { - groups + groups, + settings } } } diff --git a/src/pages/api/admin/settings.ts b/src/pages/api/admin/settings.ts new file mode 100644 index 0000000..95dbc5f --- /dev/null +++ b/src/pages/api/admin/settings.ts @@ -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 +) { + 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 +) { + return requireAuth(req, res, handler) +} + diff --git a/src/shared/data/groups.json b/src/shared/data/groups.json index 1481bb3..826cc62 100644 --- a/src/shared/data/groups.json +++ b/src/shared/data/groups.json @@ -18,5 +18,10 @@ "parseId": 172, "name": "ИБ-7к", "course": 3 + }, + "ib3": { + "parseId": 123, + "name": "ИБ-3", + "course": 4 } } \ No newline at end of file diff --git a/src/shared/data/settings-loader.ts b/src/shared/data/settings-loader.ts new file mode 100644 index 0000000..4874095 --- /dev/null +++ b/src/shared/data/settings-loader.ts @@ -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 +} + diff --git a/src/shared/data/settings.json b/src/shared/data/settings.json new file mode 100644 index 0000000..753bc57 --- /dev/null +++ b/src/shared/data/settings.json @@ -0,0 +1,3 @@ +{ + "weekNavigationEnabled": false +} \ No newline at end of file diff --git a/src/widgets/schedule/index.tsx b/src/widgets/schedule/index.tsx index 58971de..a33b4b8 100644 --- a/src/widgets/schedule/index.tsx +++ b/src/widgets/schedule/index.tsx @@ -1,14 +1,27 @@ import type { Day as DayType } from '@/shared/model/day' import { Day } from '@/widgets/schedule/day' +import { WeekNavigation } from '@/widgets/schedule/week-navigation' import { useRouter } from 'next/router' import React from 'react' 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[] + currentWk: number | null | undefined + availableWeeks: WeekInfo[] | null | undefined + weekNavigationEnabled?: boolean }) { const group = useRouter().query['group'] const hasScrolledRef = React.useRef(false) + + // Определяем текущий номер недели из дней + const currentWeekNumber = days.length > 0 ? days[0]?.weekNumber : undefined React.useEffect(() => { if (hasScrolledRef.current || typeof window === 'undefined') return @@ -49,6 +62,13 @@ export function Schedule({ days }: { return (
+ {weekNavigationEnabled && ( + + )} {days.map((day, i) => (
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 ( +
+ + +
+
+ {currentWeek ? `Неделя ${currentWeek.weekNumber}` : 'Неделя'} +
+
+ + +
+ ) +} +