feat: добавлен режим "Каникулы" и улучшения админ-панели

- Добавлен режим "Каникулы" который полностью заменяет главную страницу:
  * Карточка с эмодзи 🎉 и праздничным сообщением
  * Поддержка произвольного текста в формате Markdown
  * Карточка центрируется по вертикали при отсутствии текста

- Улучшения админ-панели:
  * Переключатель режима "Каникулы"
  * Редактор текста с подсказками по форматированию Markdown
  * Исправлена проблема с обновлением настроек (сохранение существующих значений)
  * Исправлена проблема с debug опциями в production (не блокируют обновление обычных настроек)

- Оптимизация загрузки:
  * Проверка режима каникул перед загрузкой групп
  * Динамическая загрузка ReactMarkdown только при необходимости
  * Кеш настроек сбрасывается на главной странице для актуальности

- Добавлен скрипт для сброса пароля администратора (scripts/reset-admin-password.js)

- Установлена библиотека react-markdown для рендеринга Markdown контента
This commit is contained in:
kilyabin
2025-12-04 23:22:42 +04:00
parent e46a2419c3
commit 3f74709513
9 changed files with 1562 additions and 23 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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();

View File

@@ -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} />
</> </>

View File

@@ -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']
}
// Валидация типов debug опций
const debugKeys = ['forceCache', 'forceEmpty', 'forceError', 'forceTimeout', 'showCacheInfo'] 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)

View File

@@ -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
} }
} }

View File

@@ -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,

View File

@@ -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,