# ТЗ: 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 — модель и токены ✅
- [x] Создать модель `WidgetToken`
- [x] Миграция для таблицы `widget_tokens`
- [x] API создания токена (`POST /widgets/marathons/{id}/token`)
- [x] API отзыва токена (`DELETE /widgets/tokens/{id}`)
- [x] API регенерации токена (`POST /widgets/tokens/{id}/regenerate`)
- [x] Валидация токена

### Этап 2: Backend — API виджетов ✅
- [x] Эндпоинт `/widgets/data/leaderboard`
- [x] Эндпоинт `/widgets/data/current`
- [x] Эндпоинт `/widgets/data/progress`
- [x] Схемы ответов
- [ ] Rate limiting

### Этап 3: Frontend — страницы виджетов ✅
- [x] Роутинг `/widget/*`
- [x] Компонент `LeaderboardWidget`
- [x] Компонент `CurrentWidget`
- [x] Компонент `ProgressWidget`
- [x] Polling обновлений (30 сек)

### Этап 4: Frontend — темы и стили ✅
- [x] Базовые стили виджетов
- [x] Тема Dark
- [x] Тема Light
- [x] Тема Neon
- [x] Поддержка прозрачного фона
- [x] Параметры кастомизации через URL (theme, count, avatars, transparent)

### Этап 5: Frontend — страница настроек ✅
- [x] Модальное окно настройки виджетов (WidgetSettingsModal)
- [x] Форма настроек (тема, количество, аватарки, прозрачность)
- [x] Копирование URL
- [x] Превью виджетов (iframe)
- [x] Инструкция по добавлению в OBS

### Этап 6: Тестирование
- [ ] Проверка в OBS Browser Source
- [ ] Тестирование тем
- [ ] Проверка обновления данных
- [ ] Тестирование на разных разрешениях
- [ ] Проверка производительности (polling)

### Не реализовано (опционально)
- [x] Комбинированный виджет
- [ ] Rate limiting для API виджетов

---

## Примеры виджетов

### Лидерборд (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
- **Виджет событий** — показ активных событий марафона
- **Виджет колеса** — мини-версия колеса фортуны
