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:
664
docs/tz-obs-widget.md
Normal file
664
docs/tz-obs-widget.md
Normal 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
|
||||
- **Виджет событий** — показ активных событий марафона
|
||||
- **Виджет колеса** — мини-версия колеса фортуны
|
||||
Reference in New Issue
Block a user