6 Commits

Author SHA1 Message Date
243abe55b5 Fix service status 2025-12-20 02:28:41 +07:00
c645171671 Add static pages and styles 2025-12-20 02:01:51 +07:00
07745ea4ed Add TG banner 2025-12-20 01:07:24 +07:00
22385e8742 Fix auth refresh 2025-12-20 00:43:36 +07:00
a77a757317 Add reset password to admin panel 2025-12-20 00:34:22 +07:00
2d281d1c8c Add search and fetch user account 2025-12-20 00:17:58 +07:00
26 changed files with 1394 additions and 431 deletions

View File

@@ -22,353 +22,7 @@ Success: #22c55e
Error: #ef4444 Error: #ef4444
Text: #e2e8f0 Text: #e2e8f0
Text Muted: #64748b Text Muted: #64748b
``` ```А ты
---
## Фаза 1: Базовая инфраструктура
### 1.1 Обновление Tailwind Config
- [ ] Новая цветовая палитра (neon colors)
- [ ] Кастомные анимации:
- `glitch` - glitch эффект для текста
- `glow-pulse` - пульсация свечения
- `float` - плавное парение
- `slide-in-left/right/up/down` - слайды
- `scale-in` - появление с масштабом
- `shimmer` - блик на элементах
- [ ] Кастомные backdrop-blur классы
- [ ] Градиентные утилиты
### 1.2 Глобальные стили (index.css)
- [ ] CSS переменные для цветов
- [ ] Glitch keyframes анимация
- [ ] Noise/grain overlay
- [ ] Glow эффекты (box-shadow неон)
- [ ] Custom scrollbar (неоновый)
- [ ] Selection стили (выделение текста)
### 1.3 Новые UI компоненты
- [ ] `GlitchText` - текст с glitch эффектом
- [ ] `NeonButton` - кнопка с неоновым свечением
- [ ] `GlassCard` - карточка с glassmorphism
- [ ] `AnimatedCounter` - анимированные числа
- [ ] `ProgressBar` - неоновый прогресс бар
- [ ] `Badge` - бейджи со свечением
- [ ] `Skeleton` - скелетоны загрузки
- [ ] `Tooltip` - тултипы
- [ ] `Tabs` - табы с анимацией
- [ ] `Modal` - переработанная модалка
---
## Фаза 2: Layout и навигация
### 2.1 Новый Header
- [ ] Sticky header с blur при скролле
- [ ] Логотип с glitch эффектом при hover
- [ ] Анимированная навигация (underline slide)
- [ ] Notification bell с badge
- [ ] User dropdown с аватаром
- [ ] Mobile hamburger menu с slide-in
### 2.2 Sidebar (новый компонент)
- [ ] Collapsible sidebar для desktop
- [ ] Иконки с tooltip
- [ ] Active state с неоновой подсветкой
- [ ] Quick stats внизу
### 2.3 Footer
- [ ] Минималистичный footer
- [ ] Social links
- [ ] Version info
---
## Фаза 3: Страницы
### 3.1 HomePage (полный редизайн)
```
┌─────────────────────────────────────────────┐
│ HERO SECTION │
│ ┌─────────────────────────────────────┐ │
│ │ Animated background (particles/ │ │
│ │ geometric shapes) │ │
│ │ │ │
│ │ GAME <glitch>MARATHON</glitch> │ │
│ │ │ │
│ │ Tagline with typing effect │ │
│ │ │ │
│ │ [Start Playing] [Watch Demo] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ FEATURES SECTION (3 glass cards) │
│ ┌───────┐ ┌───────┐ ┌───────┐ │
│ │ Icon │ │ Icon │ │ Icon │ hover: │
│ │ Title │ │ Title │ │ Title │ lift + │
│ │ Desc │ │ Desc │ │ Desc │ glow │
│ └───────┘ └───────┘ └───────┘ │
├─────────────────────────────────────────────┤
│ HOW IT WORKS (timeline style) │
│ ○───────○───────○───────○ │
│ 1 2 3 4 │
│ Create Add Spin Win │
├─────────────────────────────────────────────┤
│ LIVE STATS (animated counters) │
│ [ 1,234 Marathons ] [ 5,678 Challenges ] │
├─────────────────────────────────────────────┤
│ CTA SECTION │
│ Ready to compete? [Join Now] │
└─────────────────────────────────────────────┘
```
### 3.2 Login/Register Pages
- [ ] Centered card с glassmorphism
- [ ] Animated background (subtle)
- [ ] Form inputs с glow при focus
- [ ] Password strength indicator
- [ ] Social login buttons (future)
- [ ] Smooth transitions между login/register
### 3.3 MarathonsPage (Dashboard)
```
┌─────────────────────────────────────────────┐
│ Header: "My Marathons" + Create button │
├─────────────────────────────────────────────┤
│ Quick Stats Bar │
│ [Active: 2] [Completed: 5] [Total Wins: 3]│
├─────────────────────────────────────────────┤
│ Filters/Tabs: All | Active | Completed │
├─────────────────────────────────────────────┤
│ Marathon Cards Grid (2-3 columns) │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Cover image/ │ │ │ │
│ │ gradient │ │ │ │
│ │ ──────────── │ │ │ │
│ │ Title │ │ │ │
│ │ Status badge │ │ │ │
│ │ Participants │ │ │ │
│ │ Progress bar │ │ │ │
│ └──────────────────┘ └──────────────────┘ │
├─────────────────────────────────────────────┤
│ Join by Code (expandable section) │
└─────────────────────────────────────────────┘
```
### 3.4 MarathonPage (Detail)
```
┌─────────────────────────────────────────────┐
│ Hero Banner │
│ ┌─────────────────────────────────────┐ │
│ │ Background gradient + pattern │ │
│ │ Marathon Title (large) │ │
│ │ Status | Dates | Participants │ │
│ │ [Play] [Leaderboard] [Settings] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Event Banner (if active) - animated │
├────────────────────┬────────────────────────┤
│ Main Content │ Sidebar │
│ ┌──────────────┐ │ ┌──────────────────┐ │
│ │ Your Stats │ │ │ Activity Feed │ │
│ │ Points/Streak│ │ │ (scrollable) │ │
│ └──────────────┘ │ │ │ │
│ ┌──────────────┐ │ │ │ │
│ │ Quick Actions│ │ │ │ │
│ └──────────────┘ │ │ │ │
│ ┌──────────────┐ │ │ │ │
│ │ Games List │ │ │ │ │
│ │ (collapsible)│ │ │ │ │
│ └──────────────┘ │ └──────────────────┘ │
└────────────────────┴────────────────────────┘
```
### 3.5 PlayPage (Game Screen) - ГЛАВНАЯ СТРАНИЦА
```
┌─────────────────────────────────────────────┐
│ Top Bar: Points | Streak | Event Timer │
├─────────────────────────────────────────────┤
│ ┌─────────────────────────────────────┐ │
│ │ SPIN WHEEL │ │
│ │ (redesigned, neon style) │ │
│ │ ┌─────────┐ │ │
│ │ │ WHEEL │ │ │
│ │ │ │ │ │
│ │ └─────────┘ │ │
│ │ [SPIN BUTTON] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Active Challenge Card (если есть) │
│ ┌─────────────────────────────────────┐ │
│ │ Game: [Title] | Difficulty: [★★★] │ │
│ │ ─────────────────────────────────── │ │
│ │ Challenge Title │ │
│ │ Description... │ │
│ │ ─────────────────────────────────── │ │
│ │ Points: 100 | Time: ~2h │ │
│ │ ─────────────────────────────────── │ │
│ │ Proof Upload Area │ │
│ │ [File] [URL] [Comment] │ │
│ │ ─────────────────────────────────── │ │
│ │ [Complete ✓] [Skip ✗] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Mini Leaderboard (top 3 + you) │
└─────────────────────────────────────────────┘
```
### 3.6 LeaderboardPage
- [ ] Animated podium для top 3
- [ ] Table с hover эффектами
- [ ] Highlight для текущего пользователя
- [ ] Streak fire animation
- [ ] Sorting/filtering
### 3.7 ProfilePage
```
┌─────────────────────────────────────────────┐
│ Profile Header │
│ ┌─────────────────────────────────────┐ │
│ │ Avatar (large, glow border) │ │
│ │ Nickname [Edit] │ │
│ │ Member since: Date │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Stats Cards (animated counters) │
│ [Marathons] [Wins] [Challenges] [Points] │
├─────────────────────────────────────────────┤
│ Achievements Section (future) │
├─────────────────────────────────────────────┤
│ Telegram Connection Card │
├─────────────────────────────────────────────┤
│ Security Section (password change) │
└─────────────────────────────────────────────┘
```
### 3.8 LobbyPage
- [ ] Step-by-step wizard UI
- [ ] Game cards grid с preview
- [ ] Challenge preview с difficulty badges
- [ ] AI generation progress animation
- [ ] Launch countdown
---
## Фаза 4: Специальные компоненты
### 4.1 SpinWheel (полный редизайн)
- [ ] 3D perspective эффект
- [ ] Неоновые сегменты с названиями игр
- [ ] Particle effects при кручении
- [ ] Glow trail за указателем
- [ ] Sound effects (optional)
- [ ] Confetti при выигрыше
### 4.2 EventBanner (переработка)
- [ ] Pulsating glow border
- [ ] Countdown timer с flip animation
- [ ] Event-specific icons/colors
- [ ] Dismiss animation
### 4.3 ActivityFeed (переработка)
- [ ] Timeline style
- [ ] Avatar circles
- [ ] Type-specific icons
- [ ] Hover для деталей
- [ ] New items animation (slide-in)
### 4.4 Challenge Cards
- [ ] Difficulty stars/badges
- [ ] Progress indicator
- [ ] Expandable details
- [ ] Proof preview thumbnail
---
## Фаза 5: Анимации и эффекты
### 5.1 Page Transitions
- [ ] Framer Motion page transitions
- [ ] Fade + slide between routes
- [ ] Loading skeleton screens
### 5.2 Micro-interactions
- [ ] Button press effects
- [ ] Input focus glow
- [ ] Success checkmark animation
- [ ] Error shake animation
- [ ] Loading spinners (custom)
### 5.3 Background Effects
- [ ] Animated gradient mesh
- [ ] Floating particles (optional)
- [ ] Grid pattern overlay
- [ ] Noise texture
### 5.4 Special Effects
- [ ] Glitch text на заголовках
- [ ] Neon glow на важных элементах
- [ ] Shimmer effect на loading
- [ ] Confetti на achievements
---
## Фаза 6: Responsive и Polish
### 6.1 Mobile Optimization
- [ ] Touch-friendly targets
- [ ] Swipe gestures
- [ ] Bottom navigation (mobile)
- [ ] Collapsible sections
### 6.2 Accessibility
- [ ] Keyboard navigation
- [ ] Focus indicators
- [ ] Screen reader support
- [ ] Reduced motion option
### 6.3 Performance
- [ ] Lazy loading images
- [ ] Code splitting
- [ ] Animation optimization
- [ ] Bundle size check
---
## Порядок реализации
### Sprint 1: Фундамент (2-3 дня)
1. Tailwind config + colors
2. Global CSS + animations
3. Base UI components (Button, Card, Input)
4. GlitchText component
5. Updated Layout/Header
### Sprint 2: Core Pages (3-4 дня)
1. HomePage с hero
2. Login/Register
3. MarathonsPage dashboard
4. Profile page
### Sprint 3: Game Flow (3-4 дня)
1. MarathonPage detail
2. SpinWheel redesign
3. PlayPage
4. LeaderboardPage
### Sprint 4: Polish (2-3 дня)
1. LobbyPage
2. Event components
3. Activity feed
4. Animations & transitions
### Sprint 5: Finalization (1-2 дня)
1. Mobile testing
2. Performance optimization
3. Bug fixes
4. Final polish
---
## Референсы для вдохновления ## Референсы для вдохновления

View File

@@ -0,0 +1,346 @@
"""Seed static content
Revision ID: 018_seed_static_content
Revises: 017_admin_logs_nullable_admin_id
Create Date: 2024-12-20
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '018_seed_static_content'
down_revision: Union[str, None] = '017_admin_logs_nullable_admin_id'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
STATIC_CONTENT_DATA = [
{
'key': 'terms_of_service',
'title': 'Пользовательское соглашение',
'content': '''<p class="text-gray-400 mb-6">Настоящее Пользовательское соглашение (далее — «Соглашение») регулирует отношения между администрацией интернет-сервиса «Игровой Марафон» (далее — «Сервис», «Платформа», «Мы») и физическим лицом, использующим Сервис (далее — «Пользователь», «Вы»).</p>
<p class="text-gray-400 mb-6"><strong class="text-white">Дата вступления в силу:</strong> с момента регистрации на Платформе.<br/>
Используя Сервис, Вы подтверждаете, что полностью ознакомились с условиями настоящего Соглашения и принимаете их в полном объёме.</p>
<hr class="my-8 border-dark-600" />
<h2>1. Общие положения</h2>
<p>1.1. Сервис «Игровой Марафон» представляет собой онлайн-платформу для организации и проведения игровых марафонов — соревнований, в рамках которых участники выполняют игровые задания (челленджи) и получают очки за их успешное выполнение.</p>
<p>1.2. Сервис предоставляет Пользователям следующие возможности:</p>
<ul>
<li>Создание и участие в игровых марафонах</li>
<li>Получение случайных игровых заданий различной сложности</li>
<li>Отслеживание прогресса и статистики участников</li>
<li>Участие в специальных игровых событиях</li>
<li>Получение уведомлений через интеграцию с Telegram</li>
</ul>
<p>1.3. Сервис предоставляется на условиях «как есть» (as is). Администрация не гарантирует, что Сервис будет соответствовать ожиданиям Пользователя или работать бесперебойно.</p>
<hr class="my-8 border-dark-600" />
<h2>2. Регистрация и учётная запись</h2>
<p>2.1. Для доступа к функционалу Сервиса необходима регистрация учётной записи. При регистрации Пользователь обязуется предоставить достоверные данные.</p>
<p>2.2. Пользователь несёт полную ответственность за:</p>
<ul>
<li>Сохранность своих учётных данных (логина и пароля)</li>
<li>Все действия, совершённые с использованием его учётной записи</li>
<li>Своевременное уведомление Администрации о несанкционированном доступе к аккаунту</li>
</ul>
<p>2.3. Каждый Пользователь имеет право на одну учётную запись. Создание дополнительных аккаунтов (мультиаккаунтинг) запрещено и влечёт блокировку всех связанных учётных записей.</p>
<p>2.4. Пользователь вправе в любой момент удалить свою учётную запись, обратившись к Администрации. При удалении аккаунта все связанные данные будут безвозвратно удалены.</p>
<hr class="my-8 border-dark-600" />
<h2>3. Правила использования Сервиса</h2>
<p>3.1. <strong class="text-white">При использовании Сервиса запрещается:</strong></p>
<ul>
<li>Использовать читы, эксплойты, модификации и любое стороннее программное обеспечение, дающее нечестное преимущество при выполнении игровых заданий</li>
<li>Предоставлять ложные доказательства выполнения заданий (поддельные скриншоты, видео, достижения)</li>
<li>Передавать доступ к учётной записи третьим лицам</li>
<li>Оскорблять, унижать или преследовать других участников</li>
<li>Распространять спам, рекламу или вредоносный контент</li>
<li>Нарушать работу Сервиса техническими средствами</li>
<li>Использовать Сервис для деятельности, нарушающей законодательство</li>
</ul>
<p>3.2. <strong class="text-white">Правила проведения марафонов:</strong></p>
<ul>
<li>Участники обязаны честно выполнять полученные задания</li>
<li>Доказательства выполнения должны быть подлинными и соответствовать требованиям задания</li>
<li>Отказ от задания (дроп) влечёт штрафные санкции согласно правилам конкретного марафона</li>
<li>Споры по заданиям разрешаются через систему диспутов с участием других участников марафона</li>
</ul>
<p>3.3. Организаторы марафонов несут ответственность за соблюдение правил в рамках своих мероприятий и имеют право устанавливать дополнительные правила, не противоречащие настоящему Соглашению.</p>
<hr class="my-8 border-dark-600" />
<h2>4. Система очков и рейтинг</h2>
<p>4.1. За выполнение заданий Пользователи получают очки, количество которых зависит от сложности задания и активных игровых событий.</p>
<p>4.2. Очки используются исключительно для формирования рейтинга участников в рамках марафонов и не имеют денежного эквивалента.</p>
<p>4.3. Администрация оставляет за собой право корректировать начисленные очки в случае выявления нарушений или технических ошибок.</p>
<hr class="my-8 border-dark-600" />
<h2>5. Ответственность сторон</h2>
<p>5.1. <strong class="text-white">Администрация не несёт ответственности за:</strong></p>
<ul>
<li>Временную недоступность Сервиса по техническим причинам</li>
<li>Потерю данных вследствие технических сбоев</li>
<li>Действия третьих лиц, получивших доступ к учётной записи Пользователя</li>
<li>Контент, размещаемый Пользователями</li>
<li>Качество интернет-соединения Пользователя</li>
</ul>
<p>5.2. Пользователь несёт ответственность за соблюдение условий настоящего Соглашения и применимого законодательства.</p>
<hr class="my-8 border-dark-600" />
<h2>6. Санкции за нарушения</h2>
<p>6.1. За нарушение условий настоящего Соглашения Администрация вправе применить следующие санкции:</p>
<ul>
<li><strong class="text-yellow-400">Предупреждение</strong> — за незначительные нарушения</li>
<li><strong class="text-orange-400">Временная блокировка</strong> — ограничение доступа к Сервису на определённый срок</li>
<li><strong class="text-red-400">Постоянная блокировка</strong> — бессрочное ограничение доступа за грубые или повторные нарушения</li>
</ul>
<p>6.2. Решение о применении санкций принимается Администрацией единолично и является окончательным. Администрация не обязана объяснять причины принятого решения.</p>
<p>6.3. Обход блокировки путём создания новых учётных записей влечёт блокировку всех выявленных аккаунтов.</p>
<hr class="my-8 border-dark-600" />
<h2>7. Интеллектуальная собственность</h2>
<p>7.1. Все элементы Сервиса (дизайн, код, тексты, логотипы) являются объектами интеллектуальной собственности Администрации и защищены применимым законодательством.</p>
<p>7.2. Использование материалов Сервиса без письменного разрешения Администрации запрещено.</p>
<hr class="my-8 border-dark-600" />
<h2>8. Изменение условий Соглашения</h2>
<p>8.1. Администрация вправе в одностороннем порядке изменять условия настоящего Соглашения.</p>
<p>8.2. Актуальная редакция Соглашения размещается на данной странице с указанием даты последнего обновления.</p>
<p>8.3. Продолжение использования Сервиса после внесения изменений означает согласие Пользователя с новой редакцией Соглашения.</p>
<hr class="my-8 border-dark-600" />
<h2>9. Заключительные положения</h2>
<p>9.1. Настоящее Соглашение регулируется законодательством Российской Федерации.</p>
<p>9.2. Все споры, возникающие в связи с использованием Сервиса, подлежат разрешению путём переговоров. При недостижении согласия споры разрешаются в судебном порядке по месту нахождения Администрации.</p>
<p>9.3. Признание судом недействительности какого-либо положения настоящего Соглашения не влечёт недействительности остальных положений.</p>
<p>9.4. По всем вопросам, связанным с использованием Сервиса, Вы можете обратиться к Администрации через Telegram-бота или иные доступные каналы связи.</p>'''
},
{
'key': 'privacy_policy',
'title': 'Политика конфиденциальности',
'content': '''<p class="text-gray-400 mb-6">Настоящая Политика конфиденциальности (далее — «Политика») описывает, как интернет-сервис «Игровой Марафон» (далее — «Сервис», «Мы») собирает, использует, хранит и защищает персональные данные пользователей (далее — «Пользователь», «Вы»).</p>
<p class="text-gray-400 mb-6">Используя Сервис, Вы даёте согласие на обработку Ваших персональных данных в соответствии с условиями настоящей Политики.</p>
<hr class="my-8 border-dark-600" />
<h2>1. Собираемые данные</h2>
<p>1.1. <strong class="text-white">Данные, предоставляемые Пользователем:</strong></p>
<ul>
<li><strong>Регистрационные данные:</strong> логин, пароль (в зашифрованном виде), никнейм</li>
<li><strong>Данные профиля:</strong> аватар (при загрузке)</li>
<li><strong>Данные интеграции Telegram:</strong> Telegram ID, имя пользователя, имя и фамилия (при привязке бота)</li>
</ul>
<p>1.2. <strong class="text-white">Данные, собираемые автоматически:</strong></p>
<ul>
<li><strong>Данные об активности:</strong> участие в марафонах, выполненные задания, заработанные очки, статистика</li>
<li><strong>Технические данные:</strong> IP-адрес, тип браузера, время доступа (для обеспечения безопасности)</li>
<li><strong>Данные сессии:</strong> информация для поддержания авторизации</li>
</ul>
<hr class="my-8 border-dark-600" />
<h2>2. Цели обработки данных</h2>
<p>2.1. Мы обрабатываем Ваши персональные данные для следующих целей:</p>
<p><strong class="text-neon-400">Предоставление услуг:</strong></p>
<ul>
<li>Идентификация и аутентификация Пользователя</li>
<li>Обеспечение участия в марафонах и игровых событиях</li>
<li>Ведение статистики и формирование рейтингов</li>
<li>Отображение профиля Пользователя другим участникам</li>
</ul>
<p><strong class="text-neon-400">Коммуникация:</strong></p>
<ul>
<li>Отправка уведомлений о событиях марафонов через Telegram-бота</li>
<li>Информирование о новых заданиях и результатах</li>
<li>Ответы на обращения Пользователей</li>
</ul>
<p><strong class="text-neon-400">Безопасность:</strong></p>
<ul>
<li>Защита от несанкционированного доступа</li>
<li>Выявление и предотвращение нарушений</li>
<li>Ведение журнала административных действий</li>
</ul>
<hr class="my-8 border-dark-600" />
<h2>3. Правовые основания обработки</h2>
<p>3.1. Обработка персональных данных осуществляется на следующих основаниях:</p>
<ul>
<li><strong>Согласие Пользователя</strong> — при регистрации и использовании Сервиса</li>
<li><strong>Исполнение договора</strong> — Пользовательского соглашения</li>
<li><strong>Законный интерес</strong> — обеспечение безопасности Сервиса</li>
</ul>
<hr class="my-8 border-dark-600" />
<h2>4. Хранение и защита данных</h2>
<p>4.1. <strong class="text-white">Меры безопасности:</strong></p>
<ul>
<li>Пароли хранятся в зашифрованном виде с использованием алгоритма bcrypt</li>
<li>Передача данных осуществляется по защищённому протоколу HTTPS</li>
<li>Доступ к базе данных ограничен и контролируется</li>
<li>Административные действия логируются и требуют двухфакторной аутентификации</li>
</ul>
<p>4.2. <strong class="text-white">Срок хранения:</strong></p>
<ul>
<li>Данные учётной записи хранятся до момента её удаления Пользователем</li>
<li>Данные об активности в марафонах хранятся бессрочно для ведения статистики</li>
<li>Технические логи хранятся в течение 12 месяцев</li>
</ul>
<hr class="my-8 border-dark-600" />
<h2>5. Передача данных третьим лицам</h2>
<p>5.1. Мы не продаём, не сдаём в аренду и не передаём Ваши персональные данные третьим лицам в коммерческих целях.</p>
<p>5.2. <strong class="text-white">Данные могут быть переданы:</strong></p>
<ul>
<li>Telegram — для обеспечения работы уведомлений (только Telegram ID)</li>
<li>Правоохранительным органам — по законному запросу в соответствии с применимым законодательством</li>
</ul>
<p>5.3. <strong class="text-white">Публично доступная информация:</strong></p>
<p>Следующие данные видны другим Пользователям Сервиса:</p>
<ul>
<li>Никнейм</li>
<li>Аватар</li>
<li>Статистика участия в марафонах</li>
<li>Позиция в рейтингах</li>
</ul>
<hr class="my-8 border-dark-600" />
<h2>6. Права Пользователя</h2>
<p>6.1. Вы имеете право:</p>
<ul>
<li><strong>Получить доступ</strong> к своим персональным данным</li>
<li><strong>Исправить</strong> неточные или неполные данные в настройках профиля</li>
<li><strong>Удалить</strong> свою учётную запись и связанные данные</li>
<li><strong>Отозвать согласие</strong> на обработку данных (путём удаления аккаунта)</li>
<li><strong>Отключить</strong> интеграцию с Telegram в любой момент</li>
</ul>
<p>6.2. Для реализации своих прав обратитесь к Администрации через доступные каналы связи.</p>
<hr class="my-8 border-dark-600" />
<h2>7. Файлы cookie и локальное хранилище</h2>
<p>7.1. Сервис использует локальное хранилище браузера (localStorage, sessionStorage) для:</p>
<ul>
<li>Хранения токена авторизации</li>
<li>Сохранения пользовательских настроек интерфейса</li>
<li>Запоминания закрытых информационных баннеров</li>
</ul>
<p>7.2. Вы можете очистить локальное хранилище в настройках браузера, однако это приведёт к выходу из учётной записи.</p>
<hr class="my-8 border-dark-600" />
<h2>8. Обработка данных несовершеннолетних</h2>
<p>8.1. Сервис не предназначен для лиц младше 14 лет. Мы сознательно не собираем персональные данные детей.</p>
<p>8.2. Если Вам стало известно, что ребёнок предоставил нам персональные данные, пожалуйста, свяжитесь с Администрацией для их удаления.</p>
<hr class="my-8 border-dark-600" />
<h2>9. Изменение Политики</h2>
<p>9.1. Мы оставляем за собой право изменять настоящую Политику. Актуальная редакция всегда доступна на данной странице.</p>
<p>9.2. О существенных изменениях мы уведомим Пользователей через Telegram-бота или баннер на сайте.</p>
<p>9.3. Продолжение использования Сервиса после внесения изменений означает согласие с обновлённой Политикой.</p>
<hr class="my-8 border-dark-600" />
<h2>10. Контактная информация</h2>
<p>10.1. По вопросам, связанным с обработкой персональных данных, Вы можете обратиться к Администрации через:</p>
<ul>
<li>Telegram-бота Сервиса</li>
<li>Форму обратной связи (при наличии)</li>
</ul>
<p>10.2. Мы обязуемся рассмотреть Ваше обращение в разумные сроки и предоставить ответ.</p>'''
},
{
'key': 'telegram_bot_info',
'title': 'Привяжите Telegram-бота',
'content': 'Получайте уведомления о событиях марафона, новых заданиях и результатах прямо в Telegram'
},
{
'key': 'announcement',
'title': 'Добро пожаловать!',
'content': 'Мы рады приветствовать вас в «Игровом Марафоне»! Создайте свой первый марафон или присоединитесь к существующему по коду приглашения.'
}
]
def upgrade() -> None:
for item in STATIC_CONTENT_DATA:
# Use ON CONFLICT to avoid duplicates
op.execute(f"""
INSERT INTO static_content (key, title, content, created_at, updated_at)
VALUES ('{item['key']}', '{item['title'].replace("'", "''")}', '{item['content'].replace("'", "''")}', NOW(), NOW())
ON CONFLICT (key) DO NOTHING
""")
def downgrade() -> None:
keys = [f"'{item['key']}'" for item in STATIC_CONTENT_DATA]
op.execute(f"DELETE FROM static_content WHERE key IN ({', '.join(keys)})")

View File

@@ -8,10 +8,11 @@ from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
from app.models import User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent from app.models import User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent
from app.schemas import ( from app.schemas import (
UserPublic, MessageResponse, UserPublic, MessageResponse,
AdminUserResponse, BanUserRequest, AdminLogResponse, AdminLogsListResponse, AdminUserResponse, BanUserRequest, AdminResetPasswordRequest, AdminLogResponse, AdminLogsListResponse,
BroadcastRequest, BroadcastResponse, StaticContentResponse, StaticContentUpdate, BroadcastRequest, BroadcastResponse, StaticContentResponse, StaticContentUpdate,
StaticContentCreate, DashboardStats StaticContentCreate, DashboardStats
) )
from app.core.security import get_password_hash
from app.services.telegram_notifier import telegram_notifier from app.services.telegram_notifier import telegram_notifier
from app.core.rate_limit import limiter from app.core.rate_limit import limiter
@@ -431,6 +432,66 @@ async def unban_user(
) )
# ============ Reset Password ============
@router.post("/users/{user_id}/reset-password", response_model=AdminUserResponse)
async def reset_user_password(
request: Request,
user_id: int,
data: AdminResetPasswordRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Reset user password. Admin only."""
require_admin_with_2fa(current_user)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Hash and save new password
user.password_hash = get_password_hash(data.new_password)
await db.commit()
await db.refresh(user)
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.USER_PASSWORD_RESET.value,
"user", user_id,
{"nickname": user.nickname},
request.client.host if request.client else None
)
# Notify user via Telegram if linked
if user.telegram_id:
await telegram_notifier.send_message(
user.telegram_id,
"🔐 <b>Ваш пароль был сброшен</b>\n\n"
"Администратор установил вам новый пароль. "
"Если это были не вы, свяжитесь с поддержкой."
)
marathons_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
)
return AdminUserResponse(
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=user.banned_at.isoformat() if user.banned_at else None,
banned_until=user.banned_until.isoformat() if user.banned_until else None,
ban_reason=user.ban_reason,
)
# ============ Force Finish Marathon ============ # ============ Force Finish Marathon ============
@router.post("/marathons/{marathon_id}/force-finish", response_model=MessageResponse) @router.post("/marathons/{marathon_id}/force-finish", response_model=MessageResponse)
async def force_finish_marathon( async def force_finish_marathon(
@@ -697,6 +758,37 @@ async def create_content(
return content return content
@router.delete("/content/{key}", response_model=MessageResponse)
async def delete_content(
key: str,
request: Request,
current_user: CurrentUser,
db: DbSession,
):
"""Delete static content. Admin only."""
require_admin_with_2fa(current_user)
result = await db.execute(
select(StaticContent).where(StaticContent.key == key)
)
content = result.scalar_one_or_none()
if not content:
raise HTTPException(status_code=404, detail="Content not found")
await db.delete(content)
await db.commit()
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.CONTENT_UPDATE.value,
"static_content", content.id,
{"action": "delete", "key": key},
request.client.host if request.client else None
)
return {"message": f"Content '{key}' deleted successfully"}
# ============ Dashboard ============ # ============ Dashboard ============
@router.get("/dashboard", response_model=DashboardStats) @router.get("/dashboard", response_model=DashboardStats)
async def get_dashboard(current_user: CurrentUser, db: DbSession): async def get_dashboard(current_user: CurrentUser, db: DbSession):

View File

@@ -12,6 +12,7 @@ class AdminActionType(str, Enum):
USER_UNBAN = "user_unban" USER_UNBAN = "user_unban"
USER_AUTO_UNBAN = "user_auto_unban" # System automatic unban USER_AUTO_UNBAN = "user_auto_unban" # System automatic unban
USER_ROLE_CHANGE = "user_role_change" USER_ROLE_CHANGE = "user_role_change"
USER_PASSWORD_RESET = "user_password_reset"
# Marathon actions # Marathon actions
MARATHON_FORCE_FINISH = "marathon_force_finish" MARATHON_FORCE_FINISH = "marathon_force_finish"

View File

@@ -83,6 +83,7 @@ from app.schemas.dispute import (
) )
from app.schemas.admin import ( from app.schemas.admin import (
BanUserRequest, BanUserRequest,
AdminResetPasswordRequest,
AdminUserResponse, AdminUserResponse,
AdminLogResponse, AdminLogResponse,
AdminLogsListResponse, AdminLogsListResponse,
@@ -175,6 +176,7 @@ __all__ = [
"ReturnedAssignmentResponse", "ReturnedAssignmentResponse",
# Admin # Admin
"BanUserRequest", "BanUserRequest",
"AdminResetPasswordRequest",
"AdminUserResponse", "AdminUserResponse",
"AdminLogResponse", "AdminLogResponse",
"AdminLogsListResponse", "AdminLogsListResponse",

View File

@@ -9,6 +9,10 @@ class BanUserRequest(BaseModel):
banned_until: datetime | None = None # None = permanent ban banned_until: datetime | None = None # None = permanent ban
class AdminResetPasswordRequest(BaseModel):
new_password: str = Field(..., min_length=6, max_length=100)
class AdminUserResponse(BaseModel): class AdminUserResponse(BaseModel):
id: int id: int
login: str login: str

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom' import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { ToastContainer, ConfirmModal } from '@/components/ui' import { ToastContainer, ConfirmModal } from '@/components/ui'
@@ -20,6 +21,7 @@ import { InvitePage } from '@/pages/InvitePage'
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage' import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
import { ProfilePage } from '@/pages/ProfilePage' import { ProfilePage } from '@/pages/ProfilePage'
import { UserProfilePage } from '@/pages/UserProfilePage' import { UserProfilePage } from '@/pages/UserProfilePage'
import { StaticContentPage } from '@/pages/StaticContentPage'
import { NotFoundPage } from '@/pages/NotFoundPage' import { NotFoundPage } from '@/pages/NotFoundPage'
import { TeapotPage } from '@/pages/TeapotPage' import { TeapotPage } from '@/pages/TeapotPage'
import { ServerErrorPage } from '@/pages/ServerErrorPage' import { ServerErrorPage } from '@/pages/ServerErrorPage'
@@ -60,6 +62,12 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
function App() { function App() {
const banInfo = useAuthStore((state) => state.banInfo) const banInfo = useAuthStore((state) => state.banInfo)
const isAuthenticated = useAuthStore((state) => state.isAuthenticated) const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const syncUser = useAuthStore((state) => state.syncUser)
// Sync user data with server on app load
useEffect(() => {
syncUser()
}, [syncUser])
// Show banned screen if user is authenticated and banned // Show banned screen if user is authenticated and banned
if (isAuthenticated && banInfo) { if (isAuthenticated && banInfo) {
@@ -82,6 +90,11 @@ function App() {
{/* Public invite page */} {/* Public invite page */}
<Route path="invite/:code" element={<InvitePage />} /> <Route path="invite/:code" element={<InvitePage />} />
{/* Public static content pages */}
<Route path="terms" element={<StaticContentPage />} />
<Route path="privacy" element={<StaticContentPage />} />
<Route path="page/:key" element={<StaticContentPage />} />
<Route <Route
path="login" path="login"
element={ element={

View File

@@ -52,6 +52,13 @@ export const adminApi = {
return response.data return response.data
}, },
resetUserPassword: async (id: number, newPassword: string): Promise<AdminUser> => {
const response = await client.post<AdminUser>(`/admin/users/${id}/reset-password`, {
new_password: newPassword,
})
return response.data
},
// Marathons // Marathons
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => { listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
const params: Record<string, unknown> = { skip, limit } const params: Record<string, unknown> = { skip, limit }
@@ -114,6 +121,10 @@ export const adminApi = {
const response = await client.post<StaticContent>('/admin/content', { key, title, content }) const response = await client.post<StaticContent>('/admin/content', { key, title, content })
return response.data return response.data
}, },
deleteContent: async (key: string): Promise<void> => {
await client.delete(`/admin/content/${key}`)
},
} }
// Public content API (no auth required) // Public content API (no auth required)

View File

@@ -33,11 +33,16 @@ function isBanInfo(detail: unknown): detail is BanInfo {
client.interceptors.response.use( client.interceptors.response.use(
(response) => response, (response) => response,
(error: AxiosError<{ detail: string | BanInfo }>) => { (error: AxiosError<{ detail: string | BanInfo }>) => {
// Unauthorized - redirect to login // Unauthorized - redirect to login (but not for auth endpoints)
if (error.response?.status === 401) { if (error.response?.status === 401) {
localStorage.removeItem('token') const url = error.config?.url || ''
localStorage.removeItem('user') const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/2fa')
window.location.href = '/login'
if (!isAuthEndpoint) {
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/login'
}
} }
// Forbidden - check if user is banned // Forbidden - check if user is banned

View File

@@ -0,0 +1,78 @@
import { useState, useEffect } from 'react'
import { contentApi } from '@/api/admin'
import { Megaphone, X } from 'lucide-react'
const STORAGE_KEY = 'announcement_dismissed'
export function AnnouncementBanner() {
const [content, setContent] = useState<string | null>(null)
const [title, setTitle] = useState<string | null>(null)
const [updatedAt, setUpdatedAt] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const loadAnnouncement = async () => {
try {
const data = await contentApi.getPublicContent('announcement')
// Check if this announcement was already dismissed (by updated_at)
const dismissedAt = localStorage.getItem(STORAGE_KEY)
if (dismissedAt === data.updated_at) {
setContent(null)
} else {
setContent(data.content)
setTitle(data.title)
setUpdatedAt(data.updated_at)
}
} catch {
// No announcement or error - don't show
setContent(null)
} finally {
setIsLoading(false)
}
}
loadAnnouncement()
}, [])
const handleDismiss = () => {
if (updatedAt) {
// Store the updated_at to know which announcement was dismissed
// When admin updates announcement, updated_at changes and banner shows again
localStorage.setItem(STORAGE_KEY, updatedAt)
setContent(null)
}
}
if (isLoading || !content) {
return null
}
return (
<div className="relative rounded-xl overflow-hidden bg-gradient-to-r from-accent-500/20 via-purple-500/20 to-pink-500/20 border border-accent-500/30">
{/* Close button */}
<button
onClick={handleDismiss}
className="absolute top-3 right-3 p-1.5 text-white bg-dark-700/70 hover:bg-dark-600 rounded-lg transition-colors z-10"
title="Скрыть"
>
<X className="w-4 h-4" />
</button>
{/* Content */}
<div className="p-4 pr-12 flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-500/20 border border-accent-500/30 flex items-center justify-center flex-shrink-0">
<Megaphone className="w-5 h-5 text-accent-400" />
</div>
<div>
{title && (
<h3 className="font-semibold text-white mb-1">{title}</h3>
)}
<div
className="text-sm text-gray-300"
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,100 @@
import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { contentApi } from '@/api/admin'
import { NeonButton } from '@/components/ui'
import { Bot, Bell, X } from 'lucide-react'
import { useState, useEffect } from 'react'
const STORAGE_KEY = 'telegram_banner_dismissed'
// Default content if not configured in admin
const DEFAULT_TITLE = 'Привяжите Telegram-бота'
const DEFAULT_DESCRIPTION = 'Получайте уведомления о событиях марафона, новых заданиях и результатах прямо в Telegram'
export function TelegramBotBanner() {
const user = useAuthStore((state) => state.user)
const [dismissed, setDismissed] = useState(() => {
return sessionStorage.getItem(STORAGE_KEY) === 'true'
})
const [title, setTitle] = useState(DEFAULT_TITLE)
const [description, setDescription] = useState(DEFAULT_DESCRIPTION)
useEffect(() => {
const loadContent = async () => {
try {
const data = await contentApi.getPublicContent('telegram_bot_info')
if (data.title) setTitle(data.title)
if (data.content) setDescription(data.content)
} catch {
// Use defaults if content not found
}
}
loadContent()
}, [])
const handleDismiss = () => {
sessionStorage.setItem(STORAGE_KEY, 'true')
setDismissed(true)
}
// Don't show if user already has Telegram linked or dismissed
if (user?.telegram_id || dismissed) {
return null
}
return (
<div className="relative rounded-2xl overflow-hidden">
{/* Background image */}
<div
className="absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: 'url(/telegram_bot_banner.png)' }}
/>
{/* Overlay */}
<div className="absolute inset-0 bg-gradient-to-r from-dark-900/95 via-dark-900/80 to-dark-900/60" />
{/* Close button */}
<button
onClick={handleDismiss}
className="absolute top-3 right-3 p-1.5 text-white bg-dark-700/70 hover:bg-dark-600 rounded-lg transition-colors z-10"
title="Скрыть"
>
<X className="w-4 h-4" />
</button>
{/* Content */}
<div className="relative p-6 pr-12 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-[#2AABEE]/20 border border-[#2AABEE]/30 flex items-center justify-center flex-shrink-0">
<Bot className="w-6 h-6 text-[#2AABEE]" />
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-1">
{title}
</h3>
<p className="text-gray-400 text-sm max-w-md">
{description}
</p>
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Bell className="w-3 h-3" />
Мгновенные уведомления
</span>
<span className="flex items-center gap-1">
<Bot className="w-3 h-3" />
Удобное управление
</span>
</div>
</div>
</div>
<div className="ml-16 sm:ml-0">
<Link to="/profile">
<NeonButton color="neon" size="sm">
Привязать
</NeonButton>
</Link>
</div>
</div>
</div>
)
}

View File

@@ -234,7 +234,13 @@ export function Layout() {
Игровой Марафон &copy; {new Date().getFullYear()} Игровой Марафон &copy; {new Date().getFullYear()}
</span> </span>
</div> </div>
<div className="flex items-center gap-4 text-sm text-gray-500"> <div className="flex items-center gap-6 text-sm">
<Link to="/terms" className="text-gray-500 hover:text-gray-300 transition-colors">
Правила
</Link>
<Link to="/privacy" className="text-gray-500 hover:text-gray-300 transition-colors">
Конфиденциальность
</Link>
<span className="text-neon-500/50">v1.0</span> <span className="text-neon-500/50">v1.0</span>
</div> </div>
</div> </div>

View File

@@ -6,9 +6,10 @@ import { NeonButton, Input, GlassCard, StatsCard } from '@/components/ui'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm' import { useConfirm } from '@/store/confirm'
import { fuzzyFilter } from '@/utils/fuzzySearch'
import { import {
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye, Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft, Zap ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft, Zap, Search
} from 'lucide-react' } from 'lucide-react'
export function LobbyPage() { export function LobbyPage() {
@@ -85,6 +86,10 @@ export function LobbyPage() {
// Start marathon // Start marathon
const [isStarting, setIsStarting] = useState(false) const [isStarting, setIsStarting] = useState(false)
// Search
const [searchQuery, setSearchQuery] = useState('')
const [generateSearchQuery, setGenerateSearchQuery] = useState('')
useEffect(() => { useEffect(() => {
loadData() loadData()
}, [id]) }, [id])
@@ -501,6 +506,7 @@ export function LobbyPage() {
} else { } else {
setPreviewChallenges(result.challenges) setPreviewChallenges(result.challenges)
setShowGenerateSelection(false) setShowGenerateSelection(false)
setGenerateSearchQuery('')
} }
} catch (error) { } catch (error) {
console.error('Failed to generate challenges:', error) console.error('Failed to generate challenges:', error)
@@ -798,6 +804,14 @@ export function LobbyPage() {
</select> </select>
</div> </div>
</div> </div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Подсказка для пруфа</label>
<Input
placeholder="Что именно должно быть на скриншоте/видео"
value={editChallenge.proof_hint}
onChange={(e) => setEditChallenge(prev => ({ ...prev, proof_hint: e.target.value }))}
/>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<NeonButton <NeonButton
size="sm" size="sm"
@@ -846,6 +860,9 @@ export function LobbyPage() {
</div> </div>
<h5 className="text-sm font-medium text-white">{challenge.title}</h5> <h5 className="text-sm font-medium text-white">{challenge.title}</h5>
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p> <p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
{challenge.proof_hint && (
<p className="text-xs text-gray-500 mt-1">Пруф: {challenge.proof_hint}</p>
)}
</div> </div>
{isOrganizer && ( {isOrganizer && (
<div className="flex gap-1 shrink-0"> <div className="flex gap-1 shrink-0">
@@ -1181,6 +1198,14 @@ export function LobbyPage() {
</select> </select>
</div> </div>
</div> </div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Подсказка для пруфа</label>
<Input
placeholder="Что именно должно быть на скриншоте/видео"
value={editChallenge.proof_hint}
onChange={(e) => setEditChallenge(prev => ({ ...prev, proof_hint: e.target.value }))}
/>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<NeonButton <NeonButton
size="sm" size="sm"
@@ -1226,6 +1251,9 @@ export function LobbyPage() {
</div> </div>
<h4 className="font-medium text-white mb-1">{challenge.title}</h4> <h4 className="font-medium text-white mb-1">{challenge.title}</h4>
<p className="text-sm text-gray-400 mb-2">{challenge.description}</p> <p className="text-sm text-gray-400 mb-2">{challenge.description}</p>
{challenge.proof_hint && (
<p className="text-xs text-gray-500 mb-2">Пруф: {challenge.proof_hint}</p>
)}
{challenge.proposed_by && ( {challenge.proposed_by && (
<p className="text-xs text-gray-500 flex items-center gap-1"> <p className="text-xs text-gray-500 flex items-center gap-1">
<User className="w-3 h-3" /> Предложил: {challenge.proposed_by.nickname} <User className="w-3 h-3" /> Предложил: {challenge.proposed_by.nickname}
@@ -1304,6 +1332,9 @@ export function LobbyPage() {
</div> </div>
<h4 className="font-medium text-white mb-1">{challenge.title}</h4> <h4 className="font-medium text-white mb-1">{challenge.title}</h4>
<p className="text-sm text-gray-400">{challenge.description}</p> <p className="text-sm text-gray-400">{challenge.description}</p>
{challenge.proof_hint && (
<p className="text-xs text-gray-500 mt-1">Пруф: {challenge.proof_hint}</p>
)}
</div> </div>
{challenge.status === 'pending' && ( {challenge.status === 'pending' && (
<button <button
@@ -1343,6 +1374,7 @@ export function LobbyPage() {
onClick={() => { onClick={() => {
setShowGenerateSelection(false) setShowGenerateSelection(false)
clearGameSelection() clearGameSelection()
setGenerateSearchQuery('')
}} }}
variant="secondary" variant="secondary"
size="sm" size="sm"
@@ -1376,6 +1408,25 @@ export function LobbyPage() {
{/* Game selection */} {/* Game selection */}
{showGenerateSelection && ( {showGenerateSelection && (
<div className="space-y-3"> <div className="space-y-3">
{/* Search in generation */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder="Поиск игры..."
value={generateSearchQuery}
onChange={(e) => setGenerateSearchQuery(e.target.value)}
className="input w-full pl-10 pr-10 py-2 text-sm"
/>
{generateSearchQuery && (
<button
onClick={() => setGenerateSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<button <button
onClick={selectAllGamesForGeneration} onClick={selectAllGamesForGeneration}
@@ -1390,36 +1441,48 @@ export function LobbyPage() {
Снять выбор Снять выбор
</button> </button>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2 max-h-64 overflow-y-auto custom-scrollbar">
{approvedGames.map((game) => { {(() => {
const isSelected = selectedGamesForGeneration.includes(game.id) const filteredGames = generateSearchQuery
const challengeCount = gameChallenges[game.id]?.length ?? game.challenges_count ? fuzzyFilter(approvedGames, generateSearchQuery, (g) => g.title + ' ' + (g.genre || ''))
return ( : approvedGames
<button
key={game.id} return filteredGames.length === 0 ? (
onClick={() => toggleGameSelection(game.id)} <p className="text-center text-gray-500 py-4 text-sm">
className={`flex items-center gap-3 p-3 rounded-lg border transition-all text-left ${ Ничего не найдено по запросу "{generateSearchQuery}"
isSelected </p>
? 'bg-accent-500/20 border-accent-500/50' ) : (
: 'bg-dark-700/50 border-dark-600 hover:border-dark-500' filteredGames.map((game) => {
}`} const isSelected = selectedGamesForGeneration.includes(game.id)
> const challengeCount = gameChallenges[game.id]?.length ?? game.challenges_count
<div className={`w-5 h-5 rounded flex items-center justify-center border-2 transition-colors ${ return (
isSelected <button
? 'bg-accent-500 border-accent-500' key={game.id}
: 'border-gray-500' onClick={() => toggleGameSelection(game.id)}
}`}> className={`flex items-center gap-3 p-3 rounded-lg border transition-all text-left ${
{isSelected && <Check className="w-3 h-3 text-white" />} isSelected
</div> ? 'bg-accent-500/20 border-accent-500/50'
<div className="flex-1 min-w-0"> : 'bg-dark-700/50 border-dark-600 hover:border-dark-500'
<p className="text-white font-medium truncate">{game.title}</p> }`}
<p className="text-xs text-gray-400"> >
{challengeCount > 0 ? `${challengeCount} заданий` : 'Нет заданий'} <div className={`w-5 h-5 rounded flex items-center justify-center border-2 transition-colors ${
</p> isSelected
</div> ? 'bg-accent-500 border-accent-500'
</button> : 'border-gray-500'
}`}>
{isSelected && <Check className="w-3 h-3 text-white" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">{game.title}</p>
<p className="text-xs text-gray-400">
{challengeCount > 0 ? `${challengeCount} заданий` : 'Нет заданий'}
</p>
</div>
</button>
)
})
) )
})} })()}
</div> </div>
</div> </div>
)} )}
@@ -1574,7 +1637,7 @@ export function LobbyPage() {
{/* Games list */} {/* Games list */}
<GlassCard> <GlassCard>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
<Gamepad2 className="w-5 h-5 text-neon-400" /> <Gamepad2 className="w-5 h-5 text-neon-400" />
@@ -1592,6 +1655,26 @@ export function LobbyPage() {
)} )}
</div> </div>
{/* Search */}
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder="Поиск игры..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="input w-full pl-10 pr-10"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Add game form */} {/* Add game form */}
{showAddGame && ( {showAddGame && (
<div className="mb-6 p-4 glass rounded-xl space-y-3 border border-neon-500/20"> <div className="mb-6 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
@@ -1632,17 +1715,29 @@ export function LobbyPage() {
{/* Games */} {/* Games */}
{(() => { {(() => {
const visibleGames = isOrganizer const baseGames = isOrganizer
? games.filter(g => g.status !== 'pending') ? games.filter(g => g.status !== 'pending')
: games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id)) : games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id))
const visibleGames = searchQuery
? fuzzyFilter(baseGames, searchQuery, (g) => g.title + ' ' + (g.genre || ''))
: baseGames
return visibleGames.length === 0 ? ( return visibleGames.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center">
<Gamepad2 className="w-8 h-8 text-gray-600" /> {searchQuery ? (
<Search className="w-8 h-8 text-gray-600" />
) : (
<Gamepad2 className="w-8 h-8 text-gray-600" />
)}
</div> </div>
<p className="text-gray-400"> <p className="text-gray-400">
{isOrganizer ? 'Пока нет игр. Добавьте игры, чтобы начать!' : 'Пока нет одобренных игр. Предложите свою!'} {searchQuery
? `Ничего не найдено по запросу "${searchQuery}"`
: isOrganizer
? 'Пока нет игр. Добавьте игры, чтобы начать!'
: 'Пока нет одобренных игр. Предложите свою!'}
</p> </p>
</div> </div>
) : ( ) : (

View File

@@ -16,6 +16,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { format } from 'date-fns' import { format } from 'date-fns'
import { ru } from 'date-fns/locale' import { ru } from 'date-fns/locale'
import { TelegramBotBanner } from '@/components/TelegramBotBanner'
export function MarathonPage() { export function MarathonPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
@@ -316,6 +317,9 @@ export function MarathonPage() {
/> />
</div> </div>
{/* Telegram Bot Banner */}
<TelegramBotBanner />
{/* Active event banner */} {/* Active event banner */}
{marathon.status === 'active' && activeEvent?.event && ( {marathon.status === 'active' && activeEvent?.event && (
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} /> <EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />

View File

@@ -4,6 +4,8 @@ import { marathonsApi } from '@/api'
import type { MarathonListItem } from '@/types' import type { MarathonListItem } from '@/types'
import { NeonButton, GlassCard, StatsCard } from '@/components/ui' import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { Plus, Users, Calendar, Loader2, Trophy, Gamepad2, ChevronRight, Hash, Sparkles } from 'lucide-react' import { Plus, Users, Calendar, Loader2, Trophy, Gamepad2, ChevronRight, Hash, Sparkles } from 'lucide-react'
import { TelegramBotBanner } from '@/components/TelegramBotBanner'
import { AnnouncementBanner } from '@/components/AnnouncementBanner'
import { format } from 'date-fns' import { format } from 'date-fns'
import { ru } from 'date-fns/locale' import { ru } from 'date-fns/locale'
@@ -145,6 +147,16 @@ export function MarathonsPage() {
</div> </div>
)} )}
{/* Announcement Banner */}
<div className="mb-4">
<AnnouncementBanner />
</div>
{/* Telegram Bot Banner */}
<div className="mb-8">
<TelegramBotBanner />
</div>
{/* Join marathon */} {/* Join marathon */}
{showJoinSection && ( {showJoinSection && (
<GlassCard className="mb-8 animate-slide-in-down" variant="neon"> <GlassCard className="mb-8 animate-slide-in-down" variant="neon">

View File

@@ -0,0 +1,107 @@
import { useState, useEffect } from 'react'
import { useParams, useLocation, Link } from 'react-router-dom'
import { contentApi } from '@/api/admin'
import type { StaticContent } from '@/types'
import { GlassCard } from '@/components/ui'
import { ArrowLeft, Loader2, FileText } from 'lucide-react'
// Map routes to content keys
const ROUTE_KEY_MAP: Record<string, string> = {
'/terms': 'terms_of_service',
'/privacy': 'privacy_policy',
}
export function StaticContentPage() {
const { key: paramKey } = useParams<{ key: string }>()
const location = useLocation()
const [content, setContent] = useState<StaticContent | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Determine content key from route or param
const contentKey = ROUTE_KEY_MAP[location.pathname] || paramKey
useEffect(() => {
if (!contentKey) return
const loadContent = async () => {
setIsLoading(true)
setError(null)
try {
const data = await contentApi.getPublicContent(contentKey)
setContent(data)
} catch {
setError('Контент не найден')
} finally {
setIsLoading(false)
}
}
loadContent()
}, [contentKey])
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка...</p>
</div>
)
}
if (error || !content) {
return (
<div className="max-w-3xl mx-auto">
<GlassCard className="text-center py-16">
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-dark-700 flex items-center justify-center">
<FileText className="w-10 h-10 text-gray-600" />
</div>
<h3 className="text-xl font-semibold text-white mb-2">Страница не найдена</h3>
<p className="text-gray-400 mb-6">Запрашиваемый контент не существует</p>
<Link
to="/"
className="inline-flex items-center gap-2 text-neon-400 hover:text-neon-300 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
На главную
</Link>
</GlassCard>
</div>
)
}
return (
<div className="max-w-3xl mx-auto">
<Link
to="/"
className="inline-flex items-center gap-2 text-gray-400 hover:text-neon-400 mb-6 transition-colors group"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
На главную
</Link>
<GlassCard>
<h1 className="text-2xl md:text-3xl font-bold text-white mb-6">{content.title}</h1>
<div
className="prose prose-invert prose-gray max-w-none
prose-headings:text-white prose-headings:font-semibold
prose-p:text-gray-300 prose-p:leading-relaxed
prose-a:text-neon-400 prose-a:no-underline hover:prose-a:text-neon-300
prose-strong:text-white
prose-ul:text-gray-300 prose-ol:text-gray-300
prose-li:marker:text-gray-500
prose-hr:border-dark-600 prose-hr:my-6
prose-img:rounded-xl prose-img:shadow-lg"
dangerouslySetInnerHTML={{ __html: content.content }}
/>
<div className="mt-8 pt-6 border-t border-dark-600 text-sm text-gray-500">
Последнее обновление: {new Date(content.updated_at).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</div>
</GlassCard>
</div>
)
}

View File

@@ -3,7 +3,8 @@ import { adminApi } from '@/api'
import type { StaticContent } from '@/types' import type { StaticContent } from '@/types'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { NeonButton } from '@/components/ui' import { NeonButton } from '@/components/ui'
import { FileText, Plus, Pencil, X, Save, Code } from 'lucide-react' import { FileText, Plus, Pencil, X, Save, Code, Trash2 } from 'lucide-react'
import { useConfirm } from '@/store/confirm'
function formatDate(dateStr: string) { function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('ru-RU', { return new Date(dateStr).toLocaleString('ru-RU', {
@@ -28,6 +29,7 @@ export function AdminContentPage() {
const [formContent, setFormContent] = useState('') const [formContent, setFormContent] = useState('')
const toast = useToast() const toast = useToast()
const confirm = useConfirm()
useEffect(() => { useEffect(() => {
loadContents() loadContents()
@@ -101,6 +103,30 @@ export function AdminContentPage() {
} }
} }
const handleDelete = async (content: StaticContent) => {
const confirmed = await confirm({
title: 'Удалить контент?',
message: `Вы уверены, что хотите удалить "${content.title}"? Это действие нельзя отменить.`,
confirmText: 'Удалить',
cancelText: 'Отмена',
variant: 'danger',
})
if (!confirmed) return
try {
await adminApi.deleteContent(content.key)
setContents(contents.filter(c => c.id !== content.id))
if (editing?.id === content.id) {
handleCancel()
}
toast.success('Контент удалён')
} catch (err) {
console.error('Failed to delete content:', err)
toast.error('Ошибка удаления')
}
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
@@ -155,15 +181,28 @@ export function AdminContentPage() {
{content.content.replace(/<[^>]*>/g, '').slice(0, 100)}... {content.content.replace(/<[^>]*>/g, '').slice(0, 100)}...
</p> </p>
</div> </div>
<button <div className="flex items-center gap-1 ml-3">
onClick={(e) => { <button
e.stopPropagation() onClick={(e) => {
handleEdit(content) e.stopPropagation()
}} handleEdit(content)
className="p-2 text-gray-400 hover:text-accent-400 hover:bg-accent-500/10 rounded-lg transition-colors ml-3" }}
> className="p-2 text-gray-400 hover:text-accent-400 hover:bg-accent-500/10 rounded-lg transition-colors"
<Pencil className="w-4 h-4" /> title="Редактировать"
</button> >
<Pencil className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
handleDelete(content)
}}
className="p-2 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
title="Удалить"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div> </div>
<p className="text-xs text-gray-500 mt-4 pt-3 border-t border-dark-600"> <p className="text-xs text-gray-500 mt-4 pt-3 border-t border-dark-600">
Обновлено: {formatDate(content.updated_at)} Обновлено: {formatDate(content.updated_at)}

View File

@@ -4,7 +4,7 @@ import type { AdminUser, UserRole } from '@/types'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm' import { useConfirm } from '@/store/confirm'
import { NeonButton } from '@/components/ui' import { NeonButton } from '@/components/ui'
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X } from 'lucide-react' import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X, KeyRound } from 'lucide-react'
export function AdminUsersPage() { export function AdminUsersPage() {
const [users, setUsers] = useState<AdminUser[]>([]) const [users, setUsers] = useState<AdminUser[]>([])
@@ -17,6 +17,9 @@ export function AdminUsersPage() {
const [banDuration, setBanDuration] = useState<string>('permanent') const [banDuration, setBanDuration] = useState<string>('permanent')
const [banCustomDate, setBanCustomDate] = useState('') const [banCustomDate, setBanCustomDate] = useState('')
const [banning, setBanning] = useState(false) const [banning, setBanning] = useState(false)
const [resetPasswordUser, setResetPasswordUser] = useState<AdminUser | null>(null)
const [newPassword, setNewPassword] = useState('')
const [resettingPassword, setResettingPassword] = useState(false)
const toast = useToast() const toast = useToast()
const confirm = useConfirm() const confirm = useConfirm()
@@ -120,6 +123,24 @@ export function AdminUsersPage() {
} }
} }
const handleResetPassword = async () => {
if (!resetPasswordUser || !newPassword.trim() || newPassword.length < 6) return
setResettingPassword(true)
try {
const updated = await adminApi.resetUserPassword(resetPasswordUser.id, newPassword)
setUsers(users.map(u => u.id === updated.id ? updated : u))
toast.success(`Пароль ${updated.nickname} сброшен`)
setResetPasswordUser(null)
setNewPassword('')
} catch (err) {
console.error('Failed to reset password:', err)
toast.error('Ошибка сброса пароля')
} finally {
setResettingPassword(false)
}
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@@ -265,6 +286,14 @@ export function AdminUsersPage() {
<Shield className="w-4 h-4" /> <Shield className="w-4 h-4" />
</button> </button>
)} )}
<button
onClick={() => setResetPasswordUser(user)}
className="p-2 text-yellow-400 hover:bg-yellow-500/20 rounded-lg transition-colors"
title="Сбросить пароль"
>
<KeyRound className="w-4 h-4" />
</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -393,6 +422,71 @@ export function AdminUsersPage() {
</div> </div>
</div> </div>
)} )}
{/* Reset Password Modal */}
{resetPasswordUser && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<KeyRound className="w-5 h-5 text-yellow-400" />
Сбросить пароль {resetPasswordUser.nickname}
</h3>
<button
onClick={() => {
setResetPasswordUser(null)
setNewPassword('')
}}
className="p-1.5 text-gray-400 hover:text-white rounded-lg hover:bg-dark-600/50 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-300 mb-2">
Новый пароль
</label>
<input
type="text"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Минимум 6 символов"
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
/>
{newPassword && newPassword.length < 6 && (
<p className="mt-2 text-sm text-red-400">Пароль должен быть минимум 6 символов</p>
)}
{resetPasswordUser.telegram_id && (
<p className="mt-2 text-sm text-gray-400">
Пользователь получит уведомление в Telegram о смене пароля
</p>
)}
</div>
<div className="flex gap-3 justify-end">
<NeonButton
variant="ghost"
onClick={() => {
setResetPasswordUser(null)
setNewPassword('')
}}
>
Отмена
</NeonButton>
<NeonButton
color="neon"
onClick={handleResetPassword}
disabled={!newPassword.trim() || newPassword.length < 6 || resettingPassword}
isLoading={resettingPassword}
icon={<KeyRound className="w-4 h-4" />}
>
Сбросить
</NeonButton>
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -3,6 +3,8 @@ import { persist } from 'zustand/middleware'
import type { User } from '@/types' import type { User } from '@/types'
import { authApi, type RegisterData, type LoginData } from '@/api/auth' import { authApi, type RegisterData, type LoginData } from '@/api/auth'
let syncPromise: Promise<void> | null = null
interface Pending2FA { interface Pending2FA {
sessionId: number sessionId: number
} }
@@ -41,6 +43,7 @@ interface AuthState {
bumpAvatarVersion: () => void bumpAvatarVersion: () => void
setBanned: (banInfo: BanInfo) => void setBanned: (banInfo: BanInfo) => void
clearBanned: () => void clearBanned: () => void
syncUser: () => Promise<void>
} }
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
@@ -145,6 +148,7 @@ export const useAuthStore = create<AuthState>()(
logout: () => { logout: () => {
localStorage.removeItem('token') localStorage.removeItem('token')
sessionStorage.removeItem('telegram_banner_dismissed')
set({ set({
user: null, user: null,
token: null, token: null,
@@ -181,6 +185,27 @@ export const useAuthStore = create<AuthState>()(
clearBanned: () => { clearBanned: () => {
set({ banInfo: null }) set({ banInfo: null })
}, },
syncUser: async () => {
if (!get().isAuthenticated || !get().token) return
// Prevent duplicate sync calls
if (syncPromise) return syncPromise
syncPromise = (async () => {
try {
const userData = await authApi.me()
set({ user: userData })
} catch {
// Token invalid - logout
get().logout()
} finally {
syncPromise = null
}
})()
return syncPromise
},
}), }),
{ {
name: 'auth-storage', name: 'auth-storage',

View File

@@ -0,0 +1,123 @@
// Keyboard layout mapping (RU -> EN and EN -> RU)
const ruToEn: Record<string, string> = {
'й': 'q', 'ц': 'w', 'у': 'e', 'к': 'r', 'е': 't', 'н': 'y', 'г': 'u', 'ш': 'i', 'щ': 'o', 'з': 'p',
'ф': 'a', 'ы': 's', 'в': 'd', 'а': 'f', 'п': 'g', 'р': 'h', 'о': 'j', 'л': 'k', 'д': 'l',
'я': 'z', 'ч': 'x', 'с': 'c', 'м': 'v', 'и': 'b', 'т': 'n', 'ь': 'm',
'х': '[', 'ъ': ']', 'ж': ';', 'э': "'", 'б': ',', 'ю': '.',
}
const enToRu: Record<string, string> = Object.fromEntries(
Object.entries(ruToEn).map(([ru, en]) => [en, ru])
)
function convertLayout(text: string): string {
return text
.split('')
.map(char => {
const lower = char.toLowerCase()
const converted = ruToEn[lower] || enToRu[lower] || char
return char === lower ? converted : converted.toUpperCase()
})
.join('')
}
function levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = []
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i]
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1]
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
)
}
}
}
return matrix[b.length][a.length]
}
export interface FuzzyMatch<T> {
item: T
score: number
}
export function fuzzySearch<T>(
items: T[],
query: string,
getSearchField: (item: T) => string
): FuzzyMatch<T>[] {
if (!query.trim()) {
return items.map(item => ({ item, score: 1 }))
}
const normalizedQuery = query.toLowerCase().trim()
const convertedQuery = convertLayout(normalizedQuery)
const results: FuzzyMatch<T>[] = []
for (const item of items) {
const field = getSearchField(item).toLowerCase()
// Exact substring match - highest score
if (field.includes(normalizedQuery)) {
results.push({ item, score: 1 })
continue
}
// Converted layout match
if (field.includes(convertedQuery)) {
results.push({ item, score: 0.95 })
continue
}
// Check if query matches start of words
const words = field.split(/\s+/)
const queryWords = normalizedQuery.split(/\s+/)
const startsWithMatch = queryWords.every(qw =>
words.some(w => w.startsWith(qw))
)
if (startsWithMatch) {
results.push({ item, score: 0.9 })
continue
}
// Levenshtein distance for typo tolerance
const distance = levenshteinDistance(normalizedQuery, field)
const maxLen = Math.max(normalizedQuery.length, field.length)
const similarity = 1 - distance / maxLen
// Also check against converted query
const convertedDistance = levenshteinDistance(convertedQuery, field)
const convertedSimilarity = 1 - convertedDistance / maxLen
const bestSimilarity = Math.max(similarity, convertedSimilarity)
// Only include if similarity is reasonable (> 40%)
if (bestSimilarity > 0.4) {
results.push({ item, score: bestSimilarity * 0.8 })
}
}
// Sort by score descending
return results.sort((a, b) => b.score - a.score)
}
export function fuzzyFilter<T>(
items: T[],
query: string,
getSearchField: (item: T) => string
): T[] {
return fuzzySearch(items, query, getSearchField).map(r => r.item)
}

View File

@@ -90,7 +90,7 @@ def get_latency_history(service_name: str, hours: int = 24) -> list[dict]:
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
since = datetime.now() - timedelta(hours=hours) since = datetime.utcnow() - timedelta(hours=hours)
cursor.execute(""" cursor.execute("""
SELECT latency_ms, status, checked_at SELECT latency_ms, status, checked_at
FROM metrics FROM metrics
@@ -116,7 +116,7 @@ def get_uptime_stats(service_name: str, hours: int = 24) -> dict:
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
since = datetime.now() - timedelta(hours=hours) since = datetime.utcnow() - timedelta(hours=hours)
cursor.execute(""" cursor.execute("""
SELECT COUNT(*) as total, SELECT COUNT(*) as total,
@@ -143,7 +143,7 @@ def get_avg_latency(service_name: str, hours: int = 24) -> Optional[float]:
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
since = datetime.now() - timedelta(hours=hours) since = datetime.utcnow() - timedelta(hours=hours)
cursor.execute(""" cursor.execute("""
SELECT AVG(latency_ms) as avg_latency SELECT AVG(latency_ms) as avg_latency
FROM metrics FROM metrics
@@ -249,11 +249,11 @@ def get_ssl_info(domain: str) -> Optional[dict]:
return None return None
def cleanup_old_metrics(days: int = 1): def cleanup_old_metrics(hours: int = 24):
"""Delete metrics older than specified days (default: 24 hours).""" """Delete metrics older than specified hours (default: 24 hours)."""
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cutoff = datetime.now() - timedelta(days=days) cutoff = datetime.utcnow() - timedelta(hours=hours)
cursor.execute("DELETE FROM metrics WHERE checked_at < ?", (cutoff.isoformat(),)) cursor.execute("DELETE FROM metrics WHERE checked_at < ?", (cutoff.isoformat(),))
deleted = cursor.rowcount deleted = cursor.rowcount
conn.commit() conn.commit()

View File

@@ -9,7 +9,7 @@ from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from monitors import ServiceMonitor from monitors import ServiceMonitor, Status
from database import init_db, get_recent_incidents, get_latency_history, cleanup_old_metrics from database import init_db, get_recent_incidents, get_latency_history, cleanup_old_metrics
@@ -19,52 +19,91 @@ FRONTEND_URL = os.getenv("FRONTEND_URL", "http://frontend:80")
BOT_URL = os.getenv("BOT_URL", "http://bot:8080") BOT_URL = os.getenv("BOT_URL", "http://bot:8080")
EXTERNAL_URL = os.getenv("EXTERNAL_URL", "") # Public URL for external checks EXTERNAL_URL = os.getenv("EXTERNAL_URL", "") # Public URL for external checks
PUBLIC_URL = os.getenv("PUBLIC_URL", "") # Public HTTPS URL for SSL checks PUBLIC_URL = os.getenv("PUBLIC_URL", "") # Public HTTPS URL for SSL checks
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "600")) # 10 minutes CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "60")) # Normal interval (1 minute)
FAST_CHECK_INTERVAL = int(os.getenv("FAST_CHECK_INTERVAL", "5")) # Fast interval when issues detected
STARTUP_GRACE_PERIOD = int(os.getenv("STARTUP_GRACE_PERIOD", "60")) # Wait before alerting after startup
# Initialize monitor # Initialize monitor
monitor = ServiceMonitor() monitor = ServiceMonitor()
startup_time: Optional[datetime] = None # Track when service started
# Background task reference # Background task reference
background_task: Optional[asyncio.Task] = None background_task: Optional[asyncio.Task] = None
cleanup_task: Optional[asyncio.Task] = None cleanup_task: Optional[asyncio.Task] = None
def has_issues() -> bool:
"""Check if any monitored service has issues."""
for name, svc in monitor.services.items():
# Skip external if not configured
if name == "external" and svc.status == Status.UNKNOWN:
continue
if svc.status in (Status.DOWN, Status.DEGRADED):
return True
return False
async def periodic_health_check(): async def periodic_health_check():
"""Background task to check services periodically.""" """Background task to check services periodically with adaptive polling."""
while True: while True:
try: try:
# Suppress alerts during startup grace period
suppress_alerts = is_in_grace_period()
if suppress_alerts:
remaining = STARTUP_GRACE_PERIOD - (datetime.now() - startup_time).total_seconds()
print(f"Grace period: {remaining:.0f}s remaining (alerts suppressed)")
await monitor.check_all_services( await monitor.check_all_services(
backend_url=BACKEND_URL, backend_url=BACKEND_URL,
frontend_url=FRONTEND_URL, frontend_url=FRONTEND_URL,
bot_url=BOT_URL, bot_url=BOT_URL,
external_url=EXTERNAL_URL, external_url=EXTERNAL_URL,
public_url=PUBLIC_URL public_url=PUBLIC_URL,
suppress_alerts=suppress_alerts
) )
except Exception as e: except Exception as e:
print(f"Health check error: {e}") print(f"Health check error: {e}")
await asyncio.sleep(CHECK_INTERVAL)
# Adaptive polling: check more frequently when issues detected
if has_issues():
await asyncio.sleep(FAST_CHECK_INTERVAL)
else:
await asyncio.sleep(CHECK_INTERVAL)
async def periodic_cleanup(): async def periodic_cleanup():
"""Background task to cleanup old metrics (hourly).""" """Background task to cleanup old metrics (runs immediately, then hourly)."""
while True: while True:
await asyncio.sleep(3600) # 1 hour
try: try:
deleted = cleanup_old_metrics(days=1) # Keep only last 24 hours deleted = cleanup_old_metrics(hours=24) # Keep only last 24 hours
print(f"Cleaned up {deleted} old metrics") if deleted > 0:
print(f"Cleaned up {deleted} old metrics")
except Exception as e: except Exception as e:
print(f"Cleanup error: {e}") print(f"Cleanup error: {e}")
await asyncio.sleep(3600) # Wait 1 hour before next cleanup
def is_in_grace_period() -> bool:
"""Check if we're still in startup grace period."""
if startup_time is None:
return True
elapsed = (datetime.now() - startup_time).total_seconds()
return elapsed < STARTUP_GRACE_PERIOD
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Startup and shutdown events.""" """Startup and shutdown events."""
global background_task, cleanup_task global background_task, cleanup_task, startup_time
# Initialize database # Initialize database
init_db() init_db()
print("Database initialized") print("Database initialized")
# Mark startup time
startup_time = datetime.now()
print(f"Startup grace period: {STARTUP_GRACE_PERIOD}s (no alerts until services stabilize)")
# Start background health checks # Start background health checks
background_task = asyncio.create_task(periodic_health_check()) background_task = asyncio.create_task(periodic_health_check())
cleanup_task = asyncio.create_task(periodic_cleanup()) cleanup_task = asyncio.create_task(periodic_cleanup())
@@ -91,12 +130,20 @@ templates = Jinja2Templates(directory="templates")
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def status_page(request: Request): async def status_page(request: Request, period: int = 24):
"""Main status page.""" """Main status page."""
services = monitor.get_all_statuses() # Validate period (1, 12, or 24 hours)
if period not in (1, 12, 24):
period = 24
services = monitor.get_all_statuses(period_hours=period)
overall_status = monitor.get_overall_status() overall_status = monitor.get_overall_status()
ssl_status = monitor.get_ssl_status() ssl_status = monitor.get_ssl_status()
incidents = get_recent_incidents(limit=5) incidents = get_recent_incidents(limit=5)
fast_mode = has_issues()
current_interval = FAST_CHECK_INTERVAL if fast_mode else CHECK_INTERVAL
grace_period_active = is_in_grace_period()
grace_period_remaining = max(0, STARTUP_GRACE_PERIOD - (datetime.now() - startup_time).total_seconds()) if startup_time else 0
return templates.TemplateResponse( return templates.TemplateResponse(
"index.html", "index.html",
@@ -107,7 +154,11 @@ async def status_page(request: Request):
"ssl_status": ssl_status, "ssl_status": ssl_status,
"incidents": incidents, "incidents": incidents,
"last_check": monitor.last_check, "last_check": monitor.last_check,
"check_interval": CHECK_INTERVAL "check_interval": current_interval,
"fast_mode": fast_mode,
"grace_period_active": grace_period_active,
"grace_period_remaining": int(grace_period_remaining),
"period": period
} }
) )
@@ -118,13 +169,15 @@ async def api_status():
services = monitor.get_all_statuses() services = monitor.get_all_statuses()
overall_status = monitor.get_overall_status() overall_status = monitor.get_overall_status()
ssl_status = monitor.get_ssl_status() ssl_status = monitor.get_ssl_status()
current_interval = FAST_CHECK_INTERVAL if has_issues() else CHECK_INTERVAL
return { return {
"overall_status": overall_status.value, "overall_status": overall_status.value,
"services": {name: status.to_dict() for name, status in services.items()}, "services": {name: status.to_dict() for name, status in services.items()},
"ssl": ssl_status, "ssl": ssl_status,
"last_check": monitor.last_check.isoformat() if monitor.last_check else None, "last_check": monitor.last_check.isoformat() if monitor.last_check else None,
"check_interval_seconds": CHECK_INTERVAL "check_interval_seconds": current_interval,
"fast_mode": has_issues()
} }

View File

@@ -184,7 +184,8 @@ class ServiceMonitor:
self, self,
service_name: str, service_name: str,
result: tuple, result: tuple,
now: datetime now: datetime,
suppress_alerts: bool = False
): ):
"""Process check result with DB persistence and alerting.""" """Process check result with DB persistence and alerting."""
if isinstance(result, Exception): if isinstance(result, Exception):
@@ -221,13 +222,14 @@ class ServiceMonitor:
if stats["total_checks"] > 0: if stats["total_checks"] > 0:
svc.uptime_percent = stats["uptime_percent"] svc.uptime_percent = stats["uptime_percent"]
# Handle incident tracking and alerting # Handle incident tracking and alerting (skip alerts during grace period)
if is_down and not was_down: if is_down and not was_down:
# Service just went down # Service just went down
svc.last_incident = now svc.last_incident = now
incident_id = create_incident(service_name, status.value, message) incident_id = create_incident(service_name, status.value, message)
await alert_service_down(service_name, svc.display_name, message) if not suppress_alerts:
mark_incident_notified(incident_id) await alert_service_down(service_name, svc.display_name, message)
mark_incident_notified(incident_id)
elif not is_down and was_down: elif not is_down and was_down:
# Service recovered # Service recovered
@@ -236,7 +238,8 @@ class ServiceMonitor:
started_at = datetime.fromisoformat(open_incident["started_at"]) started_at = datetime.fromisoformat(open_incident["started_at"])
downtime_minutes = int((now - started_at).total_seconds() / 60) downtime_minutes = int((now - started_at).total_seconds() / 60)
resolve_incident(service_name) resolve_incident(service_name)
await alert_service_recovered(service_name, svc.display_name, downtime_minutes) if not suppress_alerts:
await alert_service_recovered(service_name, svc.display_name, downtime_minutes)
async def check_all_services( async def check_all_services(
self, self,
@@ -244,7 +247,8 @@ class ServiceMonitor:
frontend_url: str, frontend_url: str,
bot_url: str, bot_url: str,
external_url: str = "", external_url: str = "",
public_url: str = "" public_url: str = "",
suppress_alerts: bool = False
): ):
"""Check all services concurrently.""" """Check all services concurrently."""
now = datetime.now() now = datetime.now()
@@ -262,7 +266,7 @@ class ServiceMonitor:
# Process results # Process results
service_names = ["backend", "database", "frontend", "bot", "external"] service_names = ["backend", "database", "frontend", "bot", "external"]
for i, service_name in enumerate(service_names): for i, service_name in enumerate(service_names):
await self._process_check_result(service_name, results[i], now) await self._process_check_result(service_name, results[i], now, suppress_alerts)
# Check SSL certificate (if public URL is HTTPS) # Check SSL certificate (if public URL is HTTPS)
if public_url and public_url.startswith("https://"): if public_url and public_url.startswith("https://"):
@@ -270,7 +274,15 @@ class ServiceMonitor:
self.last_check = now self.last_check = now
def get_all_statuses(self) -> dict[str, ServiceStatus]: def get_all_statuses(self, period_hours: int = 24) -> dict[str, ServiceStatus]:
"""Get all service statuses with data for specified period."""
# Update historical data for requested period
for name, svc in self.services.items():
svc.latency_history = get_latency_history(name, hours=period_hours)
svc.avg_latency_24h = get_avg_latency(name, hours=period_hours)
stats = get_uptime_stats(name, hours=period_hours)
if stats["total_checks"] > 0:
svc.uptime_percent = stats["uptime_percent"]
return self.services return self.services
def get_overall_status(self) -> Status: def get_overall_status(self) -> Status:

View File

@@ -107,6 +107,32 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.fast-mode-badge {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
background: rgba(250, 204, 21, 0.15);
border: 1px solid rgba(250, 204, 21, 0.3);
border-radius: 12px;
color: #facc15;
font-size: 0.8rem;
font-weight: 500;
animation: pulse 2s infinite;
}
.grace-period-badge {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
background: rgba(59, 130, 246, 0.15);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 12px;
color: #3b82f6;
font-size: 0.8rem;
font-weight: 500;
animation: pulse 2s infinite;
}
.services-grid { .services-grid {
display: grid; display: grid;
gap: 16px; gap: 16px;
@@ -347,6 +373,37 @@
color: #64748b; color: #64748b;
} }
.period-selector {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 24px;
}
.period-btn {
padding: 8px 16px;
background: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(100, 116, 139, 0.3);
border-radius: 8px;
color: #94a3b8;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
}
.period-btn:hover {
border-color: rgba(0, 212, 255, 0.3);
color: #e2e8f0;
}
.period-btn.active {
background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(168, 85, 247, 0.2));
border-color: rgba(0, 212, 255, 0.5);
color: #00d4ff;
}
.refresh-btn { .refresh-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -424,9 +481,21 @@
Checking services... Checking services...
{% endif %} {% endif %}
&bull; Auto-refresh every {{ check_interval }}s &bull; Auto-refresh every {{ check_interval }}s
{% if grace_period_active %}
<span class="grace-period-badge">🚀 Startup ({{ grace_period_remaining }}s)</span>
{% elif fast_mode %}
<span class="fast-mode-badge">⚡ Fast Mode</span>
{% endif %}
</p> </p>
</header> </header>
<!-- Period Selector -->
<div class="period-selector">
<a href="?period=1" class="period-btn {% if period == 1 %}active{% endif %}">1 час</a>
<a href="?period=12" class="period-btn {% if period == 12 %}active{% endif %}">12 часов</a>
<a href="?period=24" class="period-btn {% if period == 24 %}active{% endif %}">24 часа</a>
</div>
{% if ssl_status %} {% if ssl_status %}
<div class="ssl-card {% if ssl_status.days_until_expiry <= 0 %}danger{% elif ssl_status.days_until_expiry <= 14 %}warning{% endif %}"> <div class="ssl-card {% if ssl_status.days_until_expiry <= 0 %}danger{% elif ssl_status.days_until_expiry <= 14 %}warning{% endif %}">
<div class="ssl-header"> <div class="ssl-header">
@@ -491,7 +560,7 @@
</div> </div>
</div> </div>
<div class="metric"> <div class="metric">
<div class="metric-label">Avg 24h</div> <div class="metric-label">Avg {{ period }}h</div>
<div class="metric-value {% if service.avg_latency_24h and service.avg_latency_24h < 200 %}good{% elif service.avg_latency_24h and service.avg_latency_24h < 500 %}warning{% elif service.avg_latency_24h %}bad{% endif %}"> <div class="metric-value {% if service.avg_latency_24h and service.avg_latency_24h < 200 %}good{% elif service.avg_latency_24h and service.avg_latency_24h < 500 %}warning{% elif service.avg_latency_24h %}bad{% endif %}">
{% if service.avg_latency_24h %} {% if service.avg_latency_24h %}
{{ "%.0f"|format(service.avg_latency_24h) }} ms {{ "%.0f"|format(service.avg_latency_24h) }} ms
@@ -501,7 +570,7 @@
</div> </div>
</div> </div>
<div class="metric"> <div class="metric">
<div class="metric-label">Uptime 24h</div> <div class="metric-label">Uptime {{ period }}h</div>
<div class="metric-value {% if service.uptime_percent >= 99 %}good{% elif service.uptime_percent >= 95 %}warning{% else %}bad{% endif %}"> <div class="metric-value {% if service.uptime_percent >= 99 %}good{% elif service.uptime_percent >= 95 %}warning{% else %}bad{% endif %}">
{{ "%.1f"|format(service.uptime_percent) }}% {{ "%.1f"|format(service.uptime_percent) }}%
</div> </div>
@@ -620,13 +689,29 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
// Save scroll position before unload
window.addEventListener('beforeunload', () => {
sessionStorage.setItem('scrollPos', window.scrollY.toString());
});
// Restore scroll position on load
window.addEventListener('load', () => {
const scrollPos = sessionStorage.getItem('scrollPos');
if (scrollPos) {
window.scrollTo(0, parseInt(scrollPos));
}
});
async function refreshStatus(btn) { async function refreshStatus(btn) {
btn.classList.add('loading'); btn.classList.add('loading');
btn.disabled = true; btn.disabled = true;
try { try {
await fetch('/api/refresh', { method: 'POST' }); await fetch('/api/refresh', { method: 'POST' });
window.location.reload(); // Preserve period parameter on reload
const url = new URL(window.location);
url.searchParams.set('period', '{{ period }}');
window.location.href = url.toString();
} catch (e) { } catch (e) {
console.error('Refresh failed:', e); console.error('Refresh failed:', e);
btn.classList.remove('loading'); btn.classList.remove('loading');
@@ -634,9 +719,11 @@
} }
} }
// Auto-refresh page // Auto-refresh page (preserve period parameter)
setTimeout(() => { setTimeout(() => {
window.location.reload(); const url = new URL(window.location);
url.searchParams.set('period', '{{ period }}');
window.location.href = url.toString();
}, {{ check_interval }} * 1000); }, {{ check_interval }} * 1000);
</script> </script>
</body> </body>