diff --git a/.env.production.example b/.env.production.example index 65ee8d1..fd91b6d 100644 --- a/.env.production.example +++ b/.env.production.example @@ -26,3 +26,10 @@ PORT=3000 # Server hostname (default: 0.0.0.0) HOSTNAME=0.0.0.0 +# Admin panel password (required for /admin access) +ADMIN_PASSWORD=your-secure-password-here + +# Admin session secret (optional, auto-generated if not set) +# Used for signing session cookies +ADMIN_SESSION_SECRET=your-session-secret-here + diff --git a/.gitignore b/.gitignore index 62190d7..58f68bf 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts - +.vscode/ +/.vscode .env \ No newline at end of file diff --git a/next.config.js b/next.config.js index cf75826..4104cd3 100644 --- a/next.config.js +++ b/next.config.js @@ -3,7 +3,21 @@ const nextConfig = { reactStrictMode: true, output: 'standalone', generateEtags: false, - allowedDevOrigins: ['192.168.1.10'] + allowedDevOrigins: ['192.168.1.10'], + webpack: (config, { isServer }) => { + // Исключаем fs и path из клиентской сборки + if (!isServer) { + config.resolve.fallback = { + ...config.resolve.fallback, + fs: false, + path: false, + } + } + return config + }, + // Добавляем пустую конфигурацию Turbopack для устранения предупреждения + // Webpack конфигурация используется для production build + turbopack: {}, } module.exports = nextConfig diff --git a/package.json b/package.json index 11b72b9..77d992f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "npm": ">=10.0.0" }, "scripts": { - "dev": "next dev", + "dev": "next dev --webpack", "build": "next build", "start": "next start", "lint": "next lint" diff --git a/src/pages/[group].tsx b/src/pages/[group].tsx index d9dea6d..88b46d3 100644 --- a/src/pages/[group].tsx +++ b/src/pages/[group].tsx @@ -5,7 +5,7 @@ import { getSchedule } 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 { groups } from '@/shared/data/groups' +import { loadGroups, GroupsData } from '@/shared/data/groups-loader' import { SITE_URL } from '@/shared/constants/urls' import crypto from 'crypto' import React from 'react' @@ -20,10 +20,11 @@ type PageProps = { } parsedAt: Date cacheAvailableFor: string[] + groups: GroupsData } export default function HomePage(props: NextSerialized) { - const { schedule, group, cacheAvailableFor, parsedAt } = nextDeserialized(props) + const { schedule, group, cacheAvailableFor, parsedAt, groups } = nextDeserialized(props) React.useEffect(() => { if (typeof window === 'undefined') return @@ -47,7 +48,7 @@ export default function HomePage(props: NextSerialized) { - + @@ -57,6 +58,7 @@ export default function HomePage(props: NextSerialized) { const cachedSchedules = new Map() const maxCacheDurationInMS = 1000 * 60 * 60 export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise>> { + const groups = loadGroups() const group = context.params?.group if (group && Object.hasOwn(groups, group) && group in groups) { let schedule @@ -68,7 +70,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr parsedAt = cachedSchedule.lastFetched } else { try { - schedule = await getSchedule(...groups[group]) + const groupInfo = groups[group] + schedule = await getSchedule(groupInfo.parseId, groupInfo.name) parsedAt = new Date() cachedSchedules.set(group, { lastFetched: new Date(), results: schedule }) } catch(e) { @@ -109,9 +112,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr parsedAt: parsedAt, group: { id: group, - name: groups[group][1] + name: groups[group].name }, - cacheAvailableFor + cacheAvailableFor, + groups }) } } else { diff --git a/src/pages/admin.tsx b/src/pages/admin.tsx new file mode 100644 index 0000000..cd2f4ba --- /dev/null +++ b/src/pages/admin.tsx @@ -0,0 +1,525 @@ +import React from 'react' +import { GetServerSideProps } from 'next' +import { Button } from '@/shadcn/ui/button' +import { Input } from '@/shadcn/ui/input' +import { Label } from '@/shadcn/ui/label' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/shadcn/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shadcn/ui/dialog' +import { loadGroups, GroupsData } from '@/shared/data/groups-loader' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shadcn/ui/select' +import Head from 'next/head' + +type AdminPageProps = { + groups: GroupsData +} + +export default function AdminPage({ groups: initialGroups }: 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 [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) + const [showDeleteDialog, setShowDeleteDialog] = React.useState(false) + const [groupToDelete, setGroupToDelete] = React.useState(null) + + // Форма добавления/редактирования + const [formData, setFormData] = React.useState({ + id: '', + parseId: '', + name: '', + course: '1' + }) + + // Проверка авторизации при загрузке + React.useEffect(() => { + checkAuth() + }, []) + + const checkAuth = async () => { + try { + const res = await fetch('/api/admin/check-auth') + const data = await res.json() + setAuthenticated(data.authenticated) + } catch (err) { + setAuthenticated(false) + } + } + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + + try { + const res = await fetch('/api/admin/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }) + }) + + const data = await res.json() + + if (res.ok && data.success) { + setAuthenticated(true) + setPassword('') + // Обновляем список групп после авторизации + await loadGroupsList() + } else { + setError(data.error || 'Ошибка авторизации') + } + } catch (err) { + setError('Ошибка соединения с сервером') + } finally { + setLoading(false) + } + } + + const loadGroupsList = async () => { + try { + const res = await fetch('/api/admin/groups') + const data = await res.json() + if (data.groups) { + setGroups(data.groups) + } + } catch (err) { + console.error('Error loading groups:', err) + } + } + + const handleAddGroup = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + + try { + const res = await fetch('/api/admin/groups', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: formData.id, + parseId: parseInt(formData.parseId, 10), + name: formData.name, + course: parseInt(formData.course, 10) + }) + }) + + const data = await res.json() + + if (res.ok && data.success) { + setGroups(data.groups) + setShowAddDialog(false) + setFormData({ id: '', parseId: '', name: '', course: '1' }) + } else { + setError(data.error || 'Ошибка при добавлении группы') + } + } catch (err) { + setError('Ошибка соединения с сервером') + } finally { + setLoading(false) + } + } + + const handleEditGroup = async (e: React.FormEvent) => { + e.preventDefault() + if (!editingGroup) return + + setLoading(true) + setError(null) + + try { + const res = await fetch(`/api/admin/groups/${editingGroup.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + parseId: parseInt(formData.parseId, 10), + name: formData.name, + course: parseInt(formData.course, 10) + }) + }) + + const data = await res.json() + + if (res.ok && data.success) { + setGroups(data.groups) + setShowEditDialog(false) + setEditingGroup(null) + setFormData({ id: '', parseId: '', name: '', course: '1' }) + } else { + setError(data.error || 'Ошибка при редактировании группы') + } + } catch (err) { + setError('Ошибка соединения с сервером') + } finally { + setLoading(false) + } + } + + const handleDeleteGroup = async () => { + if (!groupToDelete) return + + setLoading(true) + setError(null) + + try { + const res = await fetch(`/api/admin/groups/${groupToDelete}`, { + method: 'DELETE' + }) + + const data = await res.json() + + if (res.ok && data.success) { + setGroups(data.groups) + setShowDeleteDialog(false) + setGroupToDelete(null) + } else { + setError(data.error || 'Ошибка при удалении группы') + } + } catch (err) { + setError('Ошибка соединения с сервером') + } finally { + setLoading(false) + } + } + + const openEditDialog = (id: string) => { + const group = groups[id] + if (group) { + setEditingGroup({ id, parseId: group.parseId, name: group.name, course: group.course }) + setFormData({ id, parseId: group.parseId.toString(), name: group.name, course: group.course.toString() }) + setShowEditDialog(true) + } + } + + const openDeleteDialog = (id: string) => { + setGroupToDelete(id) + setShowDeleteDialog(true) + } + + if (authenticated === null) { + return ( +
+
Загрузка...
+
+ ) + } + + if (!authenticated) { + return ( + <> + + Админ-панель — Авторизация + +
+ + + Авторизация + Введите пароль для доступа к админ-панели + +
+ + {error && ( +
+ {error} +
+ )} +
+ + setPassword(e.target.value)} + disabled={loading} + required + autoFocus + /> +
+
+ + + +
+
+
+ + ) + } + + return ( + <> + + Админ-панель — Управление группами + +
+
+
+

Админ-панель

+ +
+ + {error && ( +
+ {error} +
+ )} + + + +
+
+ Группы + Управление группами для расписания +
+ +
+
+ + {Object.keys(groups).length === 0 ? ( +

Группы не найдены

+ ) : ( +
+ {Object.entries(groups).map(([id, group]) => ( +
+
+
{group.name}
+
+ ID: {id} | Parse ID: {group.parseId} | Курс: {group.course} +
+
+
+ + +
+
+ ))} +
+ )} +
+
+
+
+ + {/* Диалог добавления группы */} + + + + Добавить группу + + Заполните данные для новой группы + + +
+
+
+ + setFormData({ ...formData, id: e.target.value })} + placeholder="ib4k" + required + pattern="[a-z0-9_-]+" + /> +

+ Только строчные буквы, цифры, дефисы и подчеркивания +

+
+
+ + setFormData({ ...formData, parseId: e.target.value })} + placeholder="138" + required + /> +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="ИБ-4к" + required + /> +
+
+ + +
+
+ + + + +
+
+
+ + {/* Диалог редактирования группы */} + + + + Редактировать группу + + Измените данные группы + + +
+
+
+ + +

+ ID группы нельзя изменить +

+
+
+ + setFormData({ ...formData, parseId: e.target.value })} + required + /> +
+
+ + setFormData({ ...formData, name: e.target.value })} + required + /> +
+
+ + +
+
+ + + + +
+
+
+ + {/* Диалог удаления группы */} + + + + Удалить группу? + + Вы уверены, что хотите удалить группу "{groupToDelete && groups[groupToDelete]?.name}"? + Это действие нельзя отменить. + + + + + + + + + + ) +} + +export const getServerSideProps: GetServerSideProps = async () => { + const groups = loadGroups() + return { + props: { + groups + } + } +} + diff --git a/src/pages/api/admin/check-auth.ts b/src/pages/api/admin/check-auth.ts new file mode 100644 index 0000000..cdb07d7 --- /dev/null +++ b/src/pages/api/admin/check-auth.ts @@ -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 +) { + if (req.method !== 'GET') { + res.status(405).json({ authenticated: false }) + return + } + + const authenticated = checkAuth(req) + res.status(200).json({ authenticated }) +} + diff --git a/src/pages/api/admin/groups.ts b/src/pages/api/admin/groups.ts new file mode 100644 index 0000000..926332d --- /dev/null +++ b/src/pages/api/admin/groups.ts @@ -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 +) { + 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 +) { + return requireAuth(req, res, handler) +} + diff --git a/src/pages/api/admin/groups/[id].ts b/src/pages/api/admin/groups/[id].ts new file mode 100644 index 0000000..7595f60 --- /dev/null +++ b/src/pages/api/admin/groups/[id].ts @@ -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 +) { + 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 +) { + return requireAuth(req, res, handler) +} + diff --git a/src/pages/api/admin/login.ts b/src/pages/api/admin/login.ts new file mode 100644 index 0000000..d13a4d3 --- /dev/null +++ b/src/pages/api/admin/login.ts @@ -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 +) { + 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' }) + } +} + diff --git a/src/pages/api/admin/logout.ts b/src/pages/api/admin/logout.ts new file mode 100644 index 0000000..40e0dd8 --- /dev/null +++ b/src/pages/api/admin/logout.ts @@ -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 +) { + if (req.method !== 'POST') { + res.status(405).json({}) + return + } + + clearSessionCookie(res) + res.status(200).json({ success: true }) +} + diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 8e34012..b9c2f64 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,16 +1,189 @@ -import { GetServerSidePropsResult } from 'next' -import { groups } from '@/shared/data/groups' +import React from 'react' +import { GetServerSideProps } from 'next' +import { loadGroups, GroupsData } from '@/shared/data/groups-loader' +import { Card, CardContent, CardHeader, CardTitle } from '@/shadcn/ui/card' +import { Button } from '@/shadcn/ui/button' +import { ThemeSwitcher } from '@/features/theme-switch' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shadcn/ui/dialog' +import Link from 'next/link' +import Head from 'next/head' +import { ChevronDown } from 'lucide-react' +import { cn } from '@/shared/utils' +import { GITHUB_REPO_URL, TELEGRAM_CONTACT_URL } from '@/shared/constants/urls' +import { MdAdd } from 'react-icons/md' +import { FaGithub } from 'react-icons/fa' +import { BsTelegram } from 'react-icons/bs' -export default function HomePage() { } +type HomePageProps = { + groups: GroupsData + groupsByCourse: { [course: number]: Array<{ id: string; name: string }> } +} + +export default function HomePage({ groups, groupsByCourse }: HomePageProps) { + const [openCourses, setOpenCourses] = React.useState>(new Set([1])) + const [addGroupDialogOpen, setAddGroupDialogOpen] = React.useState(false) + + const toggleCourse = (course: number) => { + setOpenCourses(prev => { + const next = new Set(prev) + if (next.has(course)) { + next.delete(course) + } else { + next.add(course) + } + return next + }) + } + + return ( + <> + + Расписание занятий — Колледж Связи ПГУТИ + + +
+
+
+

Расписание занятий

+

Выберите группу для просмотра расписания

+
+ +
+ {[1, 2, 3, 4, 5].map(course => { + const courseGroups = groupsByCourse[course] || [] + const isOpen = openCourses.has(course) + + if (courseGroups.length === 0) { + return null + } + + return ( + + toggleCourse(course)} + > +
+ + {course} курс + + +
+
+ {isOpen && ( + +
+ {courseGroups.map(({ id, name }) => ( + + + + ))} +
+
+ )} +
+ ) + })} +
+ + {Object.keys(groups).length === 0 && ( + + + Группы не найдены + + + )} + +
+ +
+ + + Тема + +
+ + + +
+
+
+ + {/* Диалог добавления группы */} + + + + Добавить группу + + + Если вы хотите добавить свою группу на сайт, скиньтесь всей группой и задонатьте мне 500 ₽ + + + Для меня это будет очень хорошая поддержка🥺🥺🥺 + + + + + + + + + + ) +} + +export const getServerSideProps: GetServerSideProps = async () => { + const groups = loadGroups() + + // Группируем группы по курсам + const groupsByCourse: { [course: number]: Array<{ id: string; name: string }> } = {} + + for (const [id, group] of Object.entries(groups)) { + const course = group.course + if (!groupsByCourse[course]) { + groupsByCourse[course] = [] + } + groupsByCourse[course].push({ id, name: group.name }) + } + + // Сортируем группы внутри каждого курса по имени + for (const course in groupsByCourse) { + groupsByCourse[Number(course)].sort((a, b) => a.name.localeCompare(b.name)) + } -export async function getServerSideProps(): Promise>> { - // Получаем первую группу из списка - const firstGroupId = Object.keys(groups)[0] - return { - redirect: { - destination: `/${firstGroupId}`, - permanent: true + props: { + groups, + groupsByCourse } } -} \ No newline at end of file +} diff --git a/src/pages/sitemap.xml/index.tsx b/src/pages/sitemap.xml/index.tsx index e7edb57..095e11d 100644 --- a/src/pages/sitemap.xml/index.tsx +++ b/src/pages/sitemap.xml/index.tsx @@ -1,9 +1,10 @@ import { ISitemapField, getServerSideSitemapLegacy } from 'next-sitemap' import { GetServerSideProps } from 'next' -import { groups } from '@/shared/data/groups' +import { loadGroups } from '@/shared/data/groups-loader' import { SITEMAP_SITE_URL } from '@/shared/constants/urls' export const getServerSideProps: GetServerSideProps = async (ctx) => { + const groups = loadGroups() const fields = Object.keys(groups).map(group => ( { loc: `${SITEMAP_SITE_URL}/${group}`, diff --git a/src/shared/data/groups-loader.ts b/src/shared/data/groups-loader.ts new file mode 100644 index 0000000..f60bf06 --- /dev/null +++ b/src/shared/data/groups-loader.ts @@ -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 +} + diff --git a/src/shared/data/groups.json b/src/shared/data/groups.json new file mode 100644 index 0000000..1481bb3 --- /dev/null +++ b/src/shared/data/groups.json @@ -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 + } +} \ No newline at end of file diff --git a/src/shared/data/groups.ts b/src/shared/data/groups.ts index 6463cc4..94f9c3e 100644 --- a/src/shared/data/groups.ts +++ b/src/shared/data/groups.ts @@ -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 } diff --git a/src/shared/styles/globals.css b/src/shared/styles/globals.css index d37c5ef..8a3c148 100644 --- a/src/shared/styles/globals.css +++ b/src/shared/styles/globals.css @@ -1,6 +1,16 @@ @tailwind base; @tailwind components; @tailwind utilities; + +@layer utilities { + .scrollbar-hide { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } + .scrollbar-hide::-webkit-scrollbar { + display: none; /* Chrome, Safari and Opera */ + } +} @layer base { :root { diff --git a/src/shared/utils/auth.ts b/src/shared/utils/auth.ts new file mode 100644 index 0000000..d60f24a --- /dev/null +++ b/src/shared/utils/auth.ts @@ -0,0 +1,108 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import crypto from 'crypto' + +const SESSION_COOKIE_NAME = 'admin_session' +const SESSION_SECRET = process.env.ADMIN_SESSION_SECRET || 'change-me-in-production' +const SESSION_DURATION = 1000 * 60 * 60 * 24 // 24 часа + +/** + * Проверяет пароль администратора + */ +export function verifyPassword(password: string): boolean { + const adminPassword = process.env.ADMIN_PASSWORD + if (!adminPassword) { + console.error('ADMIN_PASSWORD is not set') + return false + } + return password === adminPassword +} + +/** + * Создает сессионный токен + */ +function createSessionToken(): string { + const randomBytes = crypto.randomBytes(32).toString('hex') + const timestamp = Date.now().toString() + const data = `${randomBytes}:${timestamp}` + const hash = crypto.createHmac('sha256', SESSION_SECRET).update(data).digest('hex') + return `${data}:${hash}` +} + +/** + * Проверяет валидность сессионного токена + */ +function verifySessionToken(token: string): boolean { + try { + const parts = token.split(':') + if (parts.length !== 3) return false + + const [randomBytes, timestamp, hash] = parts + const data = `${randomBytes}:${timestamp}` + const expectedHash = crypto.createHmac('sha256', SESSION_SECRET).update(data).digest('hex') + + if (hash !== expectedHash) return false + + // Проверяем срок действия сессии + const sessionTime = parseInt(timestamp, 10) + const now = Date.now() + if (now - sessionTime > SESSION_DURATION) return false + + return true + } catch { + return false + } +} + +/** + * Устанавливает сессионную куку + */ +export function setSessionCookie(res: NextApiResponse): void { + const token = createSessionToken() + const isProduction = process.env.NODE_ENV === 'production' + const secureFlag = isProduction ? '; Secure' : '' + res.setHeader('Set-Cookie', `${SESSION_COOKIE_NAME}=${token}; HttpOnly${secureFlag}; SameSite=Strict; Path=/; Max-Age=${SESSION_DURATION / 1000}`) +} + +/** + * Проверяет авторизацию по сессионной куке + */ +export function checkAuth(req: NextApiRequest): boolean { + const cookieHeader = req.headers.cookie + if (!cookieHeader) return false + + const cookies = cookieHeader.split(';').reduce((acc, cookie) => { + const [key, value] = cookie.trim().split('=') + acc[key] = value + return acc + }, {} as Record) + + const sessionToken = cookies[SESSION_COOKIE_NAME] + if (!sessionToken) return false + + return verifySessionToken(sessionToken) +} + +/** + * Удаляет сессионную куку (логаут) + */ +export function clearSessionCookie(res: NextApiResponse): void { + const isProduction = process.env.NODE_ENV === 'production' + const secureFlag = isProduction ? '; Secure' : '' + res.setHeader('Set-Cookie', `${SESSION_COOKIE_NAME}=; HttpOnly${secureFlag}; SameSite=Strict; Path=/; Max-Age=0`) +} + +/** + * Middleware для защиты API endpoints + */ +export function requireAuth( + req: NextApiRequest, + res: NextApiResponse, + handler: (req: NextApiRequest, res: NextApiResponse) => void | Promise +): void | Promise { + if (!checkAuth(req)) { + res.status(401).json({ error: 'Unauthorized' }) + return + } + return handler(req, res) +} + diff --git a/src/widgets/navbar/index.tsx b/src/widgets/navbar/index.tsx index a9017b4..20280e3 100644 --- a/src/widgets/navbar/index.tsx +++ b/src/widgets/navbar/index.tsx @@ -6,27 +6,51 @@ import { useTheme } from 'next-themes' import Link from 'next/link' import { useRouter } from 'next/router' import { FaGithub } from 'react-icons/fa' +import { ArrowLeft } from 'lucide-react' import cx from 'classnames' import { NavContext, NavContextProvider } from '@/shared/context/nav-context' -import { groups } from '@/shared/data/groups' import { GITHUB_REPO_URL } from '@/shared/constants/urls' +import { GroupsData } from '@/shared/data/groups-loader' -export function NavBar({ cacheAvailableFor }: { +export function NavBar({ cacheAvailableFor, groups }: { cacheAvailableFor: string[] + groups: GroupsData }) { const { resolvedTheme } = useTheme() - const theme = resolvedTheme || 'light' + // Используем состояние для предотвращения проблем с гидратацией + const [mounted, setMounted] = React.useState(false) + + React.useEffect(() => { + setMounted(true) + }, []) + + // На сервере используем 'light' по умолчанию, чтобы избежать различий в гидратации + const theme = mounted ? (resolvedTheme || 'light') : 'light' return (
-