feat: добавлена админ-панель и главная страница с навигацией по курсам

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

Админ-панель:
- Создана защищенная паролем админ-панель по пути /admin
- Реализована система авторизации с сессионными куками
- Добавлен CRUD для управления группами (создание, редактирование, удаление)
- Добавлено поле "курс" (1-5) для каждой группы с возможностью редактирования

Структура данных:
- Миграция групп из TypeScript файла в JSON формат (groups.json)
- Обновлена структура данных: добавлено поле course
- Реализована автоматическая миграция старых данных в новый формат
- Создан groups-loader для работы с JSON файлом

Главная страница:
- Создана главная страница с аккордеоном по курсам (1-5)
- Группы сгруппированы по курсам для удобной навигации
- Добавлены кнопки: "Добавить группу", переключение темы и GitHub
- Убрана верхняя навигация с главной страницы

Навигация:
- Добавлена кнопка "К группам" в начало навигации на страницах расписания
- На мобильных устройствах скрыты кнопки групп, оставлена только кнопка возврата
- Улучшена адаптивность навигации

Технические улучшения:
- Исправлена проблема с tailwind-scrollbar-hide (заменен плагин на CSS класс)
- Обновлены все компоненты для работы с новой структурой данных групп
- Добавлена поддержка переменных окружения ADMIN_PASSWORD и ADMIN_SESSION_SECRET
This commit is contained in:
kilyabin
2025-11-23 00:58:58 +04:00
parent 808d577964
commit e5262f8203
20 changed files with 1320 additions and 34 deletions

View File

@@ -0,0 +1,128 @@
import fs from 'fs'
import path from 'path'
export type GroupInfo = {
parseId: number
name: string
course: number
}
export type GroupsData = { [group: string]: GroupInfo }
// Старый формат для миграции
type OldGroupsData = { [group: string]: [number, string] | GroupInfo }
let cachedGroups: GroupsData | null = null
/**
* Мигрирует старый формат данных в новый
*/
function migrateGroups(oldGroups: OldGroupsData): GroupsData {
const migrated: GroupsData = {}
for (const [id, data] of Object.entries(oldGroups)) {
// Проверяем, является ли это старым форматом [parseId, name]
if (Array.isArray(data) && data.length === 2 && typeof data[0] === 'number' && typeof data[1] === 'string') {
// Старый формат - мигрируем
migrated[id] = {
parseId: data[0],
name: data[1],
course: 1 // По умолчанию курс 1
}
} else if (typeof data === 'object' && 'parseId' in data && 'name' in data) {
// Уже новый формат
migrated[id] = data as GroupInfo
}
}
return migrated
}
/**
* Загружает группы из JSON файла
* Использует кеш для оптимизации в production
* Автоматически мигрирует старый формат в новый
*/
export function loadGroups(): GroupsData {
if (cachedGroups) {
return cachedGroups
}
// В production Next.js может использовать другую структуру директорий
// Пробуем несколько путей
const possiblePaths = [
path.join(process.cwd(), 'src/shared/data/groups.json'),
path.join(process.cwd(), '.next/standalone/src/shared/data/groups.json'),
path.join(process.cwd(), 'groups.json'),
]
for (const filePath of possiblePaths) {
try {
if (fs.existsSync(filePath)) {
const fileContents = fs.readFileSync(filePath, 'utf8')
const rawGroups = JSON.parse(fileContents) as OldGroupsData
// Проверяем, нужна ли миграция
const needsMigration = Object.values(rawGroups).some(
data => Array.isArray(data) && data.length === 2
)
let groups: GroupsData
if (needsMigration) {
// Мигрируем старый формат
groups = migrateGroups(rawGroups)
// Сохраняем мигрированные данные
const mainPath = path.join(process.cwd(), 'src/shared/data/groups.json')
if (filePath === mainPath) {
// Сохраняем только если это основной файл
fs.writeFileSync(mainPath, JSON.stringify(groups, null, 2), 'utf8')
console.log('Groups data migrated to new format')
}
} else {
groups = rawGroups as GroupsData
}
cachedGroups = groups
return groups
}
} catch (error) {
// Пробуем следующий путь
continue
}
}
console.error('Error loading groups.json: file not found in any of the expected locations')
// Fallback к пустому объекту
return {}
}
/**
* Сохраняет группы в JSON файл
*/
export function saveGroups(groups: GroupsData): void {
// Всегда сохраняем в основной путь
const filePath = path.join(process.cwd(), 'src/shared/data/groups.json')
try {
// Создаем директорию, если её нет
const dir = path.dirname(filePath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
fs.writeFileSync(filePath, JSON.stringify(groups, null, 2), 'utf8')
// Сбрасываем кеш
cachedGroups = null
} catch (error) {
console.error('Error saving groups.json:', error)
throw new Error('Failed to save groups')
}
}
/**
* Сбрасывает кеш групп (полезно после обновления файла)
*/
export function clearGroupsCache(): void {
cachedGroups = null
}

View File

@@ -0,0 +1,22 @@
{
"ib4k": {
"parseId": 138,
"name": "ИБ-4к",
"course": 4
},
"ib5": {
"parseId": 144,
"name": "ИБ-5",
"course": 3
},
"ib6": {
"parseId": 145,
"name": "ИБ-6",
"course": 3
},
"ib7k": {
"parseId": 172,
"name": "ИБ-7к",
"course": 3
}
}

View File

@@ -1,6 +1,18 @@
export const groups: { [group: string]: [number, string] } = {
ib4k: [138, 'ИБ-4к'],
ib5: [144, 'ИБ-5'],
ib6: [145, 'ИБ-6'],
ib7k: [172, 'ИБ-7к']
// Загружаем группы из JSON файла только на сервере
// На клиенте будет пустой объект, группы должны передаваться через props
let groups: { [group: string]: [number, string] } = {}
// Используем условный require только на сервере для избежания включения fs в клиентскую сборку
if (typeof window === 'undefined') {
// Серверная сторона
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const groupsLoader = require('./groups-loader')
groups = groupsLoader.loadGroups()
} catch (error) {
console.error('Error loading groups:', error)
groups = {}
}
}
export { groups }