feat: добавлен режим "Каникулы" и улучшения админ-панели
- Добавлен режим "Каникулы" который полностью заменяет главную страницу:
* Карточка с эмодзи 🎉 и праздничным сообщением
* Поддержка произвольного текста в формате Markdown
* Карточка центрируется по вертикали при отсутствии текста
- Улучшения админ-панели:
* Переключатель режима "Каникулы"
* Редактор текста с подсказками по форматированию Markdown
* Исправлена проблема с обновлением настроек (сохранение существующих значений)
* Исправлена проблема с debug опциями в production (не блокируют обновление обычных настроек)
- Оптимизация загрузки:
* Проверка режима каникул перед загрузкой групп
* Динамическая загрузка ReactMarkdown только при необходимости
* Кеш настроек сбрасывается на главной странице для актуальности
- Добавлен скрипт для сброса пароля администратора (scripts/reset-admin-password.js)
- Установлена библиотека react-markdown для рендеринга Markdown контента
This commit is contained in:
@@ -158,6 +158,11 @@ The application includes an admin panel for managing groups and application sett
|
|||||||
- ⚠️ **Important:** Change the default password immediately after first login for security!
|
- ⚠️ **Important:** Change the default password immediately after first login for security!
|
||||||
- The admin panel will show a warning if the default password is still in use
|
- The admin panel will show a warning if the default password is still in use
|
||||||
|
|
||||||
|
**Password recovery:**
|
||||||
|
- If you forgot your admin password, you can reset it using the provided scripts
|
||||||
|
- See `scripts/RESET_PASSWORD.md` for detailed instructions
|
||||||
|
- Quick reset: `node scripts/reset-admin-password.js "new_password"`
|
||||||
|
|
||||||
**Environment variables for admin panel:**
|
**Environment variables for admin panel:**
|
||||||
- `ADMIN_SESSION_SECRET` - Secret key for session tokens (optional, defaults to 'change-me-in-production')
|
- `ADMIN_SESSION_SECRET` - Secret key for session tokens (optional, defaults to 'change-me-in-production')
|
||||||
- `ADMIN_PASSWORD` - Initial admin password (optional, defaults to 'ksadmin')
|
- `ADMIN_PASSWORD` - Initial admin password (optional, defaults to 'ksadmin')
|
||||||
|
|||||||
1170
package-lock.json
generated
1170
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,8 @@
|
|||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@types/content-type": "^1.1.6",
|
"@types/content-type": "^1.1.6",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"better-sqlite3": "^11.6.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"classnames": "^2.3.2",
|
"classnames": "^2.3.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
@@ -36,13 +38,12 @@
|
|||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"sass": "^1.69.3",
|
"sass": "^1.69.3",
|
||||||
"sharp": "^0.32.6",
|
"sharp": "^0.32.6",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwind-scrollbar-hide": "^1.1.7",
|
"tailwind-scrollbar-hide": "^1.1.7",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7"
|
||||||
"better-sqlite3": "^11.6.0",
|
|
||||||
"bcrypt": "^5.1.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
|||||||
121
scripts/reset-admin-password.js
Executable file
121
scripts/reset-admin-password.js
Executable file
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скрипт для сброса пароля администратора
|
||||||
|
*
|
||||||
|
* Использование:
|
||||||
|
* node scripts/reset-admin-password.js [новый_пароль]
|
||||||
|
* или
|
||||||
|
* node scripts/reset-admin-password.js (интерактивный режим)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const readline = require('readline');
|
||||||
|
|
||||||
|
// Определяем путь к базе данных
|
||||||
|
function findDatabase() {
|
||||||
|
const possiblePaths = [
|
||||||
|
path.join(process.cwd(), 'data', 'schedule-app.db'),
|
||||||
|
path.join(process.cwd(), '.next', 'standalone', 'data', 'schedule-app.db'),
|
||||||
|
'/opt/kspguti-schedule/data/schedule-app.db',
|
||||||
|
'/opt/kspguti-schedule/.next/standalone/data/schedule-app.db',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const dbPath of possiblePaths) {
|
||||||
|
if (fs.existsSync(dbPath)) {
|
||||||
|
return dbPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для чтения пароля из терминала (простая версия)
|
||||||
|
function readPassword(prompt) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.question(prompt, (password) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(password);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔐 Сброс пароля администратора\n');
|
||||||
|
|
||||||
|
// Находим базу данных
|
||||||
|
const dbPath = findDatabase();
|
||||||
|
if (!dbPath) {
|
||||||
|
console.error('❌ Ошибка: База данных не найдена!');
|
||||||
|
console.log('\nИскали в следующих местах:');
|
||||||
|
console.log(' - ' + path.join(process.cwd(), 'data', 'schedule-app.db'));
|
||||||
|
console.log(' - ' + path.join(process.cwd(), '.next', 'standalone', 'data', 'schedule-app.db'));
|
||||||
|
console.log(' - /opt/kspguti-schedule/data/schedule-app.db');
|
||||||
|
console.log(' - /opt/kspguti-schedule/.next/standalone/data/schedule-app.db');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✓ Найдена база данных: ' + dbPath + '\n');
|
||||||
|
|
||||||
|
// Получаем новый пароль
|
||||||
|
let newPassword;
|
||||||
|
if (process.argv[2]) {
|
||||||
|
newPassword = process.argv[2];
|
||||||
|
} else {
|
||||||
|
newPassword = await readPassword('Введите новый пароль (минимум 8 символов): ');
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
console.error('❌ Ошибка: Пароль должен содержать минимум 8 символов');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmPassword = await readPassword('Подтвердите пароль: ');
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
console.error('❌ Ошибка: Пароли не совпадают');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
console.error('❌ Ошибка: Пароль должен содержать минимум 8 символов');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открываем базу данных и обновляем пароль
|
||||||
|
try {
|
||||||
|
console.log('\n⏳ Обновление пароля...');
|
||||||
|
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
// Хешируем новый пароль
|
||||||
|
const saltRounds = 10;
|
||||||
|
const hash = bcrypt.hashSync(newPassword, saltRounds);
|
||||||
|
|
||||||
|
// Обновляем пароль в базе данных
|
||||||
|
db.prepare('INSERT OR REPLACE INTO admin_password (id, password_hash) VALUES (1, ?)').run(hash);
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
console.log('✅ Пароль успешно изменен!');
|
||||||
|
console.log('\n⚠️ ВАЖНО: Сохраните пароль в безопасном месте!');
|
||||||
|
if (process.argv[2]) {
|
||||||
|
console.log('Новый пароль: ' + newPassword);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка при изменении пароля:');
|
||||||
|
console.error(error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
||||||
@@ -117,6 +117,8 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
newPassword: '',
|
newPassword: '',
|
||||||
confirmPassword: ''
|
confirmPassword: ''
|
||||||
})
|
})
|
||||||
|
const [showVacationModeEditDialog, setShowVacationModeEditDialog] = React.useState(false)
|
||||||
|
const [vacationModeContent, setVacationModeContent] = React.useState<string>(settings.vacationModeContent || '')
|
||||||
|
|
||||||
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
|
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
|
||||||
const id = Date.now().toString()
|
const id = Date.now().toString()
|
||||||
@@ -531,6 +533,39 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -996,6 +1031,79 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 уведомления */}
|
{/* Toast уведомления */}
|
||||||
<ToastContainer toasts={toasts} onClose={removeToast} />
|
<ToastContainer toasts={toasts} onClose={removeToast} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ async function handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'PUT') {
|
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') {
|
if (typeof weekNavigationEnabled !== 'boolean') {
|
||||||
res.status(400).json({ error: 'weekNavigationEnabled must be a boolean' })
|
res.status(400).json({ error: 'weekNavigationEnabled must be a boolean' })
|
||||||
@@ -32,31 +36,50 @@ async function handler(
|
|||||||
return
|
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 режиме)
|
// Валидация debug опций (только в dev режиме)
|
||||||
|
// В production режиме debug опции просто игнорируются
|
||||||
|
let validatedDebug = undefined
|
||||||
if (debug !== undefined) {
|
if (debug !== undefined) {
|
||||||
if (typeof debug !== 'object' || debug === null) {
|
if (typeof debug !== 'object' || debug === null) {
|
||||||
res.status(400).json({ error: 'debug must be an object' })
|
res.status(400).json({ error: 'debug must be an object' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
res.status(403).json({ error: 'Debug options are only available in development mode' })
|
// В development режиме разрешаем debug опции
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const debugKeys = ['forceCache', 'forceEmpty', 'forceError', 'forceTimeout', 'showCacheInfo']
|
const debugKeys = ['forceCache', 'forceEmpty', 'forceError', 'forceTimeout', 'showCacheInfo']
|
||||||
|
|
||||||
|
// Валидация типов debug опций
|
||||||
for (const key of debugKeys) {
|
for (const key of debugKeys) {
|
||||||
if (key in debug && typeof debug[key] !== 'boolean' && debug[key] !== undefined) {
|
if (key in debug && typeof debug[key] !== 'boolean' && debug[key] !== undefined) {
|
||||||
res.status(400).json({ error: `debug.${key} must be a boolean` })
|
res.status(400).json({ error: `debug.${key} must be a boolean` })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validatedDebug = debug
|
||||||
|
}
|
||||||
|
// В production режиме debug опции просто игнорируются (не сохраняются)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Объединяем текущие настройки с новыми (новые значения перезаписывают старые)
|
||||||
const settings: AppSettings = {
|
const settings: AppSettings = {
|
||||||
|
...currentSettings,
|
||||||
weekNavigationEnabled,
|
weekNavigationEnabled,
|
||||||
showAddGroupButton: showAddGroupButton !== undefined ? showAddGroupButton : true,
|
showAddGroupButton: showAddGroupButton !== undefined ? showAddGroupButton : (currentSettings.showAddGroupButton ?? true),
|
||||||
...(debug !== undefined && { debug })
|
vacationModeEnabled: vacationModeEnabled !== undefined ? vacationModeEnabled : (currentSettings.vacationModeEnabled ?? false),
|
||||||
|
vacationModeContent: vacationModeContent !== undefined ? vacationModeContent : (currentSettings.vacationModeContent || ''),
|
||||||
|
...(validatedDebug !== undefined && { debug: validatedDebug })
|
||||||
}
|
}
|
||||||
|
|
||||||
saveSettings(settings)
|
saveSettings(settings)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { GetServerSideProps } from 'next'
|
import { GetServerSideProps } from 'next'
|
||||||
import { loadGroups, GroupsData } from '@/shared/data/groups-loader'
|
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 { Card, CardContent, CardHeader, CardTitle } from '@/shadcn/ui/card'
|
||||||
import { Button } from '@/shadcn/ui/button'
|
import { Button } from '@/shadcn/ui/button'
|
||||||
import { ThemeSwitcher } from '@/features/theme-switch'
|
import { ThemeSwitcher } from '@/features/theme-switch'
|
||||||
@@ -22,13 +22,98 @@ import { MdAdd } from 'react-icons/md'
|
|||||||
import { FaGithub } from 'react-icons/fa'
|
import { FaGithub } from 'react-icons/fa'
|
||||||
import { BsTelegram } from 'react-icons/bs'
|
import { BsTelegram } from 'react-icons/bs'
|
||||||
|
|
||||||
type HomePageProps = {
|
type VacationModeProps = {
|
||||||
|
vacationModeEnabled: true
|
||||||
|
vacationModeContent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type NormalModeProps = {
|
||||||
|
vacationModeEnabled: false
|
||||||
groups: GroupsData
|
groups: GroupsData
|
||||||
groupsByCourse: { [course: number]: Array<{ id: string; name: string }> }
|
groupsByCourse: { [course: number]: Array<{ id: string; name: string }> }
|
||||||
showAddGroupButton: boolean
|
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 [openCourses, setOpenCourses] = React.useState<Set<number>>(new Set())
|
||||||
const [addGroupDialogOpen, setAddGroupDialogOpen] = React.useState(false)
|
const [addGroupDialogOpen, setAddGroupDialogOpen] = React.useState(false)
|
||||||
|
|
||||||
@@ -212,9 +297,24 @@ export default function HomePage({ groups, groupsByCourse, showAddGroupButton }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps<HomePageProps> = async () => {
|
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 groups = loadGroups()
|
||||||
const settings = loadSettings()
|
|
||||||
|
|
||||||
// Группируем группы по курсам
|
// Группируем группы по курсам
|
||||||
const groupsByCourse: { [course: number]: Array<{ id: string; name: string }> } = {}
|
const groupsByCourse: { [course: number]: Array<{ id: string; name: string }> } = {}
|
||||||
@@ -234,9 +334,10 @@ export const getServerSideProps: GetServerSideProps<HomePageProps> = async () =>
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
|
vacationModeEnabled: false,
|
||||||
groups,
|
groups,
|
||||||
groupsByCourse,
|
groupsByCourse,
|
||||||
showAddGroupButton: settings.showAddGroupButton ?? true
|
showAddGroupButton: settings.showAddGroupButton ?? true
|
||||||
}
|
} as NormalModeProps
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ export function getSettings(): AppSettings {
|
|||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
weekNavigationEnabled: false,
|
weekNavigationEnabled: false,
|
||||||
showAddGroupButton: true,
|
showAddGroupButton: true,
|
||||||
|
vacationModeEnabled: false,
|
||||||
|
vacationModeContent: '',
|
||||||
debug: {
|
debug: {
|
||||||
forceCache: false,
|
forceCache: false,
|
||||||
forceEmpty: false,
|
forceEmpty: false,
|
||||||
@@ -176,6 +178,8 @@ export function getSettings(): AppSettings {
|
|||||||
return {
|
return {
|
||||||
weekNavigationEnabled: settings.weekNavigationEnabled ?? false,
|
weekNavigationEnabled: settings.weekNavigationEnabled ?? false,
|
||||||
showAddGroupButton: settings.showAddGroupButton ?? true,
|
showAddGroupButton: settings.showAddGroupButton ?? true,
|
||||||
|
vacationModeEnabled: settings.vacationModeEnabled ?? false,
|
||||||
|
vacationModeContent: settings.vacationModeContent ?? '',
|
||||||
...settings,
|
...settings,
|
||||||
debug: {
|
debug: {
|
||||||
forceCache: false,
|
forceCache: false,
|
||||||
@@ -190,6 +194,8 @@ export function getSettings(): AppSettings {
|
|||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
weekNavigationEnabled: false,
|
weekNavigationEnabled: false,
|
||||||
showAddGroupButton: true,
|
showAddGroupButton: true,
|
||||||
|
vacationModeEnabled: false,
|
||||||
|
vacationModeContent: '',
|
||||||
debug: {
|
debug: {
|
||||||
forceCache: false,
|
forceCache: false,
|
||||||
forceEmpty: false,
|
forceEmpty: false,
|
||||||
@@ -207,6 +213,8 @@ export function updateSettings(settings: AppSettings): void {
|
|||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
weekNavigationEnabled: false,
|
weekNavigationEnabled: false,
|
||||||
showAddGroupButton: true,
|
showAddGroupButton: true,
|
||||||
|
vacationModeEnabled: false,
|
||||||
|
vacationModeContent: '',
|
||||||
debug: {
|
debug: {
|
||||||
forceCache: false,
|
forceCache: false,
|
||||||
forceEmpty: false,
|
forceEmpty: false,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { getSettings as getSettingsFromDB, updateSettings as updateSettingsInDB
|
|||||||
export type AppSettings = {
|
export type AppSettings = {
|
||||||
weekNavigationEnabled: boolean
|
weekNavigationEnabled: boolean
|
||||||
showAddGroupButton: boolean
|
showAddGroupButton: boolean
|
||||||
|
vacationModeEnabled?: boolean
|
||||||
|
vacationModeContent?: string
|
||||||
debug?: {
|
debug?: {
|
||||||
forceCache?: boolean
|
forceCache?: boolean
|
||||||
forceEmpty?: boolean
|
forceEmpty?: boolean
|
||||||
@@ -38,6 +40,8 @@ export function loadSettings(forceRefresh: boolean = false): AppSettings {
|
|||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
weekNavigationEnabled: false,
|
weekNavigationEnabled: false,
|
||||||
showAddGroupButton: true,
|
showAddGroupButton: true,
|
||||||
|
vacationModeEnabled: false,
|
||||||
|
vacationModeContent: '',
|
||||||
debug: {
|
debug: {
|
||||||
forceCache: false,
|
forceCache: false,
|
||||||
forceEmpty: false,
|
forceEmpty: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user