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,20 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { checkAuth } from '@/shared/utils/auth'
type ResponseData = {
authenticated: boolean
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
if (req.method !== 'GET') {
res.status(405).json({ authenticated: false })
return
}
const authenticated = checkAuth(req)
res.status(200).json({ authenticated })
}

View File

@@ -0,0 +1,88 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { requireAuth } from '@/shared/utils/auth'
import { loadGroups, saveGroups, GroupsData } from '@/shared/data/groups-loader'
type ResponseData = {
groups?: GroupsData
success?: boolean
error?: string
}
async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
if (req.method === 'GET') {
// Получение списка групп
const groups = loadGroups()
res.status(200).json({ groups })
return
}
if (req.method === 'POST') {
// Добавление новой группы
const { id, parseId, name, course } = req.body
if (!id || typeof id !== 'string') {
res.status(400).json({ error: 'Group ID is required' })
return
}
if (!parseId || typeof parseId !== 'number') {
res.status(400).json({ error: 'Parse ID must be a number' })
return
}
if (!name || typeof name !== 'string') {
res.status(400).json({ error: 'Group name is required' })
return
}
// Валидация курса (1-5)
const groupCourse = course !== undefined ? Number(course) : 1
if (!Number.isInteger(groupCourse) || groupCourse < 1 || groupCourse > 5) {
res.status(400).json({ error: 'Course must be a number between 1 and 5' })
return
}
// Валидация ID (только латинские буквы, цифры, дефисы и подчеркивания)
if (!/^[a-z0-9_-]+$/.test(id)) {
res.status(400).json({ error: 'Group ID must contain only lowercase letters, numbers, dashes and underscores' })
return
}
const groups = loadGroups()
// Проверка на дубликат
if (groups[id]) {
res.status(400).json({ error: 'Group with this ID already exists' })
return
}
// Добавляем группу
groups[id] = {
parseId,
name,
course: groupCourse
}
try {
saveGroups(groups)
res.status(200).json({ success: true, groups })
} catch (error) {
console.error('Error saving groups:', error)
res.status(500).json({ error: 'Failed to save groups' })
}
return
}
res.status(405).json({ error: 'Method not allowed' })
}
export default function protectedHandler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
return requireAuth(req, res, handler)
}

View File

@@ -0,0 +1,97 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { requireAuth } from '@/shared/utils/auth'
import { loadGroups, saveGroups, GroupsData } from '@/shared/data/groups-loader'
type ResponseData = {
success?: boolean
groups?: GroupsData
error?: string
}
async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
const { id } = req.query
if (!id || typeof id !== 'string') {
res.status(400).json({ error: 'Group ID is required' })
return
}
const groups = loadGroups()
if (req.method === 'PUT') {
// Редактирование группы
const { parseId, name, course } = req.body
if (!groups[id]) {
res.status(404).json({ error: 'Group not found' })
return
}
if (parseId !== undefined && typeof parseId !== 'number') {
res.status(400).json({ error: 'Parse ID must be a number' })
return
}
if (name !== undefined && typeof name !== 'string') {
res.status(400).json({ error: 'Group name must be a string' })
return
}
if (course !== undefined) {
const groupCourse = Number(course)
if (!Number.isInteger(groupCourse) || groupCourse < 1 || groupCourse > 5) {
res.status(400).json({ error: 'Course must be a number between 1 and 5' })
return
}
}
// Обновляем группу
const currentGroup = groups[id]
groups[id] = {
parseId: parseId !== undefined ? parseId : currentGroup.parseId,
name: name !== undefined ? name : currentGroup.name,
course: course !== undefined ? Number(course) : currentGroup.course
}
try {
saveGroups(groups)
res.status(200).json({ success: true, groups })
} catch (error) {
console.error('Error saving groups:', error)
res.status(500).json({ error: 'Failed to save groups' })
}
return
}
if (req.method === 'DELETE') {
// Удаление группы
if (!groups[id]) {
res.status(404).json({ error: 'Group not found' })
return
}
delete groups[id]
try {
saveGroups(groups)
res.status(200).json({ success: true, groups })
} catch (error) {
console.error('Error saving groups:', error)
res.status(500).json({ error: 'Failed to save groups' })
}
return
}
res.status(405).json({ error: 'Method not allowed' })
}
export default function protectedHandler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
return requireAuth(req, res, handler)
}

View File

@@ -0,0 +1,32 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { verifyPassword, setSessionCookie } from '@/shared/utils/auth'
type ResponseData = {
success?: boolean
error?: string
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
if (req.method !== 'POST') {
res.status(405).json({ error: 'Method not allowed' })
return
}
const { password } = req.body
if (!password || typeof password !== 'string') {
res.status(400).json({ error: 'Password is required' })
return
}
if (verifyPassword(password)) {
setSessionCookie(res)
res.status(200).json({ success: true })
} else {
res.status(401).json({ error: 'Invalid password' })
}
}

View File

@@ -0,0 +1,20 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { clearSessionCookie } from '@/shared/utils/auth'
type ResponseData = {
success?: boolean
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
if (req.method !== 'POST') {
res.status(405).json({})
return
}
clearSessionCookie(res)
res.status(200).json({ success: true })
}