diff --git a/REDESIGN_PLAN.md b/REDESIGN_PLAN.md index 1080c2e..a7dd052 100644 --- a/REDESIGN_PLAN.md +++ b/REDESIGN_PLAN.md @@ -22,353 +22,7 @@ Success: #22c55e Error: #ef4444 Text: #e2e8f0 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 MARATHON │ │ -│ │ │ │ -│ │ 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 - ---- +```А ты ## Референсы для вдохновления diff --git a/backend/alembic/versions/018_seed_static_content.py b/backend/alembic/versions/018_seed_static_content.py new file mode 100644 index 0000000..f648f2e --- /dev/null +++ b/backend/alembic/versions/018_seed_static_content.py @@ -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': '''

Настоящее Пользовательское соглашение (далее — «Соглашение») регулирует отношения между администрацией интернет-сервиса «Игровой Марафон» (далее — «Сервис», «Платформа», «Мы») и физическим лицом, использующим Сервис (далее — «Пользователь», «Вы»).

+ +

Дата вступления в силу: с момента регистрации на Платформе.
+Используя Сервис, Вы подтверждаете, что полностью ознакомились с условиями настоящего Соглашения и принимаете их в полном объёме.

+ +
+ +

1. Общие положения

+ +

1.1. Сервис «Игровой Марафон» представляет собой онлайн-платформу для организации и проведения игровых марафонов — соревнований, в рамках которых участники выполняют игровые задания (челленджи) и получают очки за их успешное выполнение.

+ +

1.2. Сервис предоставляет Пользователям следующие возможности:

+ + +

1.3. Сервис предоставляется на условиях «как есть» (as is). Администрация не гарантирует, что Сервис будет соответствовать ожиданиям Пользователя или работать бесперебойно.

+ +
+ +

2. Регистрация и учётная запись

+ +

2.1. Для доступа к функционалу Сервиса необходима регистрация учётной записи. При регистрации Пользователь обязуется предоставить достоверные данные.

+ +

2.2. Пользователь несёт полную ответственность за:

+ + +

2.3. Каждый Пользователь имеет право на одну учётную запись. Создание дополнительных аккаунтов (мультиаккаунтинг) запрещено и влечёт блокировку всех связанных учётных записей.

+ +

2.4. Пользователь вправе в любой момент удалить свою учётную запись, обратившись к Администрации. При удалении аккаунта все связанные данные будут безвозвратно удалены.

+ +
+ +

3. Правила использования Сервиса

+ +

3.1. При использовании Сервиса запрещается:

+ + +

3.2. Правила проведения марафонов:

+ + +

3.3. Организаторы марафонов несут ответственность за соблюдение правил в рамках своих мероприятий и имеют право устанавливать дополнительные правила, не противоречащие настоящему Соглашению.

+ +
+ +

4. Система очков и рейтинг

+ +

4.1. За выполнение заданий Пользователи получают очки, количество которых зависит от сложности задания и активных игровых событий.

+ +

4.2. Очки используются исключительно для формирования рейтинга участников в рамках марафонов и не имеют денежного эквивалента.

+ +

4.3. Администрация оставляет за собой право корректировать начисленные очки в случае выявления нарушений или технических ошибок.

+ +
+ +

5. Ответственность сторон

+ +

5.1. Администрация не несёт ответственности за:

+ + +

5.2. Пользователь несёт ответственность за соблюдение условий настоящего Соглашения и применимого законодательства.

+ +
+ +

6. Санкции за нарушения

+ +

6.1. За нарушение условий настоящего Соглашения Администрация вправе применить следующие санкции:

+ + +

6.2. Решение о применении санкций принимается Администрацией единолично и является окончательным. Администрация не обязана объяснять причины принятого решения.

+ +

6.3. Обход блокировки путём создания новых учётных записей влечёт блокировку всех выявленных аккаунтов.

+ +
+ +

7. Интеллектуальная собственность

+ +

7.1. Все элементы Сервиса (дизайн, код, тексты, логотипы) являются объектами интеллектуальной собственности Администрации и защищены применимым законодательством.

+ +

7.2. Использование материалов Сервиса без письменного разрешения Администрации запрещено.

+ +
+ +

8. Изменение условий Соглашения

+ +

8.1. Администрация вправе в одностороннем порядке изменять условия настоящего Соглашения.

+ +

8.2. Актуальная редакция Соглашения размещается на данной странице с указанием даты последнего обновления.

+ +

8.3. Продолжение использования Сервиса после внесения изменений означает согласие Пользователя с новой редакцией Соглашения.

+ +
+ +

9. Заключительные положения

+ +

9.1. Настоящее Соглашение регулируется законодательством Российской Федерации.

+ +

9.2. Все споры, возникающие в связи с использованием Сервиса, подлежат разрешению путём переговоров. При недостижении согласия споры разрешаются в судебном порядке по месту нахождения Администрации.

+ +

9.3. Признание судом недействительности какого-либо положения настоящего Соглашения не влечёт недействительности остальных положений.

+ +

9.4. По всем вопросам, связанным с использованием Сервиса, Вы можете обратиться к Администрации через Telegram-бота или иные доступные каналы связи.

''' + }, + { + 'key': 'privacy_policy', + 'title': 'Политика конфиденциальности', + 'content': '''

Настоящая Политика конфиденциальности (далее — «Политика») описывает, как интернет-сервис «Игровой Марафон» (далее — «Сервис», «Мы») собирает, использует, хранит и защищает персональные данные пользователей (далее — «Пользователь», «Вы»).

+ +

Используя Сервис, Вы даёте согласие на обработку Ваших персональных данных в соответствии с условиями настоящей Политики.

+ +
+ +

1. Собираемые данные

+ +

1.1. Данные, предоставляемые Пользователем:

+ + +

1.2. Данные, собираемые автоматически:

+ + +
+ +

2. Цели обработки данных

+ +

2.1. Мы обрабатываем Ваши персональные данные для следующих целей:

+ +

Предоставление услуг:

+ + +

Коммуникация:

+ + +

Безопасность:

+ + +
+ +

3. Правовые основания обработки

+ +

3.1. Обработка персональных данных осуществляется на следующих основаниях:

+ + +
+ +

4. Хранение и защита данных

+ +

4.1. Меры безопасности:

+ + +

4.2. Срок хранения:

+ + +
+ +

5. Передача данных третьим лицам

+ +

5.1. Мы не продаём, не сдаём в аренду и не передаём Ваши персональные данные третьим лицам в коммерческих целях.

+ +

5.2. Данные могут быть переданы:

+ + +

5.3. Публично доступная информация:

+

Следующие данные видны другим Пользователям Сервиса:

+ + +
+ +

6. Права Пользователя

+ +

6.1. Вы имеете право:

+ + +

6.2. Для реализации своих прав обратитесь к Администрации через доступные каналы связи.

+ +
+ +

7. Файлы cookie и локальное хранилище

+ +

7.1. Сервис использует локальное хранилище браузера (localStorage, sessionStorage) для:

+ + +

7.2. Вы можете очистить локальное хранилище в настройках браузера, однако это приведёт к выходу из учётной записи.

+ +
+ +

8. Обработка данных несовершеннолетних

+ +

8.1. Сервис не предназначен для лиц младше 14 лет. Мы сознательно не собираем персональные данные детей.

+ +

8.2. Если Вам стало известно, что ребёнок предоставил нам персональные данные, пожалуйста, свяжитесь с Администрацией для их удаления.

+ +
+ +

9. Изменение Политики

+ +

9.1. Мы оставляем за собой право изменять настоящую Политику. Актуальная редакция всегда доступна на данной странице.

+ +

9.2. О существенных изменениях мы уведомим Пользователей через Telegram-бота или баннер на сайте.

+ +

9.3. Продолжение использования Сервиса после внесения изменений означает согласие с обновлённой Политикой.

+ +
+ +

10. Контактная информация

+ +

10.1. По вопросам, связанным с обработкой персональных данных, Вы можете обратиться к Администрации через:

+ + +

10.2. Мы обязуемся рассмотреть Ваше обращение в разумные сроки и предоставить ответ.

''' + }, + { + '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)})") diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py index f5aa817..130e045 100644 --- a/backend/app/api/v1/admin.py +++ b/backend/app/api/v1/admin.py @@ -758,6 +758,37 @@ async def create_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 ============ @router.get("/dashboard", response_model=DashboardStats) async def get_dashboard(current_user: CurrentUser, db: DbSession): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1cb4718..407cda1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ import { InvitePage } from '@/pages/InvitePage' import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage' import { ProfilePage } from '@/pages/ProfilePage' import { UserProfilePage } from '@/pages/UserProfilePage' +import { StaticContentPage } from '@/pages/StaticContentPage' import { NotFoundPage } from '@/pages/NotFoundPage' import { TeapotPage } from '@/pages/TeapotPage' import { ServerErrorPage } from '@/pages/ServerErrorPage' @@ -89,6 +90,11 @@ function App() { {/* Public invite page */} } /> + {/* Public static content pages */} + } /> + } /> + } /> + ('/admin/content', { key, title, content }) return response.data }, + + deleteContent: async (key: string): Promise => { + await client.delete(`/admin/content/${key}`) + }, } // Public content API (no auth required) diff --git a/frontend/src/components/AnnouncementBanner.tsx b/frontend/src/components/AnnouncementBanner.tsx new file mode 100644 index 0000000..55059b6 --- /dev/null +++ b/frontend/src/components/AnnouncementBanner.tsx @@ -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(null) + const [title, setTitle] = useState(null) + const [updatedAt, setUpdatedAt] = useState(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 ( +
+ {/* Close button */} + + + {/* Content */} +
+
+ +
+
+ {title && ( +

{title}

+ )} +
+
+
+
+ ) +} diff --git a/frontend/src/components/TelegramBotBanner.tsx b/frontend/src/components/TelegramBotBanner.tsx index a39a94b..63ca42b 100644 --- a/frontend/src/components/TelegramBotBanner.tsx +++ b/frontend/src/components/TelegramBotBanner.tsx @@ -1,16 +1,36 @@ 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 } from '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') @@ -49,10 +69,10 @@ export function TelegramBotBanner() {

- Привяжите Telegram-бота + {title}

- Получайте уведомления о событиях марафона, новых заданиях и результатах прямо в Telegram + {description}

diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index d5a5b71..b2f3959 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -234,7 +234,13 @@ export function Layout() { Игровой Марафон © {new Date().getFullYear()}
-
+
+ + Правила + + + Конфиденциальность + v1.0
diff --git a/frontend/src/pages/MarathonsPage.tsx b/frontend/src/pages/MarathonsPage.tsx index 50170ae..0e6658f 100644 --- a/frontend/src/pages/MarathonsPage.tsx +++ b/frontend/src/pages/MarathonsPage.tsx @@ -5,6 +5,7 @@ import type { MarathonListItem } from '@/types' import { NeonButton, GlassCard, StatsCard } from '@/components/ui' 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 { ru } from 'date-fns/locale' @@ -146,6 +147,11 @@ export function MarathonsPage() {
)} + {/* Announcement Banner */} +
+ +
+ {/* Telegram Bot Banner */}
diff --git a/frontend/src/pages/StaticContentPage.tsx b/frontend/src/pages/StaticContentPage.tsx new file mode 100644 index 0000000..40a7030 --- /dev/null +++ b/frontend/src/pages/StaticContentPage.tsx @@ -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 = { + '/terms': 'terms_of_service', + '/privacy': 'privacy_policy', +} + +export function StaticContentPage() { + const { key: paramKey } = useParams<{ key: string }>() + const location = useLocation() + const [content, setContent] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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 ( +
+ +

Загрузка...

+
+ ) + } + + if (error || !content) { + return ( +
+ +
+ +
+

Страница не найдена

+

Запрашиваемый контент не существует

+ + + На главную + +
+
+ ) + } + + return ( +
+ + + На главную + + + +

{content.title}

+
+
+ Последнее обновление: {new Date(content.updated_at).toLocaleDateString('ru-RU', { + day: 'numeric', + month: 'long', + year: 'numeric' + })} +
+ +
+ ) +} diff --git a/frontend/src/pages/admin/AdminContentPage.tsx b/frontend/src/pages/admin/AdminContentPage.tsx index a34f3cf..dc1904c 100644 --- a/frontend/src/pages/admin/AdminContentPage.tsx +++ b/frontend/src/pages/admin/AdminContentPage.tsx @@ -3,7 +3,8 @@ import { adminApi } from '@/api' import type { StaticContent } from '@/types' import { useToast } from '@/store/toast' 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) { return new Date(dateStr).toLocaleString('ru-RU', { @@ -28,6 +29,7 @@ export function AdminContentPage() { const [formContent, setFormContent] = useState('') const toast = useToast() + const confirm = useConfirm() useEffect(() => { 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) { return (
@@ -155,15 +181,28 @@ export function AdminContentPage() { {content.content.replace(/<[^>]*>/g, '').slice(0, 100)}...

- +
+ + +

Обновлено: {formatDate(content.updated_at)}