feat: добавлен режим "Каникулы" и улучшения админ-панели
- Добавлен режим "Каникулы" который полностью заменяет главную страницу:
* Карточка с эмодзи 🎉 и праздничным сообщением
* Поддержка произвольного текста в формате Markdown
* Карточка центрируется по вертикали при отсутствии текста
- Улучшения админ-панели:
* Переключатель режима "Каникулы"
* Редактор текста с подсказками по форматированию Markdown
* Исправлена проблема с обновлением настроек (сохранение существующих значений)
* Исправлена проблема с debug опциями в production (не блокируют обновление обычных настроек)
- Оптимизация загрузки:
* Проверка режима каникул перед загрузкой групп
* Динамическая загрузка ReactMarkdown только при необходимости
* Кеш настроек сбрасывается на главной странице для актуальности
- Добавлен скрипт для сброса пароля администратора (scripts/reset-admin-password.js)
- Установлена библиотека react-markdown для рендеринга Markdown контента
This commit is contained in:
@@ -117,6 +117,8 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
const [showVacationModeEditDialog, setShowVacationModeEditDialog] = React.useState(false)
|
||||
const [vacationModeContent, setVacationModeContent] = React.useState<string>(settings.vacationModeContent || '')
|
||||
|
||||
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
|
||||
const id = Date.now().toString()
|
||||
@@ -531,6 +533,39 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<div className="font-semibold">Режим "Каникулы"</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Включить режим каникул (заменяет главную страницу)
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={settings.vacationModeEnabled ?? false}
|
||||
onChange={(checked) => handleUpdateSettings({ ...settings, vacationModeEnabled: checked })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">Редактировать текст каникул</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Настроить текст, отображаемый в режиме каникул
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setVacationModeContent(settings.vacationModeContent || '')
|
||||
setShowVacationModeEditDialog(true)
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
Редактировать
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -996,6 +1031,79 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Диалог редактирования текста каникул */}
|
||||
<Dialog open={showVacationModeEditDialog} onOpenChange={setShowVacationModeEditDialog}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Редактирование текста режима Каникулы</DialogTitle>
|
||||
<DialogDescription>
|
||||
Отредактируйте текст, который будет отображаться в режиме каникул. Поддерживается форматирование Markdown.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vacation-mode-content">Текст (Markdown)</Label>
|
||||
<textarea
|
||||
id="vacation-mode-content"
|
||||
value={vacationModeContent}
|
||||
onChange={(e) => setVacationModeContent(e.target.value)}
|
||||
className="flex min-h-[300px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
|
||||
placeholder="Введите текст в формате Markdown..."
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p className="font-semibold">Подсказки по форматированию:</p>
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
<li><code className="bg-muted px-1 py-0.5 rounded"># Заголовок</code> - заголовок первого уровня</li>
|
||||
<li><code className="bg-muted px-1 py-0.5 rounded">## Подзаголовок</code> - заголовок второго уровня</li>
|
||||
<li><code className="bg-muted px-1 py-0.5 rounded">**жирный**</code> - <strong>жирный текст</strong></li>
|
||||
<li><code className="bg-muted px-1 py-0.5 rounded">*курсив*</code> - <em>курсивный текст</em></li>
|
||||
<li><code className="bg-muted px-1 py-0.5 rounded">[текст](url)</code> - ссылка</li>
|
||||
<li><code className="bg-muted px-1 py-0.5 rounded">- элемент</code> - маркированный список</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowVacationModeEditDialog(false)
|
||||
setVacationModeContent(settings.vacationModeContent || '')
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
vacationModeContent: vacationModeContent
|
||||
}
|
||||
await handleUpdateSettings(updatedSettings)
|
||||
setShowVacationModeEditDialog(false)
|
||||
showToast('Текст каникул успешно сохранен', 'success')
|
||||
} catch (err) {
|
||||
const errorMessage = 'Ошибка при сохранении текста'
|
||||
setError(errorMessage)
|
||||
showToast(errorMessage, 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Сохранение...' : 'Сохранить'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Toast уведомления */}
|
||||
<ToastContainer toasts={toasts} onClose={removeToast} />
|
||||
</>
|
||||
|
||||
@@ -19,8 +19,12 @@ async function handler(
|
||||
}
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
// Сначала загружаем текущие настройки из базы данных
|
||||
clearSettingsCache()
|
||||
const currentSettings = loadSettings(true)
|
||||
|
||||
// Обновление настроек
|
||||
const { weekNavigationEnabled, showAddGroupButton, debug } = req.body
|
||||
const { weekNavigationEnabled, showAddGroupButton, vacationModeEnabled, vacationModeContent, debug } = req.body
|
||||
|
||||
if (typeof weekNavigationEnabled !== 'boolean') {
|
||||
res.status(400).json({ error: 'weekNavigationEnabled must be a boolean' })
|
||||
@@ -32,31 +36,50 @@ async function handler(
|
||||
return
|
||||
}
|
||||
|
||||
if (vacationModeEnabled !== undefined && typeof vacationModeEnabled !== 'boolean') {
|
||||
res.status(400).json({ error: 'vacationModeEnabled must be a boolean' })
|
||||
return
|
||||
}
|
||||
|
||||
if (vacationModeContent !== undefined && typeof vacationModeContent !== 'string') {
|
||||
res.status(400).json({ error: 'vacationModeContent must be a string' })
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация debug опций (только в dev режиме)
|
||||
// В production режиме debug опции просто игнорируются
|
||||
let validatedDebug = undefined
|
||||
if (debug !== undefined) {
|
||||
if (typeof debug !== 'object' || debug === null) {
|
||||
res.status(400).json({ error: 'debug must be an object' })
|
||||
return
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
res.status(403).json({ error: 'Debug options are only available in development mode' })
|
||||
return
|
||||
}
|
||||
|
||||
const debugKeys = ['forceCache', 'forceEmpty', 'forceError', 'forceTimeout', 'showCacheInfo']
|
||||
for (const key of debugKeys) {
|
||||
if (key in debug && typeof debug[key] !== 'boolean' && debug[key] !== undefined) {
|
||||
res.status(400).json({ error: `debug.${key} must be a boolean` })
|
||||
return
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// В development режиме разрешаем debug опции
|
||||
const debugKeys = ['forceCache', 'forceEmpty', 'forceError', 'forceTimeout', 'showCacheInfo']
|
||||
|
||||
// Валидация типов debug опций
|
||||
for (const key of debugKeys) {
|
||||
if (key in debug && typeof debug[key] !== 'boolean' && debug[key] !== undefined) {
|
||||
res.status(400).json({ error: `debug.${key} must be a boolean` })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
validatedDebug = debug
|
||||
}
|
||||
// В production режиме debug опции просто игнорируются (не сохраняются)
|
||||
}
|
||||
|
||||
// Объединяем текущие настройки с новыми (новые значения перезаписывают старые)
|
||||
const settings: AppSettings = {
|
||||
...currentSettings,
|
||||
weekNavigationEnabled,
|
||||
showAddGroupButton: showAddGroupButton !== undefined ? showAddGroupButton : true,
|
||||
...(debug !== undefined && { debug })
|
||||
showAddGroupButton: showAddGroupButton !== undefined ? showAddGroupButton : (currentSettings.showAddGroupButton ?? true),
|
||||
vacationModeEnabled: vacationModeEnabled !== undefined ? vacationModeEnabled : (currentSettings.vacationModeEnabled ?? false),
|
||||
vacationModeContent: vacationModeContent !== undefined ? vacationModeContent : (currentSettings.vacationModeContent || ''),
|
||||
...(validatedDebug !== undefined && { debug: validatedDebug })
|
||||
}
|
||||
|
||||
saveSettings(settings)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { GetServerSideProps } from 'next'
|
||||
import { loadGroups, GroupsData } from '@/shared/data/groups-loader'
|
||||
import { loadSettings } from '@/shared/data/settings-loader'
|
||||
import { loadSettings, clearSettingsCache, AppSettings } from '@/shared/data/settings-loader'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shadcn/ui/card'
|
||||
import { Button } from '@/shadcn/ui/button'
|
||||
import { ThemeSwitcher } from '@/features/theme-switch'
|
||||
@@ -22,13 +22,98 @@ import { MdAdd } from 'react-icons/md'
|
||||
import { FaGithub } from 'react-icons/fa'
|
||||
import { BsTelegram } from 'react-icons/bs'
|
||||
|
||||
type HomePageProps = {
|
||||
type VacationModeProps = {
|
||||
vacationModeEnabled: true
|
||||
vacationModeContent: string
|
||||
}
|
||||
|
||||
type NormalModeProps = {
|
||||
vacationModeEnabled: false
|
||||
groups: GroupsData
|
||||
groupsByCourse: { [course: number]: Array<{ id: string; name: string }> }
|
||||
showAddGroupButton: boolean
|
||||
}
|
||||
|
||||
export default function HomePage({ groups, groupsByCourse, showAddGroupButton }: HomePageProps) {
|
||||
type HomePageProps = VacationModeProps | NormalModeProps
|
||||
|
||||
// Компонент режима каникул с динамической загрузкой ReactMarkdown
|
||||
function VacationMode({ vacationModeContent }: { vacationModeContent: string }) {
|
||||
const [MarkdownComponent, setMarkdownComponent] = React.useState<React.ComponentType<any> | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
// Загружаем ReactMarkdown только на клиенте
|
||||
import('react-markdown').then((module) => {
|
||||
setMarkdownComponent(() => module.default)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const hasContent = vacationModeContent && vacationModeContent.trim().length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Каникулы — Колледж Связи ПГУТИ</title>
|
||||
<meta name="description" content="По расписанию у тебя отдых! Наслаждайся свободным временем" />
|
||||
</Head>
|
||||
<div className={`min-h-screen p-4 md:p-8 ${!hasContent ? 'flex items-center justify-center' : ''}`}>
|
||||
<div className={`max-w-4xl mx-auto ${hasContent ? 'space-y-6' : ''}`}>
|
||||
<Card className="text-center py-8">
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="text-8xl mb-4">🎉</div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold">По расписанию у тебя отдых! Наслаждайся свободным временем 😋</h2>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasContent && (
|
||||
<div className="markdown-content space-y-4">
|
||||
{MarkdownComponent ? (
|
||||
<MarkdownComponent
|
||||
components={{
|
||||
h1: ({ children }: any) => <h1 className="text-3xl font-bold mt-6 mb-4">{children}</h1>,
|
||||
h2: ({ children }: any) => <h2 className="text-2xl font-bold mt-5 mb-3">{children}</h2>,
|
||||
h3: ({ children }: any) => <h3 className="text-xl font-bold mt-4 mb-2">{children}</h3>,
|
||||
p: ({ children }: any) => <p className="mb-4 leading-7">{children}</p>,
|
||||
strong: ({ children }: any) => <strong className="font-semibold">{children}</strong>,
|
||||
em: ({ children }: any) => <em className="italic">{children}</em>,
|
||||
ul: ({ children }: any) => <ul className="list-disc list-inside mb-4 space-y-1">{children}</ul>,
|
||||
ol: ({ children }: any) => <ol className="list-decimal list-inside mb-4 space-y-1">{children}</ol>,
|
||||
li: ({ children }: any) => <li className="ml-2">{children}</li>,
|
||||
a: ({ href, children }: any) => (
|
||||
<a href={href} className="text-primary underline hover:text-primary/80" target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ children }: any) => (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">{children}</code>
|
||||
),
|
||||
blockquote: ({ children }: any) => (
|
||||
<blockquote className="border-l-4 border-muted-foreground/30 pl-4 italic my-4">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{vacationModeContent}
|
||||
</MarkdownComponent>
|
||||
) : (
|
||||
<div className="text-muted-foreground">Загрузка...</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HomePage(props: HomePageProps) {
|
||||
// Режим каникул - полностью заменяет главную страницу
|
||||
if (props.vacationModeEnabled) {
|
||||
return <VacationMode vacationModeContent={props.vacationModeContent} />
|
||||
}
|
||||
|
||||
// Обычный режим - список групп
|
||||
const { groups, groupsByCourse, showAddGroupButton } = props
|
||||
const [openCourses, setOpenCourses] = React.useState<Set<number>>(new Set())
|
||||
const [addGroupDialogOpen, setAddGroupDialogOpen] = React.useState(false)
|
||||
|
||||
@@ -212,9 +297,24 @@ export default function HomePage({ groups, groupsByCourse, showAddGroupButton }:
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<HomePageProps> = async () => {
|
||||
// Используем кеш (обновляется каждую минуту автоматически)
|
||||
// Сначала загружаем только настройки для проверки режима каникул
|
||||
// Всегда загружаем свежие настройки без кеша для актуальности
|
||||
clearSettingsCache()
|
||||
const settings = loadSettings(true)
|
||||
const vacationModeEnabled = settings.vacationModeEnabled ?? false
|
||||
|
||||
// Если режим каникул включен, возвращаем только необходимые данные
|
||||
if (vacationModeEnabled) {
|
||||
return {
|
||||
props: {
|
||||
vacationModeEnabled: true,
|
||||
vacationModeContent: settings.vacationModeContent || ''
|
||||
} as VacationModeProps
|
||||
}
|
||||
}
|
||||
|
||||
// Если режим каникул выключен, загружаем группы и обрабатываем их
|
||||
const groups = loadGroups()
|
||||
const settings = loadSettings()
|
||||
|
||||
// Группируем группы по курсам
|
||||
const groupsByCourse: { [course: number]: Array<{ id: string; name: string }> } = {}
|
||||
@@ -234,9 +334,10 @@ export const getServerSideProps: GetServerSideProps<HomePageProps> = async () =>
|
||||
|
||||
return {
|
||||
props: {
|
||||
vacationModeEnabled: false,
|
||||
groups,
|
||||
groupsByCourse,
|
||||
showAddGroupButton: settings.showAddGroupButton ?? true
|
||||
}
|
||||
} as NormalModeProps
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user