Add OBS widgets for streamers

- Add widget token authentication system
- Create leaderboard, current assignment, and progress widgets
- Support dark, light, and neon themes
- Add widget settings modal for URL generation
- Fix avatar loading through backend API proxy

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-09 19:16:50 +03:00
parent cd78a99ce7
commit 146ed5e489
18 changed files with 2286 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

664
docs/tz-obs-widget.md Normal file
View File

@@ -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
<Route path="/widget/leaderboard" element={<LeaderboardWidget />} />
<Route path="/widget/current" element={<CurrentWidget />} />
<Route path="/widget/progress" element={<ProgressWidget />} />
<Route path="/widget/combined" element={<CombinedWidget />} />
```
### Компонент виджета
```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 <WidgetLoader />
if (!data) return null
return (
<WidgetContainer theme={theme} transparent={params.get('transparent') === 'true'}>
<div className="widget-leaderboard">
<h3 className="widget-title">{data.marathon_title}</h3>
{data.entries.map((entry) => (
<LeaderboardRow
key={entry.rank}
entry={entry}
highlight={highlight && entry.is_current_user}
/>
))}
</div>
</WidgetContainer>
)
}
```
---
## Темы оформления
### Базовые темы
#### 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<string | null>(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 (
<div>
<h1>Виджеты для OBS</h1>
{!widgetToken ? (
<Button onClick={generateToken}>Создать токен</Button>
) : (
<>
<Section title="Настройки">
<Select
label="Тема"
value={selectedTheme}
options={['dark', 'light', 'neon']}
onChange={setSelectedTheme}
/>
<Input
label="Участников в лидерборде"
type="number"
value={leaderboardCount}
onChange={setLeaderboardCount}
/>
</Section>
<Section title="Ссылки для OBS">
<WidgetUrlBlock
title="Лидерборд"
url={widgetUrl('leaderboard')}
preview={<LeaderboardPreview />}
/>
<WidgetUrlBlock
title="Текущее задание"
url={widgetUrl('current')}
preview={<CurrentPreview />}
/>
<WidgetUrlBlock
title="Прогресс"
url={widgetUrl('progress')}
/>
</Section>
<Section title="Инструкция">
<ol>
<li>Скопируйте нужную ссылку</li>
<li>В OBS добавьте источник "Browser"</li>
<li>Вставьте ссылку в поле URL</li>
<li>Установите размер (рекомендуется: 400x300)</li>
</ol>
</Section>
</>
)}
</div>
)
}
```
### Превью виджетов
Показывать живой превью виджета с текущими настройками:
```tsx
const WidgetPreview = ({ type, params }) => {
return (
<div className="widget-preview">
<iframe
src={`/widget/${type}?${params}`}
width="400"
height="300"
style={{ border: 'none', borderRadius: 8 }}
/>
</div>
)
}
```
---
## Безопасность
### Токены виджетов
- Токен привязан к конкретному участнику и марафону
- Токен можно отозвать (деактивировать)
- Токен даёт доступ только к публичной информации марафона
- Нельзя использовать для изменения данных
### Rate Limiting
```python
# Ограничения для widget эндпоинтов
WIDGET_RATE_LIMIT = "60/minute" # 60 запросов в минуту на токен
```
### Валидация токена
```python
async def validate_widget_token(token: str, marathon_id: int, db: AsyncSession) -> WidgetToken:
widget_token = await db.scalar(
select(WidgetToken)
.options(selectinload(WidgetToken.participant))
.where(
WidgetToken.token == token,
WidgetToken.marathon_id == marathon_id,
WidgetToken.is_active == True,
)
)
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
```
---
## План реализации
### Этап 1: Backend — модель и токены
- [ ] Создать модель `WidgetToken`
- [ ] Миграция для таблицы `widget_tokens`
- [ ] API создания токена (`POST /marathons/{id}/widget-token`)
- [ ] API отзыва токена (`DELETE /widget-tokens/{id}`)
- [ ] Валидация токена
### Этап 2: Backend — API виджетов
- [ ] Эндпоинт `/widget/leaderboard`
- [ ] Эндпоинт `/widget/current`
- [ ] Эндпоинт `/widget/progress`
- [ ] Схемы ответов
- [ ] Rate limiting
### Этап 3: Frontend — страницы виджетов
- [ ] Роутинг `/widget/*`
- [ ] Компонент `LeaderboardWidget`
- [ ] Компонент `CurrentWidget`
- [ ] Компонент `ProgressWidget`
- [ ] Polling обновлений
### Этап 4: Frontend — темы и стили
- [ ] Базовые стили виджетов
- [ ] Тема Dark
- [ ] Тема Light
- [ ] Тема Neon
- [ ] Поддержка прозрачного фона
- [ ] Параметры кастомизации через URL
### Этап 5: Frontend — страница настроек
- [ ] Страница генерации виджетов
- [ ] Форма настроек (тема, количество и т.д.)
- [ ] Копирование URL
- [ ] Превью виджетов
- [ ] Инструкция по добавлению в OBS
### Этап 6: Тестирование
- [ ] Проверка в OBS Browser Source
- [ ] Тестирование тем
- [ ] Проверка обновления данных
- [ ] Тестирование на разных разрешениях
- [ ] Проверка производительности (polling)
---
## Примеры виджетов
### Лидерборд (Dark theme)
```
┌─────────────────────────────────────┐
│ 🏆 Game Marathon │
├─────────────────────────────────────┤
│ 1. 🟣 PlayerOne 1250 pts │
│ 2. 🔵 StreamerPro 980 pts │
│ ▶3. 🟢 CurrentUser 875 pts ◀│
│ 4. 🟡 GamerX 720 pts │
│ 5. 🔴 ProPlayer 650 pts │
└─────────────────────────────────────┘
аватарки
```
### Текущее задание
```
┌─────────────────────────────────┐
│ 🎮 Dark Souls III │
├─────────────────────────────────┤
│ Челлендж: │
│ Победить Намлесс Кинга │
│ без брони │
│ │
│ Очки: +150 │
│ │
│ Сложность: ⭐⭐⭐ │
└─────────────────────────────────┘
```
### Прогресс
```
┌─────────────────────────────────┐
│ 🟢 CurrentUser │
│ ↑ │
│ аватарка │
├─────────────────────────────────┤
│ Место: #3 │
│ Очки: 875 │
│ Стрик: 🔥 5 │
│ Выполнено: 12 │
│ Дропнуто: 2 │
└─────────────────────────────────┘
```
---
## Дополнительные идеи (будущее)
- **Анимации** — анимация при изменении позиций в лидерборде
- **Звуковые оповещения** — звук при выполнении задания
- **WebSocket** — мгновенные обновления без polling
- **Кастомный CSS** — возможность вставить свой CSS
- **Виджет событий** — показ активных событий марафона
- **Виджет колеса** — мини-версия колеса фортуны

View File

@@ -28,6 +28,11 @@ import { ServerErrorPage } from '@/pages/ServerErrorPage'
import { ShopPage } from '@/pages/ShopPage'
import { InventoryPage } from '@/pages/InventoryPage'
// Widget Pages (for OBS)
import LeaderboardWidget from '@/pages/widget/LeaderboardWidget'
import CurrentWidget from '@/pages/widget/CurrentWidget'
import ProgressWidget from '@/pages/widget/ProgressWidget'
// Admin Pages
import {
AdminLayout,
@@ -86,6 +91,11 @@ function App() {
<ToastContainer />
<ConfirmModal />
<Routes>
{/* Widget routes (no layout, for OBS browser source) */}
<Route path="/widget/leaderboard" element={<LeaderboardWidget />} />
<Route path="/widget/current" element={<CurrentWidget />} />
<Route path="/widget/progress" element={<ProgressWidget />} />
<Route path="/" element={<Layout />}>
<Route index element={<HomePage />} />

View File

@@ -11,3 +11,4 @@ export { usersApi } from './users'
export { telegramApi } from './telegram'
export { shopApi } from './shop'
export { promoApi } from './promo'
export { widgetsApi } from './widgets'

View File

@@ -0,0 +1,52 @@
import client from './client'
import type {
WidgetToken,
WidgetLeaderboardData,
WidgetCurrentData,
WidgetProgressData,
} from '../types'
export const widgetsApi = {
// Authenticated endpoints (for managing tokens)
createToken: async (marathonId: number): Promise<WidgetToken> => {
const response = await client.post<WidgetToken>(`/widgets/marathons/${marathonId}/token`)
return response.data
},
listTokens: async (marathonId: number): Promise<WidgetToken[]> => {
const response = await client.get<WidgetToken[]>(`/widgets/marathons/${marathonId}/tokens`)
return response.data
},
revokeToken: async (tokenId: number): Promise<{ message: string }> => {
const response = await client.delete<{ message: string }>(`/widgets/tokens/${tokenId}`)
return response.data
},
regenerateToken: async (tokenId: number): Promise<WidgetToken> => {
const response = await client.post<WidgetToken>(`/widgets/tokens/${tokenId}/regenerate`)
return response.data
},
// Public widget data endpoints (authenticated via widget token)
getLeaderboard: async (marathonId: number, token: string, count: number = 5): Promise<WidgetLeaderboardData> => {
const response = await client.get<WidgetLeaderboardData>(
`/widgets/data/leaderboard?marathon=${marathonId}&token=${token}&count=${count}`
)
return response.data
},
getCurrent: async (marathonId: number, token: string): Promise<WidgetCurrentData> => {
const response = await client.get<WidgetCurrentData>(
`/widgets/data/current?marathon=${marathonId}&token=${token}`
)
return response.data
},
getProgress: async (marathonId: number, token: string): Promise<WidgetProgressData> => {
const response = await client.get<WidgetProgressData>(
`/widgets/data/progress?marathon=${marathonId}&token=${token}`
)
return response.data
},
}

View File

@@ -0,0 +1,217 @@
import { useState, useEffect } from 'react'
import { widgetsApi } from '@/api/widgets'
import type { WidgetToken } from '@/types'
import { useToast } from '@/store/toast'
interface WidgetSettingsModalProps {
marathonId: number
isOpen: boolean
onClose: () => void
}
type WidgetTheme = 'dark' | 'light' | 'neon'
export function WidgetSettingsModal({ marathonId, isOpen, onClose }: WidgetSettingsModalProps) {
const [token, setToken] = useState<WidgetToken | null>(null)
const [loading, setLoading] = useState(false)
const [theme, setTheme] = useState<WidgetTheme>('dark')
const [count, setCount] = useState(5)
const [showAvatars, setShowAvatars] = useState(true)
const [transparent, setTransparent] = useState(false)
const toast = useToast()
useEffect(() => {
if (isOpen && !token) {
loadOrCreateToken()
}
}, [isOpen])
const loadOrCreateToken = async () => {
setLoading(true)
try {
const result = await widgetsApi.createToken(marathonId)
setToken(result)
} catch {
toast.error('Не удалось создать токен')
} finally {
setLoading(false)
}
}
const regenerateToken = async () => {
if (!token) return
setLoading(true)
try {
const result = await widgetsApi.regenerateToken(token.id)
setToken(result)
toast.success('Токен обновлён')
} catch {
toast.error('Не удалось обновить токен')
} finally {
setLoading(false)
}
}
const buildWidgetUrl = (type: 'leaderboard' | 'current' | 'progress') => {
if (!token) return ''
const baseUrl = window.location.origin
const params = new URLSearchParams({
marathon: marathonId.toString(),
token: token.token,
theme,
...(type === 'leaderboard' && { count: count.toString() }),
...(showAvatars === false && { avatars: 'false' }),
...(transparent && { transparent: 'true' }),
})
return `${baseUrl}/widget/${type}?${params}`
}
const copyToClipboard = (url: string, name: string) => {
navigator.clipboard.writeText(url)
toast.success(`Ссылка "${name}" скопирована`)
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-dark-700">
<div className="flex justify-between items-center">
<h2 className="text-xl font-bold">Виджеты для OBS</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="p-6 space-y-6">
{loading ? (
<div className="text-center py-8">
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full mx-auto"></div>
<p className="text-gray-400 mt-2">Загрузка...</p>
</div>
) : token ? (
<>
{/* Settings */}
<div className="space-y-4">
<h3 className="font-semibold text-lg">Настройки</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Тема</label>
<select
value={theme}
onChange={(e) => setTheme(e.target.value as WidgetTheme)}
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2"
>
<option value="dark">Тёмная</option>
<option value="light">Светлая</option>
<option value="neon">Неон</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Участников в лидерборде</label>
<input
type="number"
min={1}
max={20}
value={count}
onChange={(e) => setCount(parseInt(e.target.value) || 5)}
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2"
/>
</div>
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showAvatars}
onChange={(e) => setShowAvatars(e.target.checked)}
className="w-4 h-4 rounded"
/>
<span className="text-sm">Показывать аватарки</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={transparent}
onChange={(e) => setTransparent(e.target.checked)}
className="w-4 h-4 rounded"
/>
<span className="text-sm">Прозрачный фон</span>
</label>
</div>
</div>
{/* Widget URLs */}
<div className="space-y-4">
<h3 className="font-semibold text-lg">Ссылки для OBS</h3>
{[
{ type: 'leaderboard' as const, name: 'Лидерборд', desc: 'Таблица участников с очками' },
{ type: 'current' as const, name: 'Текущее задание', desc: 'Активный челлендж / прохождение' },
{ type: 'progress' as const, name: 'Прогресс', desc: 'Статистика участника' },
].map(({ type, name, desc }) => (
<div key={type} className="bg-dark-700 rounded-lg p-4">
<div className="flex justify-between items-start mb-2">
<div>
<div className="font-medium">{name}</div>
<div className="text-sm text-gray-400">{desc}</div>
</div>
<button
onClick={() => copyToClipboard(buildWidgetUrl(type), name)}
className="px-3 py-1 bg-primary text-white text-sm rounded-lg hover:bg-primary/80 transition-colors"
>
Копировать
</button>
</div>
<div className="bg-dark-800 rounded px-3 py-2 text-xs font-mono text-gray-400 break-all">
{buildWidgetUrl(type)}
</div>
</div>
))}
</div>
{/* Instructions */}
<div className="bg-dark-700/50 rounded-lg p-4">
<h4 className="font-medium mb-2">Как добавить в OBS</h4>
<ol className="text-sm text-gray-400 space-y-1 list-decimal list-inside">
<li>Скопируйте нужную ссылку</li>
<li>В OBS нажмите "+" "Браузер"</li>
<li>Вставьте ссылку в поле URL</li>
<li>Рекомендуемый размер: 400x300</li>
</ol>
</div>
{/* Token actions */}
<div className="flex justify-between items-center pt-4 border-t border-dark-700">
<div className="text-sm text-gray-500">
Токен: {token.token.substring(0, 20)}...
</div>
<button
onClick={regenerateToken}
className="text-sm text-red-400 hover:text-red-300"
>
Сбросить токен
</button>
</div>
</>
) : (
<div className="text-center py-8 text-gray-400">
Не удалось загрузить данные
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -10,11 +10,12 @@ import { EventBanner } from '@/components/EventBanner'
import { EventControl } from '@/components/EventControl'
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
import { MarathonSettingsModal } from '@/components/MarathonSettingsModal'
import { WidgetSettingsModal } from '@/components/WidgetSettingsModal'
import {
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles,
AlertTriangle, Clock, CheckCircle, XCircle, ThumbsUp, ThumbsDown, ExternalLink, User
AlertTriangle, Clock, CheckCircle, XCircle, ThumbsUp, ThumbsDown, ExternalLink, User, Monitor
} from 'lucide-react'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
@@ -38,6 +39,7 @@ export function MarathonPage() {
const [showChallenges, setShowChallenges] = useState(false)
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
const [showSettings, setShowSettings] = useState(false)
const [showWidgets, setShowWidgets] = useState(false)
const activityFeedRef = useRef<ActivityFeedRef>(null)
// Disputes for organizers
@@ -663,6 +665,30 @@ export function MarathonPage() {
</GlassCard>
)}
{/* Widgets for OBS */}
{marathon.status === 'active' && isParticipant && (
<GlassCard>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-purple-500/20 flex items-center justify-center">
<Monitor className="w-5 h-5 text-purple-400" />
</div>
<div>
<h3 className="font-semibold text-white">Виджеты для стрима</h3>
<p className="text-sm text-gray-400">Добавьте виджеты в OBS</p>
</div>
</div>
<NeonButton
variant="secondary"
onClick={() => setShowWidgets(true)}
icon={<Settings className="w-4 h-4" />}
>
Настроить
</NeonButton>
</div>
</GlassCard>
)}
{/* My stats */}
{marathon.my_participation && (
<GlassCard variant="neon">
@@ -821,6 +847,13 @@ export function MarathonPage() {
onClose={() => setShowSettings(false)}
onUpdate={setMarathon}
/>
{/* Widgets Modal */}
<WidgetSettingsModal
marathonId={marathon.id}
isOpen={showWidgets}
onClose={() => setShowWidgets(false)}
/>
</div>
)
}

View File

@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { widgetsApi } from '@/api/widgets'
import type { WidgetCurrentData } from '@/types'
import '@/styles/widget.css'
const DIFFICULTY_LABELS: Record<string, string> = {
easy: 'Легко',
medium: 'Средне',
hard: 'Сложно',
}
export default function CurrentWidget() {
const [searchParams] = useSearchParams()
const [data, setData] = useState<WidgetCurrentData | null>(null)
const [error, setError] = useState<string | null>(null)
const marathonId = searchParams.get('marathon')
const token = searchParams.get('token')
const theme = searchParams.get('theme') || 'dark'
const transparent = searchParams.get('transparent') === 'true'
useEffect(() => {
if (!marathonId || !token) {
setError('Missing marathon or token parameter')
return
}
const fetchData = async () => {
try {
const result = await widgetsApi.getCurrent(parseInt(marathonId), token)
setData(result)
setError(null)
} catch {
setError('Failed to load data')
}
}
fetchData()
const interval = setInterval(fetchData, 30000)
return () => clearInterval(interval)
}, [marathonId, token])
if (error) {
return (
<div className={`widget widget-theme-${theme} ${transparent ? 'widget-transparent' : ''}`}>
<div className="widget-error">{error}</div>
</div>
)
}
if (!data) {
return (
<div className={`widget widget-theme-${theme} ${transparent ? 'widget-transparent' : ''}`}>
<div className="widget-loading">Loading...</div>
</div>
)
}
if (!data.has_assignment) {
return (
<div className={`widget widget-theme-${theme} ${transparent ? 'widget-transparent' : ''}`}>
<div className="widget-current widget-no-assignment">
<div className="widget-waiting">Ожидание спина...</div>
</div>
</div>
)
}
return (
<div className={`widget widget-theme-${theme} ${transparent ? 'widget-transparent' : ''}`}>
<div className="widget-current">
<div className="widget-current-header">
{data.game_cover_url && (
<img src={data.game_cover_url} alt="" className="widget-game-cover" />
)}
<div className="widget-current-info">
<div className="widget-game-title">{data.game_title}</div>
<div className="widget-assignment-type">
{data.assignment_type === 'playthrough' ? 'Прохождение' : 'Челлендж'}
</div>
</div>
</div>
<div className="widget-challenge">
<div className="widget-challenge-title">{data.challenge_title}</div>
{data.challenge_description && (
<div className="widget-challenge-desc">{data.challenge_description}</div>
)}
</div>
<div className="widget-current-footer">
<span className="widget-points-badge">+{data.points} очков</span>
{data.difficulty && (
<span className={`widget-difficulty widget-difficulty-${data.difficulty}`}>
{DIFFICULTY_LABELS[data.difficulty] || data.difficulty}
</span>
)}
</div>
{data.assignment_type === 'playthrough' && data.bonus_total !== null && data.bonus_total > 0 && (
<div className="widget-bonus-progress">
Бонусы: {data.bonus_completed || 0} / {data.bonus_total}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Flame } from 'lucide-react'
import { widgetsApi } from '@/api/widgets'
import type { WidgetLeaderboardData } from '@/types'
import '@/styles/widget.css'
export default function LeaderboardWidget() {
const [searchParams] = useSearchParams()
const [data, setData] = useState<WidgetLeaderboardData | null>(null)
const [error, setError] = useState<string | null>(null)
const marathonId = searchParams.get('marathon')
const token = searchParams.get('token')
const theme = searchParams.get('theme') || 'dark'
const count = parseInt(searchParams.get('count') || '5')
const showAvatars = searchParams.get('avatars') !== 'false'
const transparent = searchParams.get('transparent') === 'true'
useEffect(() => {
if (!marathonId || !token) {
setError('Missing marathon or token parameter')
return
}
const fetchData = async () => {
try {
const result = await widgetsApi.getLeaderboard(parseInt(marathonId), token, count)
setData(result)
setError(null)
} catch {
setError('Failed to load data')
}
}
fetchData()
const interval = setInterval(fetchData, 30000) // Refresh every 30 seconds
return () => clearInterval(interval)
}, [marathonId, token, count])
if (error) {
return (
<div className={`widget widget-theme-${theme} ${transparent ? 'widget-transparent' : ''}`}>
<div className="widget-error">{error}</div>
</div>
)
}
if (!data) {
return (
<div className={`widget widget-theme-${theme} ${transparent ? 'widget-transparent' : ''}`}>
<div className="widget-loading">Loading...</div>
</div>
)
}
return (
<div className={`widget widget-theme-${theme} ${transparent ? 'widget-transparent' : ''}`}>
<div className="widget-leaderboard">
<h3 className="widget-title">{data.marathon_title}</h3>
<div className="widget-leaderboard-list">
{data.entries.map((entry) => (
<div
key={entry.rank}
className={`widget-leaderboard-row ${entry.is_current_user ? 'widget-highlight' : ''}`}
>
<span className="widget-rank">#{entry.rank}</span>
{showAvatars && (
<div className="widget-avatar">
{entry.avatar_url ? (
<img src={entry.avatar_url} alt="" />
) : (
<div className="widget-avatar-placeholder">
{entry.nickname.charAt(0).toUpperCase()}
</div>
)}
</div>
)}
<span className="widget-nickname">{entry.nickname}</span>
<span className="widget-points">{entry.total_points} pts</span>
{entry.current_streak > 0 && (
<span className="widget-streak">
<Flame className="w-3 h-3 text-orange-400 inline" />
{entry.current_streak}
</span>
)}
</div>
))}
</div>
{data.current_user_rank && data.current_user_rank > count && (
<div className="widget-current-rank">
Ваше место: #{data.current_user_rank}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Flame } from 'lucide-react'
import { widgetsApi } from '@/api/widgets'
import type { WidgetProgressData } from '@/types'
import '@/styles/widget.css'
export default function ProgressWidget() {
const [searchParams] = useSearchParams()
const [data, setData] = useState<WidgetProgressData | null>(null)
const [error, setError] = useState<string | null>(null)
const marathonId = searchParams.get('marathon')
const token = searchParams.get('token')
const theme = searchParams.get('theme') || 'dark'
const transparent = searchParams.get('transparent') === 'true'
const showAvatars = searchParams.get('avatars') !== 'false'
useEffect(() => {
if (!marathonId || !token) {
setError('Missing marathon or token parameter')
return
}
const fetchData = async () => {
try {
const result = await widgetsApi.getProgress(parseInt(marathonId), token)
setData(result)
setError(null)
} catch {
setError('Failed to load data')
}
}
fetchData()
const interval = setInterval(fetchData, 30000)
return () => clearInterval(interval)
}, [marathonId, token])
if (error) {
return (
<div className={`widget widget-theme-${theme} ${transparent ? 'widget-transparent' : ''}`}>
<div className="widget-error">{error}</div>
</div>
)
}
if (!data) {
return (
<div className={`widget widget-theme-${theme} ${transparent ? 'widget-transparent' : ''}`}>
<div className="widget-loading">Loading...</div>
</div>
)
}
return (
<div className={`widget widget-theme-${theme} ${transparent ? 'widget-transparent' : ''}`}>
<div className="widget-progress">
<div className="widget-progress-header">
{showAvatars && (
<div className="widget-avatar widget-avatar-lg">
{data.avatar_url ? (
<img src={data.avatar_url} alt="" />
) : (
<div className="widget-avatar-placeholder">
{data.nickname.charAt(0).toUpperCase()}
</div>
)}
</div>
)}
<div className="widget-progress-user">
<div className="widget-nickname-lg">{data.nickname}</div>
<div className="widget-marathon-title">{data.marathon_title}</div>
</div>
</div>
<div className="widget-progress-stats">
<div className="widget-stat">
<span className="widget-stat-value">#{data.rank}</span>
<span className="widget-stat-label">Место</span>
</div>
<div className="widget-stat">
<span className="widget-stat-value">{data.total_points}</span>
<span className="widget-stat-label">Очки</span>
</div>
<div className="widget-stat">
<span className="widget-stat-value">
<Flame className="w-5 h-5 text-orange-400 inline" />
{data.current_streak}
</span>
<span className="widget-stat-label">Стрик</span>
</div>
</div>
<div className="widget-progress-counts">
<span className="widget-completed"> {data.completed_count}</span>
<span className="widget-dropped"> {data.dropped_count}</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,363 @@
/* Widget Base Styles */
.widget {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 16px;
border-radius: 12px;
min-width: 280px;
max-width: 400px;
}
.widget-transparent {
background: transparent !important;
}
/* === Dark Theme (default) === */
.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);
--widget-success: #22c55e;
--widget-danger: #ef4444;
background: var(--widget-bg);
color: var(--widget-text);
}
/* === Light Theme === */
.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);
--widget-success: #16a34a;
--widget-danger: #dc2626;
background: var(--widget-bg);
color: var(--widget-text);
}
/* === Neon Theme === */
.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;
--widget-success: #00ff88;
--widget-danger: #ff0066;
background: var(--widget-bg);
color: var(--widget-text);
border: 1px solid var(--widget-border);
text-shadow: 0 0 10px currentColor;
}
/* === Common Elements === */
.widget-title {
font-size: 14px;
font-weight: 600;
margin: 0 0 12px 0;
color: var(--widget-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.widget-loading,
.widget-error {
text-align: center;
padding: 20px;
color: var(--widget-text-secondary);
}
.widget-error {
color: var(--widget-danger);
}
/* === Avatar === */
.widget-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
}
.widget-avatar-lg {
width: 48px;
height: 48px;
}
.widget-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.widget-avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--widget-accent);
color: white;
font-weight: 600;
font-size: 14px;
}
.widget-avatar-lg .widget-avatar-placeholder {
font-size: 18px;
}
/* === Leaderboard Widget === */
.widget-leaderboard-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.widget-leaderboard-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
transition: background 0.2s;
}
.widget-highlight {
background: var(--widget-highlight) !important;
border: 1px solid var(--widget-accent);
}
.widget-rank {
font-weight: 700;
font-size: 14px;
min-width: 30px;
color: var(--widget-text-secondary);
}
.widget-nickname {
flex: 1;
font-weight: 500;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.widget-points {
font-weight: 600;
font-size: 14px;
color: var(--widget-accent);
}
.widget-streak {
font-size: 12px;
color: var(--widget-text-secondary);
}
.widget-current-rank {
margin-top: 12px;
text-align: center;
font-size: 12px;
color: var(--widget-text-secondary);
padding: 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
}
/* === Current Assignment Widget === */
.widget-current {
display: flex;
flex-direction: column;
gap: 12px;
}
.widget-no-assignment {
min-height: 100px;
display: flex;
align-items: center;
justify-content: center;
}
.widget-waiting {
color: var(--widget-text-secondary);
font-style: italic;
}
.widget-current-header {
display: flex;
gap: 12px;
align-items: flex-start;
}
.widget-game-cover {
width: 60px;
height: 80px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
.widget-current-info {
flex: 1;
min-width: 0;
}
.widget-game-title {
font-weight: 600;
font-size: 16px;
margin-bottom: 4px;
}
.widget-assignment-type {
font-size: 12px;
color: var(--widget-accent);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.widget-challenge {
padding: 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
}
.widget-challenge-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
}
.widget-challenge-desc {
font-size: 12px;
color: var(--widget-text-secondary);
line-height: 1.4;
}
.widget-current-footer {
display: flex;
gap: 10px;
align-items: center;
}
.widget-points-badge {
background: var(--widget-accent);
color: white;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.widget-difficulty {
font-size: 12px;
padding: 4px 8px;
border-radius: 6px;
font-weight: 500;
}
.widget-difficulty-easy {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.widget-difficulty-medium {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.widget-difficulty-hard {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.widget-bonus-progress {
font-size: 12px;
color: var(--widget-text-secondary);
text-align: center;
padding: 6px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
}
/* === Progress Widget === */
.widget-progress {
display: flex;
flex-direction: column;
gap: 16px;
}
.widget-progress-header {
display: flex;
gap: 12px;
align-items: center;
}
.widget-progress-user {
flex: 1;
min-width: 0;
}
.widget-nickname-lg {
font-size: 18px;
font-weight: 700;
}
.widget-marathon-title {
font-size: 12px;
color: var(--widget-text-secondary);
}
.widget-progress-stats {
display: flex;
justify-content: space-around;
gap: 8px;
}
.widget-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
flex: 1;
}
.widget-stat-value {
font-size: 20px;
font-weight: 700;
color: var(--widget-accent);
}
.widget-stat-label {
font-size: 11px;
color: var(--widget-text-secondary);
text-transform: uppercase;
margin-top: 4px;
}
.widget-progress-counts {
display: flex;
justify-content: center;
gap: 20px;
font-size: 14px;
}
.widget-completed {
color: var(--widget-success);
}
.widget-dropped {
color: var(--widget-danger);
}

View File

@@ -909,3 +909,58 @@ export interface PromoCodeRedeemResponse {
new_balance: number
message: string
}
// === Widget types ===
export interface WidgetToken {
id: number
token: string
created_at: string
expires_at: string | null
is_active: boolean
urls: {
leaderboard: string
current: string
progress: string
}
}
export interface WidgetLeaderboardEntry {
rank: number
nickname: string
avatar_url: string | null
total_points: number
current_streak: number
is_current_user: boolean
}
export interface WidgetLeaderboardData {
entries: WidgetLeaderboardEntry[]
current_user_rank: number | null
total_participants: number
marathon_title: string
}
export interface WidgetCurrentData {
has_assignment: boolean
game_title: string | null
game_cover_url: string | null
assignment_type: 'challenge' | 'playthrough' | null
challenge_title: string | null
challenge_description: string | null
points: number | null
difficulty: Difficulty | null
bonus_completed: number | null
bonus_total: number | null
}
export interface WidgetProgressData {
nickname: string
avatar_url: string | null
rank: number
total_points: number
current_streak: number
completed_count: number
dropped_count: number
marathon_title: string
}