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:
20
src/pages/api/admin/check-auth.ts
Normal file
20
src/pages/api/admin/check-auth.ts
Normal 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 })
|
||||
}
|
||||
|
||||
88
src/pages/api/admin/groups.ts
Normal file
88
src/pages/api/admin/groups.ts
Normal 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)
|
||||
}
|
||||
|
||||
97
src/pages/api/admin/groups/[id].ts
Normal file
97
src/pages/api/admin/groups/[id].ts
Normal 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)
|
||||
}
|
||||
|
||||
32
src/pages/api/admin/login.ts
Normal file
32
src/pages/api/admin/login.ts
Normal 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' })
|
||||
}
|
||||
}
|
||||
|
||||
20
src/pages/api/admin/logout.ts
Normal file
20
src/pages/api/admin/logout.ts
Normal 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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user