diff --git a/backend/alembic/versions/029_add_widget_tokens.py b/backend/alembic/versions/029_add_widget_tokens.py new file mode 100644 index 0000000..46ccde4 --- /dev/null +++ b/backend/alembic/versions/029_add_widget_tokens.py @@ -0,0 +1,36 @@ +"""Add widget tokens + +Revision ID: 029 +Revises: 028 +Create Date: 2025-01-09 +""" +from alembic import op +import sqlalchemy as sa + + +revision = '029_add_widget_tokens' +down_revision = '028_add_promo_codes' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'widget_tokens', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('token', sa.String(64), nullable=False), + sa.Column('participant_id', sa.Integer(), nullable=False), + sa.Column('marathon_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['participant_id'], ['participants.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['marathon_id'], ['marathons.id'], ondelete='CASCADE'), + ) + op.create_index('ix_widget_tokens_token', 'widget_tokens', ['token'], unique=True) + + +def downgrade(): + op.drop_index('ix_widget_tokens_token', table_name='widget_tokens') + op.drop_table('widget_tokens') diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index f1b5f71..5c77374 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop, promo +from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop, promo, widgets router = APIRouter(prefix="/api/v1") @@ -18,3 +18,4 @@ router.include_router(telegram.router) router.include_router(content.router) router.include_router(shop.router) router.include_router(promo.router) +router.include_router(widgets.router) diff --git a/backend/app/api/v1/widgets.py b/backend/app/api/v1/widgets.py new file mode 100644 index 0000000..22dfbcc --- /dev/null +++ b/backend/app/api/v1/widgets.py @@ -0,0 +1,423 @@ +import secrets +from datetime import datetime +from fastapi import APIRouter, HTTPException, status, Query +from sqlalchemy import select, func +from sqlalchemy.orm import selectinload + +from app.api.deps import DbSession, CurrentUser, require_participant +from app.models import ( + WidgetToken, Participant, Marathon, Assignment, AssignmentStatus, + BonusAssignment, BonusAssignmentStatus, +) +from app.schemas.widget import ( + WidgetTokenResponse, + WidgetTokenListItem, + WidgetLeaderboardEntry, + WidgetLeaderboardResponse, + WidgetCurrentResponse, + WidgetProgressResponse, +) +from app.schemas.common import MessageResponse +from app.core.config import settings + +router = APIRouter(prefix="/widgets", tags=["widgets"]) + + +def get_avatar_url(user) -> str | None: + """Get avatar URL - through backend API if user has avatar, else telegram""" + if user.avatar_path: + return f"/api/v1/users/{user.id}/avatar" + return user.telegram_avatar_url + + +def generate_widget_token() -> str: + """Generate a secure widget token""" + return f"wgt_{secrets.token_urlsafe(32)}" + + +def build_widget_urls(marathon_id: int, token: str) -> dict[str, str]: + """Build widget URLs for the token""" + base_url = settings.FRONTEND_URL or "http://localhost:5173" + params = f"marathon={marathon_id}&token={token}" + return { + "leaderboard": f"{base_url}/widget/leaderboard?{params}", + "current": f"{base_url}/widget/current?{params}", + "progress": f"{base_url}/widget/progress?{params}", + } + + +# === Token management (authenticated) === + +@router.post("/marathons/{marathon_id}/token", response_model=WidgetTokenResponse) +async def create_widget_token( + marathon_id: int, + current_user: CurrentUser, + db: DbSession, +): + """Create a widget token for the current user in a marathon""" + participant = await require_participant(db, current_user.id, marathon_id) + + # Check if user already has an active token + existing = await db.scalar( + select(WidgetToken).where( + WidgetToken.participant_id == participant.id, + WidgetToken.marathon_id == marathon_id, + WidgetToken.is_active == True, + ) + ) + + if existing: + # Return existing token + return WidgetTokenResponse( + id=existing.id, + token=existing.token, + created_at=existing.created_at, + expires_at=existing.expires_at, + is_active=existing.is_active, + urls=build_widget_urls(marathon_id, existing.token), + ) + + # Create new token + token = generate_widget_token() + widget_token = WidgetToken( + token=token, + participant_id=participant.id, + marathon_id=marathon_id, + ) + db.add(widget_token) + await db.commit() + await db.refresh(widget_token) + + return WidgetTokenResponse( + id=widget_token.id, + token=widget_token.token, + created_at=widget_token.created_at, + expires_at=widget_token.expires_at, + is_active=widget_token.is_active, + urls=build_widget_urls(marathon_id, widget_token.token), + ) + + +@router.get("/marathons/{marathon_id}/tokens", response_model=list[WidgetTokenListItem]) +async def list_widget_tokens( + marathon_id: int, + current_user: CurrentUser, + db: DbSession, +): + """List all widget tokens for the current user in a marathon""" + participant = await require_participant(db, current_user.id, marathon_id) + + result = await db.execute( + select(WidgetToken) + .where( + WidgetToken.participant_id == participant.id, + WidgetToken.marathon_id == marathon_id, + ) + .order_by(WidgetToken.created_at.desc()) + ) + tokens = result.scalars().all() + + return [ + WidgetTokenListItem( + id=t.id, + token=t.token, + created_at=t.created_at, + is_active=t.is_active, + ) + for t in tokens + ] + + +@router.delete("/tokens/{token_id}", response_model=MessageResponse) +async def revoke_widget_token( + token_id: int, + current_user: CurrentUser, + db: DbSession, +): + """Revoke a widget token""" + result = await db.execute( + select(WidgetToken) + .options(selectinload(WidgetToken.participant)) + .where(WidgetToken.id == token_id) + ) + widget_token = result.scalar_one_or_none() + + if not widget_token: + raise HTTPException(status_code=404, detail="Token not found") + + if widget_token.participant.user_id != current_user.id and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized to revoke this token") + + widget_token.is_active = False + await db.commit() + + return MessageResponse(message="Token revoked") + + +@router.post("/tokens/{token_id}/regenerate", response_model=WidgetTokenResponse) +async def regenerate_widget_token( + token_id: int, + current_user: CurrentUser, + db: DbSession, +): + """Regenerate a widget token (deactivates old, creates new)""" + result = await db.execute( + select(WidgetToken) + .options(selectinload(WidgetToken.participant)) + .where(WidgetToken.id == token_id) + ) + old_token = result.scalar_one_or_none() + + if not old_token: + raise HTTPException(status_code=404, detail="Token not found") + + if old_token.participant.user_id != current_user.id and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + # Deactivate old token + old_token.is_active = False + + # Create new token + new_token = WidgetToken( + token=generate_widget_token(), + participant_id=old_token.participant_id, + marathon_id=old_token.marathon_id, + ) + db.add(new_token) + await db.commit() + await db.refresh(new_token) + + return WidgetTokenResponse( + id=new_token.id, + token=new_token.token, + created_at=new_token.created_at, + expires_at=new_token.expires_at, + is_active=new_token.is_active, + urls=build_widget_urls(new_token.marathon_id, new_token.token), + ) + + +# === Public widget endpoints (authenticated via widget token) === + +async def validate_widget_token(token: str, marathon_id: int, db) -> WidgetToken: + """Validate widget token and return it""" + result = await db.execute( + select(WidgetToken) + .options( + selectinload(WidgetToken.participant).selectinload(Participant.user), + selectinload(WidgetToken.marathon), + ) + .where( + WidgetToken.token == token, + WidgetToken.marathon_id == marathon_id, + WidgetToken.is_active == True, + ) + ) + widget_token = result.scalar_one_or_none() + + if not widget_token: + raise HTTPException(status_code=401, detail="Invalid widget token") + + if widget_token.expires_at and widget_token.expires_at < datetime.utcnow(): + raise HTTPException(status_code=401, detail="Widget token expired") + + return widget_token + + +@router.get("/data/leaderboard", response_model=WidgetLeaderboardResponse) +async def widget_leaderboard( + marathon: int = Query(..., description="Marathon ID"), + token: str = Query(..., description="Widget token"), + count: int = Query(5, ge=1, le=50, description="Number of participants"), + db: DbSession = None, +): + """Get leaderboard data for widget""" + widget_token = await validate_widget_token(token, marathon, db) + current_participant = widget_token.participant + + # Get all participants ordered by points + result = await db.execute( + select(Participant) + .options(selectinload(Participant.user)) + .where(Participant.marathon_id == marathon) + .order_by(Participant.total_points.desc()) + ) + all_participants = result.scalars().all() + + total_participants = len(all_participants) + current_user_rank = None + + # Find current user rank and build entries + entries = [] + for rank, p in enumerate(all_participants, 1): + if p.id == current_participant.id: + current_user_rank = rank + + if rank <= count: + user = p.user + entries.append(WidgetLeaderboardEntry( + rank=rank, + nickname=user.nickname, + avatar_url=get_avatar_url(user), + total_points=p.total_points, + current_streak=p.current_streak, + is_current_user=(p.id == current_participant.id), + )) + + return WidgetLeaderboardResponse( + entries=entries, + current_user_rank=current_user_rank, + total_participants=total_participants, + marathon_title=widget_token.marathon.title, + ) + + +@router.get("/data/current", response_model=WidgetCurrentResponse) +async def widget_current_assignment( + marathon: int = Query(..., description="Marathon ID"), + token: str = Query(..., description="Widget token"), + db: DbSession = None, +): + """Get current assignment data for widget""" + widget_token = await validate_widget_token(token, marathon, db) + participant = widget_token.participant + + # Get active assignment + result = await db.execute( + select(Assignment) + .options( + selectinload(Assignment.challenge), + selectinload(Assignment.game), + ) + .where( + Assignment.participant_id == participant.id, + Assignment.status.in_([ + AssignmentStatus.ACTIVE.value, + AssignmentStatus.RETURNED.value, + ]), + ) + .order_by(Assignment.started_at.desc()) + .limit(1) + ) + assignment = result.scalar_one_or_none() + + if not assignment: + return WidgetCurrentResponse(has_assignment=False) + + # Determine assignment type and details + if assignment.is_playthrough: + game = assignment.game + assignment_type = "playthrough" + challenge_title = "Прохождение" + challenge_description = game.playthrough_description + points = game.playthrough_points + difficulty = None + + # Count bonus challenges + bonus_result = await db.execute( + select(func.count()).select_from(BonusAssignment) + .where(BonusAssignment.main_assignment_id == assignment.id) + ) + bonus_total = bonus_result.scalar() or 0 + + completed_result = await db.execute( + select(func.count()).select_from(BonusAssignment) + .where( + BonusAssignment.main_assignment_id == assignment.id, + BonusAssignment.status == BonusAssignmentStatus.COMPLETED.value, + ) + ) + bonus_completed = completed_result.scalar() or 0 + + game_title = game.title + game_cover_url = f"/api/v1/games/{game.id}/cover" if game.cover_path else None + else: + challenge = assignment.challenge + assignment_type = "challenge" + challenge_title = challenge.title + challenge_description = challenge.description + points = challenge.points + difficulty = challenge.difficulty + bonus_completed = None + bonus_total = None + + game = challenge.game if hasattr(challenge, 'game') else None + if not game: + # Load game via challenge + from app.models import Game + game_result = await db.execute( + select(Game).where(Game.id == challenge.game_id) + ) + game = game_result.scalar_one_or_none() + + game_title = game.title if game else None + game_cover_url = f"/api/v1/games/{game.id}/cover" if game and game.cover_path else None + + return WidgetCurrentResponse( + has_assignment=True, + game_title=game_title, + game_cover_url=game_cover_url, + assignment_type=assignment_type, + challenge_title=challenge_title, + challenge_description=challenge_description, + points=points, + difficulty=difficulty, + bonus_completed=bonus_completed, + bonus_total=bonus_total, + ) + + +@router.get("/data/progress", response_model=WidgetProgressResponse) +async def widget_progress( + marathon: int = Query(..., description="Marathon ID"), + token: str = Query(..., description="Widget token"), + db: DbSession = None, +): + """Get participant progress data for widget""" + widget_token = await validate_widget_token(token, marathon, db) + participant = widget_token.participant + user = participant.user + + # Calculate rank + result = await db.execute( + select(func.count()) + .select_from(Participant) + .where( + Participant.marathon_id == marathon, + Participant.total_points > participant.total_points, + ) + ) + higher_count = result.scalar() or 0 + rank = higher_count + 1 + + # Count completed and dropped assignments + completed_result = await db.execute( + select(func.count()) + .select_from(Assignment) + .where( + Assignment.participant_id == participant.id, + Assignment.status == AssignmentStatus.COMPLETED.value, + ) + ) + completed_count = completed_result.scalar() or 0 + + dropped_result = await db.execute( + select(func.count()) + .select_from(Assignment) + .where( + Assignment.participant_id == participant.id, + Assignment.status == AssignmentStatus.DROPPED.value, + ) + ) + dropped_count = dropped_result.scalar() or 0 + + return WidgetProgressResponse( + nickname=user.nickname, + avatar_url=get_avatar_url(user), + rank=rank, + total_points=participant.total_points, + current_streak=participant.current_streak, + completed_count=completed_count, + dropped_count=dropped_count, + marathon_title=widget_token.marathon.title, + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 6ccff8a..8b4b8a5 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -18,6 +18,7 @@ from app.models.inventory import UserInventory from app.models.coin_transaction import CoinTransaction, CoinTransactionType from app.models.consumable_usage import ConsumableUsage from app.models.promo_code import PromoCode, PromoCodeRedemption +from app.models.widget_token import WidgetToken __all__ = [ "User", @@ -65,4 +66,5 @@ __all__ = [ "ConsumableUsage", "PromoCode", "PromoCodeRedemption", + "WidgetToken", ] diff --git a/backend/app/models/widget_token.py b/backend/app/models/widget_token.py new file mode 100644 index 0000000..308f417 --- /dev/null +++ b/backend/app/models/widget_token.py @@ -0,0 +1,22 @@ +from datetime import datetime +from sqlalchemy import DateTime, ForeignKey, String, Boolean +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class WidgetToken(Base): + """Токен для авторизации OBS виджетов""" + __tablename__ = "widget_tokens" + + id: Mapped[int] = mapped_column(primary_key=True) + token: Mapped[str] = mapped_column(String(64), unique=True, index=True) + participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE")) + marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE")) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + # Relationships + participant: Mapped["Participant"] = relationship("Participant") + marathon: Mapped["Marathon"] = relationship("Marathon") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 24a84db..eeb867e 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -134,6 +134,15 @@ from app.schemas.promo_code import ( PromoCodeRedemptionUser, ) from app.schemas.user import ShopItemPublic +from app.schemas.widget import ( + WidgetTokenCreate, + WidgetTokenResponse, + WidgetTokenListItem, + WidgetLeaderboardEntry, + WidgetLeaderboardResponse, + WidgetCurrentResponse, + WidgetProgressResponse, +) __all__ = [ # User @@ -260,4 +269,12 @@ __all__ = [ "PromoCodeRedeemResponse", "PromoCodeRedemptionResponse", "PromoCodeRedemptionUser", + # Widget + "WidgetTokenCreate", + "WidgetTokenResponse", + "WidgetTokenListItem", + "WidgetLeaderboardEntry", + "WidgetLeaderboardResponse", + "WidgetCurrentResponse", + "WidgetProgressResponse", ] diff --git a/backend/app/schemas/widget.py b/backend/app/schemas/widget.py new file mode 100644 index 0000000..8bbece2 --- /dev/null +++ b/backend/app/schemas/widget.py @@ -0,0 +1,79 @@ +from pydantic import BaseModel +from datetime import datetime + + +# === Token schemas === + +class WidgetTokenCreate(BaseModel): + """Создание токена виджета""" + pass # Не требует параметров + + +class WidgetTokenResponse(BaseModel): + """Ответ с токеном виджета""" + id: int + token: str + created_at: datetime + expires_at: datetime | None + is_active: bool + urls: dict[str, str] # Готовые URL для виджетов + + class Config: + from_attributes = True + + +class WidgetTokenListItem(BaseModel): + """Элемент списка токенов""" + id: int + token: str + created_at: datetime + is_active: bool + + class Config: + from_attributes = True + + +# === Widget data schemas === + +class WidgetLeaderboardEntry(BaseModel): + """Запись в лидерборде виджета""" + rank: int + nickname: str + avatar_url: str | None + total_points: int + current_streak: int + is_current_user: bool # Для подсветки + + +class WidgetLeaderboardResponse(BaseModel): + """Ответ лидерборда для виджета""" + entries: list[WidgetLeaderboardEntry] + current_user_rank: int | None + total_participants: int + marathon_title: str + + +class WidgetCurrentResponse(BaseModel): + """Текущее задание для виджета""" + has_assignment: bool + game_title: str | None = None + game_cover_url: str | None = None + assignment_type: str | None = None # "challenge" | "playthrough" + challenge_title: str | None = None + challenge_description: str | None = None + points: int | None = None + difficulty: str | None = None # easy, medium, hard + bonus_completed: int | None = None # Для прохождений + bonus_total: int | None = None + + +class WidgetProgressResponse(BaseModel): + """Прогресс участника для виджета""" + nickname: str + avatar_url: str | None + rank: int + total_points: int + current_streak: int + completed_count: int + dropped_count: int + marathon_title: str diff --git a/docs/tz-obs-widget.md b/docs/tz-obs-widget.md new file mode 100644 index 0000000..397611f --- /dev/null +++ b/docs/tz-obs-widget.md @@ -0,0 +1,664 @@ +# ТЗ: OBS Виджеты для стрима + +## Описание задачи + +Создать набор виджетов для отображения информации о марафоне в OBS через Browser Source. Виджеты позволяют стримерам показывать зрителям актуальную информацию о марафоне в реальном времени. + +--- + +## Виджеты + +### 1. Лидерборд + +Таблица участников марафона с их позициями и очками. + +| Поле | Описание | +|------|----------| +| Место | Позиция в рейтинге (1, 2, 3...) | +| Аватар | Аватарка участника (круглая, 32x32 px) | +| Никнейм | Имя участника | +| Очки | Текущее количество очков | +| Стрик | Текущий стрик (опционально) | + +**Настройки:** +- Количество отображаемых участников (3, 5, 10, все) +- Подсветка текущего стримера +- Показ/скрытие аватарок +- Показ/скрытие стриков + +--- + +### 2. Текущее задание + +Отображает активное задание стримера. + +| Поле | Описание | +|------|----------| +| Игра | Название игры | +| Задание | Описание челленджа / прохождения | +| Очки | Количество очков за выполнение | +| Тип | Челлендж / Прохождение | +| Прогресс бонусов | Для прохождений: X/Y бонусных челленджей | + +**Состояния:** +- Активное задание — показывает детали +- Нет задания — "Ожидание спина" или скрыт + +--- + +### 3. Прогресс марафона + +Общая статистика стримера в марафоне. + +| Поле | Описание | +|------|----------| +| Позиция | Текущее место в рейтинге | +| Очки | Набранные очки | +| Стрик | Текущий стрик | +| Выполнено | Количество выполненных заданий | +| Дропнуто | Количество дропнутых заданий | + +--- + +### 4. Комбинированный виджет (опционально) + +Объединяет несколько блоков в одном виджете: +- Мини-лидерборд (топ-3) +- Текущее задание +- Статистика стримера + +--- + +## Техническая реализация + +### Архитектура + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ OBS Browser Source │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────┐ │ +│ │ /widget/{type}?params │ │ +│ │ │ │ +│ │ Frontend страница │ │ +│ │ (React / статический HTML)│ │ +│ └─────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────┐ │ +│ │ WebSocket / Polling │ │ +│ │ Обновление данных │ │ +│ └─────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────┐ │ +│ │ Backend API │ │ +│ │ /api/v1/widget/* │ │ +│ └─────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### URL структура + +``` +/widget/leaderboard?marathon={id}&token={token}&theme={theme}&count={count} +/widget/current?marathon={id}&token={token}&theme={theme} +/widget/progress?marathon={id}&token={token}&theme={theme} +/widget/combined?marathon={id}&token={token}&theme={theme} +``` + +### Параметры URL + +| Параметр | Обязательный | Описание | +|----------|--------------|----------| +| `marathon` | Да | ID марафона | +| `token` | Да | Токен виджета (привязан к участнику) | +| `theme` | Нет | Тема оформления (dark, light, custom) | +| `count` | Нет | Количество участников (для лидерборда) | +| `highlight` | Нет | Подсветить пользователя (true/false) | +| `avatars` | Нет | Показывать аватарки (true/false, по умолчанию true) | +| `fontSize` | Нет | Размер шрифта (sm, md, lg) | +| `width` | Нет | Ширина виджета в пикселях | +| `transparent` | Нет | Прозрачный фон (true/false) | + +--- + +## Backend API + +### Токен виджета + +Для авторизации виджетов используется специальный токен, привязанный к участнику марафона. Это позволяет: +- Идентифицировать стримера для подсветки в лидерборде +- Показывать личную статистику и задания +- Не требовать полной авторизации в OBS + +#### Генерация токена + +``` +POST /api/v1/marathons/{marathon_id}/widget-token +Authorization: Bearer {jwt_token} + +Response: +{ + "token": "wgt_abc123xyz...", + "expires_at": null, // Бессрочный или с датой + "urls": { + "leaderboard": "https://marathon.example.com/widget/leaderboard?marathon=1&token=wgt_abc123xyz", + "current": "https://marathon.example.com/widget/current?marathon=1&token=wgt_abc123xyz", + "progress": "https://marathon.example.com/widget/progress?marathon=1&token=wgt_abc123xyz" + } +} +``` + +#### Модель токена + +```python +class WidgetToken(Base): + __tablename__ = "widget_tokens" + + id: Mapped[int] = mapped_column(primary_key=True) + token: Mapped[str] = mapped_column(String(64), unique=True, index=True) + participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id")) + marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id")) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + participant: Mapped["Participant"] = relationship() + marathon: Mapped["Marathon"] = relationship() +``` + +### Эндпоинты виджетов + +```python +# Публичные эндпоинты (авторизация через widget token) + +@router.get("/widget/leaderboard") +async def widget_leaderboard( + marathon: int, + token: str, + count: int = 10, + db: DbSession +) -> WidgetLeaderboardResponse: + """ + Получить данные лидерборда для виджета. + Возвращает топ участников и позицию владельца токена. + """ + +@router.get("/widget/current") +async def widget_current_assignment( + marathon: int, + token: str, + db: DbSession +) -> WidgetCurrentResponse: + """ + Получить текущее задание владельца токена. + """ + +@router.get("/widget/progress") +async def widget_progress( + marathon: int, + token: str, + db: DbSession +) -> WidgetProgressResponse: + """ + Получить статистику владельца токена. + """ +``` + +### Схемы ответов + +```python +class WidgetLeaderboardEntry(BaseModel): + rank: int + nickname: str + avatar_url: str | None + total_points: int + current_streak: int + is_current_user: bool # Для подсветки + +class WidgetLeaderboardResponse(BaseModel): + entries: list[WidgetLeaderboardEntry] + current_user_rank: int | None + total_participants: int + marathon_title: str + +class WidgetCurrentResponse(BaseModel): + has_assignment: bool + game_title: str | None + game_cover_url: str | None + assignment_type: str | None # "challenge" | "playthrough" + challenge_title: str | None + challenge_description: str | None + points: int | None + bonus_completed: int | None # Для прохождений + bonus_total: int | None + +class WidgetProgressResponse(BaseModel): + nickname: str + avatar_url: str | None + rank: int + total_points: int + current_streak: int + completed_count: int + dropped_count: int + marathon_title: str +``` + +--- + +## Frontend + +### Структура файлов + +``` +frontend/ +├── src/ +│ ├── pages/ +│ │ └── widget/ +│ │ ├── LeaderboardWidget.tsx +│ │ ├── CurrentWidget.tsx +│ │ ├── ProgressWidget.tsx +│ │ └── CombinedWidget.tsx +│ ├── components/ +│ │ └── widget/ +│ │ ├── WidgetContainer.tsx +│ │ ├── LeaderboardRow.tsx +│ │ ├── AssignmentCard.tsx +│ │ └── StatsBlock.tsx +│ └── styles/ +│ └── widget/ +│ ├── themes/ +│ │ ├── dark.css +│ │ ├── light.css +│ │ └── neon.css +│ └── widget.css +``` + +### Роутинг + +```tsx +// App.tsx или router config +} /> +} /> +} /> +} /> +``` + +### Компонент виджета + +```tsx +// pages/widget/LeaderboardWidget.tsx +import { useSearchParams } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { widgetApi } from '@/api/widget' + +const LeaderboardWidget = () => { + const [params] = useSearchParams() + const marathon = params.get('marathon') + const token = params.get('token') + const theme = params.get('theme') || 'dark' + const count = parseInt(params.get('count') || '5') + const highlight = params.get('highlight') !== 'false' + + const { data, isLoading } = useQuery({ + queryKey: ['widget-leaderboard', marathon, token], + queryFn: () => widgetApi.getLeaderboard(marathon, token, count), + refetchInterval: 30000, // Обновление каждые 30 сек + }) + + if (isLoading) return + if (!data) return null + + return ( + +
+

{data.marathon_title}

+ {data.entries.map((entry) => ( + + ))} +
+
+ ) +} +``` + +--- + +## Темы оформления + +### Базовые темы + +#### Dark (по умолчанию) +```css +.widget-theme-dark { + --widget-bg: rgba(18, 18, 18, 0.95); + --widget-text: #ffffff; + --widget-text-secondary: #a0a0a0; + --widget-accent: #8b5cf6; + --widget-highlight: rgba(139, 92, 246, 0.2); + --widget-border: rgba(255, 255, 255, 0.1); +} +``` + +#### Light +```css +.widget-theme-light { + --widget-bg: rgba(255, 255, 255, 0.95); + --widget-text: #1a1a1a; + --widget-text-secondary: #666666; + --widget-accent: #7c3aed; + --widget-highlight: rgba(124, 58, 237, 0.1); + --widget-border: rgba(0, 0, 0, 0.1); +} +``` + +#### Neon +```css +.widget-theme-neon { + --widget-bg: rgba(0, 0, 0, 0.9); + --widget-text: #00ff88; + --widget-text-secondary: #00cc6a; + --widget-accent: #ff00ff; + --widget-highlight: rgba(255, 0, 255, 0.2); + --widget-border: #00ff88; +} +``` + +#### Transparent +```css +.widget-transparent { + --widget-bg: transparent; +} +``` + +### Кастомизация через URL + +``` +?theme=dark +?theme=light +?theme=neon +?theme=custom&bg=1a1a1a&text=ffffff&accent=ff6600 +?transparent=true +``` + +--- + +## Обновление данных + +### Варианты + +| Способ | Описание | Плюсы | Минусы | +|--------|----------|-------|--------| +| Polling | Периодический запрос (30 сек) | Простота | Задержка, нагрузка | +| WebSocket | Реал-тайм обновления | Мгновенно | Сложность | +| SSE | Server-Sent Events | Простой real-time | Односторонний | + +### Рекомендация + +**Polling с интервалом 30 секунд** — оптимальный баланс: +- Простая реализация +- Минимальная нагрузка на сервер +- Достаточная актуальность для стрима + +Для будущего развития можно добавить WebSocket. + +--- + +## Интерфейс настройки + +### Страница генерации виджетов + +В личном кабинете участника добавить раздел "Виджеты для стрима": + +```tsx +// pages/WidgetSettingsPage.tsx +const WidgetSettingsPage = () => { + const [widgetToken, setWidgetToken] = useState(null) + const [selectedTheme, setSelectedTheme] = useState('dark') + const [leaderboardCount, setLeaderboardCount] = useState(5) + + const generateToken = async () => { + const response = await api.createWidgetToken(marathonId) + setWidgetToken(response.token) + } + + const widgetUrl = (type: string) => { + const params = new URLSearchParams({ + marathon: marathonId.toString(), + token: widgetToken, + theme: selectedTheme, + ...(type === 'leaderboard' && { count: leaderboardCount.toString() }), + }) + return `${window.location.origin}/widget/${type}?${params}` + } + + return ( +
+

Виджеты для OBS

+ + {!widgetToken ? ( + + ) : ( + <> +
+ +
+ +
+ } + /> + } + /> + +
+ +
+
    +
  1. Скопируйте нужную ссылку
  2. +
  3. В OBS добавьте источник "Browser"
  4. +
  5. Вставьте ссылку в поле URL
  6. +
  7. Установите размер (рекомендуется: 400x300)
  8. +
+
+ + )} +
+ ) +} +``` + +### Превью виджетов + +Показывать живой превью виджета с текущими настройками: + +```tsx +const WidgetPreview = ({ type, params }) => { + return ( +
+