refactor: optimize project structure, migrate to SQLite, and add new features
fix:
- Fix TypeScript type errors in api-wrapper.ts (ApiResponse type)
- Fix backward compatibility in database.ts getSettings() for missing fields
- Fix default value for weekNavigationEnabled (changed from true to false)
- Fix API routes error handling with unified wrapper
- Fix duplicate toggle switch code in admin.tsx (6 instances)
- Fix inconsistent authentication check in API routes (unified with withAuth)
- Fix error message text in loading-context.tsx (improved user experience)
add:
- Add database.ts: SQLite database layer with better-sqlite3 for persistent storage
* Groups management (CRUD operations)
* Settings management with caching
* Admin password hashing with bcrypt
* Automatic database initialization and migration
- Add api-wrapper.ts utility for unified API route handling
* withAuth wrapper for protected routes
* withMethods wrapper for public routes
* Consistent error handling and method validation
- Add validation.ts utility with centralized validation functions
* validateCourse - course validation (1-5)
* validateGroupId - group ID format validation
* validatePassword - password strength validation
- Add showAddGroupButton setting to control visibility of 'Add Group' button on homepage
- Add toggle switch component in admin.tsx for reusable UI (replaces 6 duplicate instances)
- Add CourseSelect component in admin.tsx for reusable course selection
- Add DialogFooterButtons component in admin.tsx for reusable dialog footer
- Add unified loadData function in admin.tsx to reduce code duplication
- Add change-password.ts API endpoint for admin password management
- Add logs.ts API endpoint for viewing error logs in admin panel
- Add logErrorToFile function in logger.ts for persistent error logging
- Add comprehensive error logging in schedule.ts (parsing, fetch, timeout, network errors)
- Add comprehensive project structure documentation in README.md
- Add architecture and code organization section in README.md
- Add database information section in README.md
- Add SQLite and bcrypt to tech stack documentation
- Add better-sqlite3 and bcrypt dependencies to package.json
- Add .gitignore rules for error.log and database files (data/, *.db, *.db-shm, *.db-wal)
refactor:
- Refactor admin.tsx: extract reusable components (toggle, select, dialog footer)
- Refactor API routes to use withAuth wrapper for consistent authentication
- Refactor API routes to use validation utilities instead of inline validation
- Refactor groups.ts and groups.json: move to old/data/ directory (deprecated, now using SQLite)
- Refactor settings-loader.ts: migrate from JSON to SQLite database
- Refactor groups-loader.ts: migrate from JSON to SQLite database
- Refactor database.ts: improve backward compatibility for settings migration
- Refactor admin.tsx: unify data loading functions (loadGroupsList, loadSettingsList)
- Refactor index.tsx: add showAddGroupButton prop and conditional rendering
- Refactor API routes: consistent error handling and method validation
- Refactor README.md: update tech stack, project structure, and admin panel documentation
- Refactor auth.ts: improve session management and cookie handling
- Refactor schedule.ts: improve error handling with detailed logging and error types
- Refactor logger.ts: add file-based error logging functionality
- Refactor loading-context.tsx: improve error message clarity
remove:
- Remove hello.ts test API endpoint
- Remove groups.ts and groups.json (moved to old/data/, replaced by SQLite)
update:
- Update .gitignore to exclude old data files, database files, and error logs
- Update package.json: add better-sqlite3, bcrypt and their type definitions
- Update README.md with new features, architecture, and database information
- Update all API routes to use new wrapper system
- Update admin panel with new settings and improved UI
- Update sitemap.xml with cache usage comment
This commit is contained in:
@@ -155,6 +155,7 @@ function cleanupCache() {
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise<GetServerSidePropsResult<NextSerialized<PageProps>>> {
|
||||
// Используем кеш (обновляется каждую минуту автоматически)
|
||||
const groups = loadGroups()
|
||||
const settings = loadSettings()
|
||||
const group = context.params?.group
|
||||
|
||||
@@ -27,9 +27,74 @@ import {
|
||||
type AdminPageProps = {
|
||||
groups: GroupsData
|
||||
settings: AppSettings
|
||||
isDefaultPassword: boolean
|
||||
}
|
||||
|
||||
export default function AdminPage({ groups: initialGroups, settings: initialSettings }: AdminPageProps) {
|
||||
// Компонент Toggle Switch
|
||||
function ToggleSwitch({ checked, onChange, disabled }: {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
// Компонент выбора курса
|
||||
function CourseSelect({ value, onChange, id }: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
id: string
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger id={id}>
|
||||
<SelectValue placeholder="Выберите курс" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 курс</SelectItem>
|
||||
<SelectItem value="2">2 курс</SelectItem>
|
||||
<SelectItem value="3">3 курс</SelectItem>
|
||||
<SelectItem value="4">4 курс</SelectItem>
|
||||
<SelectItem value="5">5 курс</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
// Компонент для DialogFooter с кнопками
|
||||
function DialogFooterButtons({ onCancel, onSubmit, submitLabel, loading, submitVariant = 'default' }: {
|
||||
onCancel: () => void
|
||||
onSubmit?: () => void
|
||||
submitLabel: string
|
||||
loading?: boolean
|
||||
submitVariant?: 'default' | 'destructive'
|
||||
}) {
|
||||
return (
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Отмена
|
||||
</Button>
|
||||
{onSubmit && (
|
||||
<Button type="button" variant={submitVariant} onClick={onSubmit} disabled={loading}>
|
||||
{loading ? 'Обработка...' : submitLabel}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminPage({ groups: initialGroups, settings: initialSettings, isDefaultPassword: initialIsDefaultPassword }: AdminPageProps) {
|
||||
const [authenticated, setAuthenticated] = React.useState<boolean | null>(null)
|
||||
const [password, setPassword] = React.useState('')
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
@@ -40,8 +105,18 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
const [showAddDialog, setShowAddDialog] = React.useState(false)
|
||||
const [showEditDialog, setShowEditDialog] = React.useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = React.useState(false)
|
||||
const [showLogsDialog, setShowLogsDialog] = React.useState(false)
|
||||
const [logs, setLogs] = React.useState<string>('')
|
||||
const [logsLoading, setLogsLoading] = React.useState(false)
|
||||
const [groupToDelete, setGroupToDelete] = React.useState<string | null>(null)
|
||||
const [toasts, setToasts] = React.useState<Toast[]>([])
|
||||
const [showChangePasswordDialog, setShowChangePasswordDialog] = React.useState(false)
|
||||
const [isDefaultPassword, setIsDefaultPassword] = React.useState<boolean>(initialIsDefaultPassword)
|
||||
const [passwordFormData, setPasswordFormData] = React.useState({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
|
||||
const id = Date.now().toString()
|
||||
@@ -105,29 +180,22 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
}
|
||||
}
|
||||
|
||||
const loadGroupsList = async () => {
|
||||
const loadData = async <T,>(endpoint: string, setter: (data: T) => void) => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/groups')
|
||||
const res = await fetch(endpoint)
|
||||
const data = await res.json()
|
||||
if (data.groups) {
|
||||
setGroups(data.groups)
|
||||
setter(data.groups as T)
|
||||
} else if (data.settings) {
|
||||
setter(data.settings as T)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading groups:', err)
|
||||
console.error(`Error loading data from ${endpoint}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
const loadSettingsList = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings')
|
||||
const data = await res.json()
|
||||
if (data.settings) {
|
||||
setSettings(data.settings)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading settings:', err)
|
||||
}
|
||||
}
|
||||
const loadGroupsList = () => loadData('/api/admin/groups', setGroups)
|
||||
const loadSettingsList = () => loadData('/api/admin/settings', setSettings)
|
||||
|
||||
const handleUpdateSettings = async (newSettings: AppSettings) => {
|
||||
// Сохраняем предыдущее состояние для отката при ошибке
|
||||
@@ -289,6 +357,29 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
const loadLogs = async () => {
|
||||
setLogsLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/admin/logs')
|
||||
const data = await res.json()
|
||||
if (data.success && data.logs) {
|
||||
setLogs(data.logs)
|
||||
} else {
|
||||
setLogs(data.error || 'Не удалось загрузить логи')
|
||||
}
|
||||
} catch (err) {
|
||||
setLogs('Ошибка при загрузке логов')
|
||||
console.error('Error loading logs:', err)
|
||||
} finally {
|
||||
setLogsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenLogsDialog = () => {
|
||||
setShowLogsDialog(true)
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
if (authenticated === null) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@@ -350,19 +441,27 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold">Админ-панель</h1>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch('/api/admin/logout', { method: 'POST' })
|
||||
} catch (err) {
|
||||
console.error('Logout error:', err)
|
||||
}
|
||||
setAuthenticated(false)
|
||||
}}
|
||||
>
|
||||
Выйти
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleOpenLogsDialog}
|
||||
>
|
||||
Логи
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch('/api/admin/logout', { method: 'POST' })
|
||||
} catch (err) {
|
||||
console.error('Logout error:', err)
|
||||
}
|
||||
setAuthenticated(false)
|
||||
}}
|
||||
>
|
||||
Выйти
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -371,6 +470,34 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDefaultPassword && (
|
||||
<Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-yellow-800 dark:text-yellow-200">Внимание: используется стандартный пароль</CardTitle>
|
||||
<CardDescription className="text-yellow-700 dark:text-yellow-300">
|
||||
Для безопасности рекомендуется сменить пароль на более надежный
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => setShowChangePasswordDialog(true)} variant="default">
|
||||
Сменить пароль
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Безопасность</CardTitle>
|
||||
<CardDescription>Управление паролем администратора</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => setShowChangePasswordDialog(true)} variant="outline">
|
||||
Сменить пароль
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Настройки</CardTitle>
|
||||
@@ -385,16 +512,24 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
Включить или выключить навигацию по неделям в расписании
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.weekNavigationEnabled}
|
||||
onChange={(e) => handleUpdateSettings({ ...settings, weekNavigationEnabled: e.target.checked })}
|
||||
disabled={loading}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
|
||||
</label>
|
||||
<ToggleSwitch
|
||||
checked={settings.weekNavigationEnabled}
|
||||
onChange={(checked) => handleUpdateSettings({ ...settings, weekNavigationEnabled: checked })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<div className="font-semibold">Кнопка "Добавить группу"</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Отображать кнопку "Добавить группу" на главной странице
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={settings.showAddGroupButton ?? true}
|
||||
onChange={(checked) => handleUpdateSettings({ ...settings, showAddGroupButton: checked })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -468,22 +603,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
Принудительно использовать кэш, даже если он свежий (симулирует ошибку парсинга)
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.debug?.forceCache ?? false}
|
||||
onChange={(e) => handleUpdateSettings({
|
||||
...settings,
|
||||
debug: {
|
||||
...settings.debug,
|
||||
forceCache: e.target.checked
|
||||
}
|
||||
})}
|
||||
disabled={loading}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
|
||||
</label>
|
||||
<ToggleSwitch
|
||||
checked={settings.debug?.forceCache ?? false}
|
||||
onChange={(checked) => handleUpdateSettings({
|
||||
...settings,
|
||||
debug: {
|
||||
...settings.debug,
|
||||
forceCache: checked
|
||||
}
|
||||
})}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
@@ -492,22 +622,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
Показать пустое расписание независимо от реальных данных
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.debug?.forceEmpty ?? false}
|
||||
onChange={(e) => handleUpdateSettings({
|
||||
...settings,
|
||||
debug: {
|
||||
...settings.debug,
|
||||
forceEmpty: e.target.checked
|
||||
}
|
||||
})}
|
||||
disabled={loading}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
|
||||
</label>
|
||||
<ToggleSwitch
|
||||
checked={settings.debug?.forceEmpty ?? false}
|
||||
onChange={(checked) => handleUpdateSettings({
|
||||
...settings,
|
||||
debug: {
|
||||
...settings.debug,
|
||||
forceEmpty: checked
|
||||
}
|
||||
})}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
@@ -516,22 +641,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
Показать страницу ошибки независимо от реальных данных
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.debug?.forceError ?? false}
|
||||
onChange={(e) => handleUpdateSettings({
|
||||
...settings,
|
||||
debug: {
|
||||
...settings.debug,
|
||||
forceError: e.target.checked
|
||||
}
|
||||
})}
|
||||
disabled={loading}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
|
||||
</label>
|
||||
<ToggleSwitch
|
||||
checked={settings.debug?.forceError ?? false}
|
||||
onChange={(checked) => handleUpdateSettings({
|
||||
...settings,
|
||||
debug: {
|
||||
...settings.debug,
|
||||
forceError: checked
|
||||
}
|
||||
})}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
@@ -540,22 +660,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
Симулировать таймаут при загрузке расписания
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.debug?.forceTimeout ?? false}
|
||||
onChange={(e) => handleUpdateSettings({
|
||||
...settings,
|
||||
debug: {
|
||||
...settings.debug,
|
||||
forceTimeout: e.target.checked
|
||||
}
|
||||
})}
|
||||
disabled={loading}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
|
||||
</label>
|
||||
<ToggleSwitch
|
||||
checked={settings.debug?.forceTimeout ?? false}
|
||||
onChange={(checked) => handleUpdateSettings({
|
||||
...settings,
|
||||
debug: {
|
||||
...settings.debug,
|
||||
forceTimeout: checked
|
||||
}
|
||||
})}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
@@ -564,22 +679,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
Показать дополнительную информацию о кэше в интерфейсе
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.debug?.showCacheInfo ?? false}
|
||||
onChange={(e) => handleUpdateSettings({
|
||||
...settings,
|
||||
debug: {
|
||||
...settings.debug,
|
||||
showCacheInfo: e.target.checked
|
||||
}
|
||||
})}
|
||||
disabled={loading}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
|
||||
</label>
|
||||
<ToggleSwitch
|
||||
checked={settings.debug?.showCacheInfo ?? false}
|
||||
onChange={(checked) => handleUpdateSettings({
|
||||
...settings,
|
||||
debug: {
|
||||
...settings.debug,
|
||||
showCacheInfo: checked
|
||||
}
|
||||
})}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -639,21 +749,11 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="add-course">Курс</Label>
|
||||
<Select
|
||||
<CourseSelect
|
||||
value={formData.course}
|
||||
onValueChange={(value) => setFormData({ ...formData, course: value })}
|
||||
>
|
||||
<SelectTrigger id="add-course">
|
||||
<SelectValue placeholder="Выберите курс" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 курс</SelectItem>
|
||||
<SelectItem value="2">2 курс</SelectItem>
|
||||
<SelectItem value="3">3 курс</SelectItem>
|
||||
<SelectItem value="4">4 курс</SelectItem>
|
||||
<SelectItem value="5">5 курс</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
onChange={(value) => setFormData({ ...formData, course: value })}
|
||||
id="add-course"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
@@ -712,21 +812,11 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-course">Курс</Label>
|
||||
<Select
|
||||
<CourseSelect
|
||||
value={formData.course}
|
||||
onValueChange={(value) => setFormData({ ...formData, course: value })}
|
||||
>
|
||||
<SelectTrigger id="edit-course">
|
||||
<SelectValue placeholder="Выберите курс" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 курс</SelectItem>
|
||||
<SelectItem value="2">2 курс</SelectItem>
|
||||
<SelectItem value="3">3 курс</SelectItem>
|
||||
<SelectItem value="4">4 курс</SelectItem>
|
||||
<SelectItem value="5">5 курс</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
onChange={(value) => setFormData({ ...formData, course: value })}
|
||||
id="edit-course"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
@@ -751,17 +841,161 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
Это действие нельзя отменить.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooterButtons
|
||||
onCancel={() => setShowDeleteDialog(false)}
|
||||
onSubmit={handleDeleteGroup}
|
||||
submitLabel="Удалить"
|
||||
loading={loading}
|
||||
submitVariant="destructive"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Диалог просмотра логов */}
|
||||
<Dialog open={showLogsDialog} onOpenChange={setShowLogsDialog}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Логи ошибок</DialogTitle>
|
||||
<DialogDescription>
|
||||
Содержимое файла error.log
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
{logsLoading ? (
|
||||
<div className="p-4 text-center text-muted-foreground">Загрузка логов...</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<pre className="p-4 bg-muted rounded-md overflow-auto max-h-[60vh] text-sm font-mono whitespace-pre-wrap break-words">
|
||||
{logs || 'Логи пусты'}
|
||||
</pre>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={loadLogs}
|
||||
>
|
||||
Обновить
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setShowDeleteDialog(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" onClick={handleDeleteGroup} disabled={loading}>
|
||||
{loading ? 'Удаление...' : 'Удалить'}
|
||||
<Button type="button" variant="outline" onClick={() => setShowLogsDialog(false)}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Диалог смены пароля */}
|
||||
<Dialog open={showChangePasswordDialog} onOpenChange={setShowChangePasswordDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Сменить пароль</DialogTitle>
|
||||
<DialogDescription>
|
||||
Введите старый пароль и новый пароль (минимум 8 символов)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Валидация на клиенте
|
||||
if (passwordFormData.newPassword.length < 8) {
|
||||
setError('Новый пароль должен содержать минимум 8 символов')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordFormData.newPassword !== passwordFormData.confirmPassword) {
|
||||
setError('Новые пароли не совпадают')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
oldPassword: passwordFormData.oldPassword,
|
||||
newPassword: passwordFormData.newPassword
|
||||
})
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (res.ok && data.success) {
|
||||
setShowChangePasswordDialog(false)
|
||||
setPasswordFormData({ oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||
setIsDefaultPassword(false) // После смены пароля он больше не дефолтный
|
||||
showToast('Пароль успешно изменен', 'success')
|
||||
} else {
|
||||
const errorMessage = data.error || 'Ошибка при смене пароля'
|
||||
setError(errorMessage)
|
||||
showToast(errorMessage, 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = 'Ошибка соединения с сервером'
|
||||
setError(errorMessage)
|
||||
showToast(errorMessage, 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="old-password">Старый пароль</Label>
|
||||
<Input
|
||||
id="old-password"
|
||||
type="password"
|
||||
value={passwordFormData.oldPassword}
|
||||
onChange={(e) => setPasswordFormData({ ...passwordFormData, oldPassword: e.target.value })}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">Новый пароль</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={passwordFormData.newPassword}
|
||||
onChange={(e) => setPasswordFormData({ ...passwordFormData, newPassword: e.target.value })}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Минимум 8 символов
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">Подтверждение нового пароля</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={passwordFormData.confirmPassword}
|
||||
onChange={(e) => setPasswordFormData({ ...passwordFormData, confirmPassword: e.target.value })}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setShowChangePasswordDialog(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Сохранение...' : 'Сменить пароль'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Toast уведомления */}
|
||||
<ToastContainer toasts={toasts} onClose={removeToast} />
|
||||
</>
|
||||
@@ -771,11 +1005,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
export const getServerSideProps: GetServerSideProps<AdminPageProps> = async () => {
|
||||
const groups = loadGroups()
|
||||
const settings = loadSettings()
|
||||
|
||||
// Проверяем, используется ли дефолтный пароль
|
||||
const { isDefaultPassword } = await import('@/shared/data/database')
|
||||
const isDefault = await isDefaultPassword()
|
||||
|
||||
return {
|
||||
props: {
|
||||
groups,
|
||||
settings
|
||||
settings,
|
||||
isDefaultPassword: isDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
39
src/pages/api/admin/change-password.ts
Normal file
39
src/pages/api/admin/change-password.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
|
||||
import { changePassword } from '@/shared/data/database'
|
||||
import { validatePassword } from '@/shared/utils/validation'
|
||||
|
||||
type ResponseData = ApiResponse
|
||||
|
||||
async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>
|
||||
) {
|
||||
const { oldPassword, newPassword } = req.body
|
||||
|
||||
if (!oldPassword || typeof oldPassword !== 'string') {
|
||||
res.status(400).json({ error: 'Old password is required' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!newPassword || typeof newPassword !== 'string') {
|
||||
res.status(400).json({ error: 'New password is required' })
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация нового пароля (минимум 8 символов)
|
||||
if (!validatePassword(newPassword)) {
|
||||
res.status(400).json({ error: 'New password must be at least 8 characters long' })
|
||||
return
|
||||
}
|
||||
|
||||
const success = await changePassword(oldPassword, newPassword)
|
||||
if (success) {
|
||||
res.status(200).json({ success: true })
|
||||
} else {
|
||||
res.status(401).json({ error: 'Invalid old password' })
|
||||
}
|
||||
}
|
||||
|
||||
export default withAuth(handler, ['POST'])
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { requireAuth } from '@/shared/utils/auth'
|
||||
import { loadGroups, saveGroups, GroupsData } from '@/shared/data/groups-loader'
|
||||
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
|
||||
import { loadGroups, saveGroups, clearGroupsCache, GroupsData } from '@/shared/data/groups-loader'
|
||||
import { validateGroupId, validateCourse } from '@/shared/utils/validation'
|
||||
|
||||
type ResponseData = {
|
||||
type ResponseData = ApiResponse<{
|
||||
groups?: GroupsData
|
||||
success?: boolean
|
||||
error?: string
|
||||
}
|
||||
}>
|
||||
|
||||
async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>
|
||||
) {
|
||||
if (req.method === 'GET') {
|
||||
// Получение списка групп
|
||||
const groups = loadGroups()
|
||||
// Получение списка групп (всегда свежие данные для админ-панели)
|
||||
clearGroupsCache()
|
||||
const groups = loadGroups(true)
|
||||
res.status(200).json({ groups })
|
||||
return
|
||||
}
|
||||
@@ -28,6 +28,11 @@ async function handler(
|
||||
return
|
||||
}
|
||||
|
||||
if (!validateGroupId(id)) {
|
||||
res.status(400).json({ error: 'Group ID must contain only lowercase letters, numbers, dashes and underscores' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!parseId || typeof parseId !== 'number') {
|
||||
res.status(400).json({ error: 'Parse ID must be a number' })
|
||||
return
|
||||
@@ -40,17 +45,11 @@ async function handler(
|
||||
|
||||
// Валидация курса (1-5)
|
||||
const groupCourse = course !== undefined ? Number(course) : 1
|
||||
if (!Number.isInteger(groupCourse) || groupCourse < 1 || groupCourse > 5) {
|
||||
if (!validateCourse(groupCourse)) {
|
||||
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()
|
||||
|
||||
// Проверка на дубликат
|
||||
@@ -66,23 +65,14 @@ async function handler(
|
||||
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' })
|
||||
}
|
||||
saveGroups(groups)
|
||||
// Сбрасываем кеш и загружаем свежие данные из БД
|
||||
clearGroupsCache()
|
||||
const updatedGroups = loadGroups(true)
|
||||
res.status(200).json({ success: true, groups: updatedGroups })
|
||||
return
|
||||
}
|
||||
|
||||
res.status(405).json({ error: 'Method not allowed' })
|
||||
}
|
||||
|
||||
export default function protectedHandler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>
|
||||
) {
|
||||
return requireAuth(req, res, handler)
|
||||
}
|
||||
export default withAuth(handler, ['GET', 'POST'])
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { requireAuth } from '@/shared/utils/auth'
|
||||
import { loadGroups, saveGroups, GroupsData } from '@/shared/data/groups-loader'
|
||||
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
|
||||
import { loadGroups, saveGroups, clearGroupsCache, GroupsData } from '@/shared/data/groups-loader'
|
||||
import { validateCourse } from '@/shared/utils/validation'
|
||||
|
||||
type ResponseData = {
|
||||
success?: boolean
|
||||
type ResponseData = ApiResponse<{
|
||||
groups?: GroupsData
|
||||
error?: string
|
||||
}
|
||||
}>
|
||||
|
||||
async function handler(
|
||||
req: NextApiRequest,
|
||||
@@ -19,7 +18,8 @@ async function handler(
|
||||
return
|
||||
}
|
||||
|
||||
const groups = loadGroups()
|
||||
// Загружаем группы с проверкой кеша
|
||||
let groups = loadGroups()
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
// Редактирование группы
|
||||
@@ -40,12 +40,9 @@ async function handler(
|
||||
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
|
||||
}
|
||||
if (course !== undefined && !validateCourse(course)) {
|
||||
res.status(400).json({ error: 'Course must be a number between 1 and 5' })
|
||||
return
|
||||
}
|
||||
|
||||
// Обновляем группу
|
||||
@@ -56,13 +53,11 @@ async function handler(
|
||||
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' })
|
||||
}
|
||||
saveGroups(groups)
|
||||
// Сбрасываем кеш и загружаем свежие данные из БД
|
||||
clearGroupsCache()
|
||||
const updatedGroups = loadGroups(true)
|
||||
res.status(200).json({ success: true, groups: updatedGroups })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -75,23 +70,14 @@ async function handler(
|
||||
|
||||
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' })
|
||||
}
|
||||
saveGroups(groups)
|
||||
// Сбрасываем кеш и загружаем свежие данные из БД
|
||||
clearGroupsCache()
|
||||
const updatedGroups = loadGroups(true)
|
||||
res.status(200).json({ success: true, groups: updatedGroups })
|
||||
return
|
||||
}
|
||||
|
||||
res.status(405).json({ error: 'Method not allowed' })
|
||||
}
|
||||
|
||||
export default function protectedHandler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>
|
||||
) {
|
||||
return requireAuth(req, res, handler)
|
||||
}
|
||||
export default withAuth(handler, ['PUT', 'DELETE'])
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ function recordFailedAttempt(ip: string): void {
|
||||
})
|
||||
}
|
||||
|
||||
export default function handler(
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>
|
||||
) {
|
||||
@@ -109,7 +109,8 @@ export default function handler(
|
||||
return
|
||||
}
|
||||
|
||||
if (verifyPassword(password)) {
|
||||
const isValid = await verifyPassword(password)
|
||||
if (isValid) {
|
||||
// Успешный вход - сбрасываем rate limit
|
||||
rateLimitMap.delete(clientIP)
|
||||
setSessionCookie(res)
|
||||
|
||||
36
src/pages/api/admin/logs.ts
Normal file
36
src/pages/api/admin/logs.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
type ResponseData = ApiResponse<{
|
||||
logs?: string
|
||||
}>
|
||||
|
||||
async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>
|
||||
) {
|
||||
// Путь к файлу логов (в корне проекта)
|
||||
const logPath = path.join(process.cwd(), 'error.log')
|
||||
|
||||
// Проверяем существование файла
|
||||
if (!fs.existsSync(logPath)) {
|
||||
res.status(200).json({ success: true, logs: 'Файл логов пуст или не существует.' })
|
||||
return
|
||||
}
|
||||
|
||||
// Читаем файл
|
||||
const logs = fs.readFileSync(logPath, 'utf8')
|
||||
|
||||
// Если файл пуст
|
||||
if (!logs || logs.trim().length === 0) {
|
||||
res.status(200).json({ success: true, logs: 'Файл логов пуст.' })
|
||||
return
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, logs })
|
||||
}
|
||||
|
||||
export default withAuth(handler, ['GET'])
|
||||
|
||||
@@ -1,33 +1,37 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { requireAuth } from '@/shared/utils/auth'
|
||||
import { loadSettings, saveSettings, AppSettings } from '@/shared/data/settings-loader'
|
||||
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
|
||||
import { loadSettings, saveSettings, clearSettingsCache, AppSettings } from '@/shared/data/settings-loader'
|
||||
|
||||
type ResponseData = {
|
||||
type ResponseData = ApiResponse<{
|
||||
settings?: AppSettings
|
||||
success?: boolean
|
||||
error?: string
|
||||
}
|
||||
}>
|
||||
|
||||
async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>
|
||||
) {
|
||||
if (req.method === 'GET') {
|
||||
// Получение настроек
|
||||
const settings = loadSettings()
|
||||
// Получение настроек (всегда свежие данные для админ-панели)
|
||||
clearSettingsCache()
|
||||
const settings = loadSettings(true)
|
||||
res.status(200).json({ settings })
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
// Обновление настроек
|
||||
const { weekNavigationEnabled, debug } = req.body
|
||||
const { weekNavigationEnabled, showAddGroupButton, debug } = req.body
|
||||
|
||||
if (typeof weekNavigationEnabled !== 'boolean') {
|
||||
res.status(400).json({ error: 'weekNavigationEnabled must be a boolean' })
|
||||
return
|
||||
}
|
||||
|
||||
if (showAddGroupButton !== undefined && typeof showAddGroupButton !== 'boolean') {
|
||||
res.status(400).json({ error: 'showAddGroupButton must be a boolean' })
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация debug опций (только в dev режиме)
|
||||
if (debug !== undefined) {
|
||||
if (typeof debug !== 'object' || debug === null) {
|
||||
@@ -51,32 +55,20 @@ async function handler(
|
||||
|
||||
const settings: AppSettings = {
|
||||
weekNavigationEnabled,
|
||||
showAddGroupButton: showAddGroupButton !== undefined ? showAddGroupButton : true,
|
||||
...(debug !== undefined && { debug })
|
||||
}
|
||||
|
||||
try {
|
||||
saveSettings(settings)
|
||||
// Сбрасываем кеш и загружаем свежие настройки для подтверждения
|
||||
const { clearSettingsCache } = await import('@/shared/data/settings-loader')
|
||||
clearSettingsCache()
|
||||
const savedSettings = loadSettings()
|
||||
res.status(200).json({ success: true, settings: savedSettings })
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error)
|
||||
res.status(500).json({ error: 'Failed to save settings' })
|
||||
}
|
||||
saveSettings(settings)
|
||||
// Сбрасываем кеш и загружаем свежие настройки для подтверждения
|
||||
clearSettingsCache()
|
||||
const savedSettings = loadSettings(true)
|
||||
res.status(200).json({ success: true, settings: savedSettings })
|
||||
return
|
||||
}
|
||||
|
||||
res.status(405).json({ error: 'Method not allowed' })
|
||||
}
|
||||
|
||||
export default function protectedHandler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>
|
||||
) {
|
||||
return requireAuth(req, res, handler)
|
||||
}
|
||||
export default withAuth(handler, ['GET', 'PUT'])
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
type Data = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export default function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Data>
|
||||
) {
|
||||
res.status(200).json({ name: 'John Doe' })
|
||||
}
|
||||
@@ -1,6 +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 { Card, CardContent, CardHeader, CardTitle } from '@/shadcn/ui/card'
|
||||
import { Button } from '@/shadcn/ui/button'
|
||||
import { ThemeSwitcher } from '@/features/theme-switch'
|
||||
@@ -24,10 +25,11 @@ import { BsTelegram } from 'react-icons/bs'
|
||||
type HomePageProps = {
|
||||
groups: GroupsData
|
||||
groupsByCourse: { [course: number]: Array<{ id: string; name: string }> }
|
||||
showAddGroupButton: boolean
|
||||
}
|
||||
|
||||
export default function HomePage({ groups, groupsByCourse }: HomePageProps) {
|
||||
const [openCourses, setOpenCourses] = React.useState<Set<number>>(new Set([1]))
|
||||
export default function HomePage({ groups, groupsByCourse, showAddGroupButton }: HomePageProps) {
|
||||
const [openCourses, setOpenCourses] = React.useState<Set<number>>(new Set())
|
||||
const [addGroupDialogOpen, setAddGroupDialogOpen] = React.useState(false)
|
||||
|
||||
// Подсчитываем смещения для каждого курса для последовательной анимации
|
||||
@@ -148,28 +150,30 @@ export default function HomePage({ groups, groupsByCourse }: HomePageProps) {
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8">
|
||||
<div
|
||||
className="stagger-card"
|
||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.05}s` } as React.CSSProperties}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setAddGroupDialogOpen(true)}
|
||||
className="gap-2"
|
||||
{showAddGroupButton && (
|
||||
<div
|
||||
className="stagger-card"
|
||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.05}s` } as React.CSSProperties}
|
||||
>
|
||||
<MdAdd className="h-4 w-4" />
|
||||
Добавить группу
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setAddGroupDialogOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<MdAdd className="h-4 w-4" />
|
||||
Добавить группу
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="stagger-card"
|
||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.08}s` } as React.CSSProperties}
|
||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + (showAddGroupButton ? 0.08 : 0.05)}s` } as React.CSSProperties}
|
||||
>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
<div
|
||||
className="stagger-card"
|
||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.11}s` } as React.CSSProperties}
|
||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + (showAddGroupButton ? 0.11 : 0.08)}s` } as React.CSSProperties}
|
||||
>
|
||||
<Link href={GITHUB_REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" className="gap-2">
|
||||
@@ -208,7 +212,9 @@ export default function HomePage({ groups, groupsByCourse }: HomePageProps) {
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<HomePageProps> = async () => {
|
||||
// Используем кеш (обновляется каждую минуту автоматически)
|
||||
const groups = loadGroups()
|
||||
const settings = loadSettings()
|
||||
|
||||
// Группируем группы по курсам
|
||||
const groupsByCourse: { [course: number]: Array<{ id: string; name: string }> } = {}
|
||||
@@ -229,7 +235,8 @@ export const getServerSideProps: GetServerSideProps<HomePageProps> = async () =>
|
||||
return {
|
||||
props: {
|
||||
groups,
|
||||
groupsByCourse
|
||||
groupsByCourse,
|
||||
showAddGroupButton: settings.showAddGroupButton ?? true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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<ISitemapField>(group => (
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user