Add widget preview and combined widget

- Add live preview iframe in widget settings modal
- Create combined widget (all-in-one: leaderboard + current + progress)
- Add widget type tabs for switching preview
- Update documentation with completed tasks

🤖 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:55:48 +03:00
parent 146ed5e489
commit 3256c40841
5 changed files with 487 additions and 123 deletions

View File

@@ -557,41 +557,42 @@ async def validate_widget_token(token: str, marathon_id: int, db: AsyncSession)
## План реализации
### Этап 1: Backend — модель и токены
- [ ] Создать модель `WidgetToken`
- [ ] Миграция для таблицы `widget_tokens`
- [ ] API создания токена (`POST /marathons/{id}/widget-token`)
- [ ] API отзыва токена (`DELETE /widget-tokens/{id}`)
- [ ] Валидация токена
### Этап 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 виджетов
- [ ] Эндпоинт `/widget/leaderboard`
- [ ] Эндпоинт `/widget/current`
- [ ] Эндпоинт `/widget/progress`
- [ ] Схемы ответов
### Этап 2: Backend — API виджетов
- [x] Эндпоинт `/widgets/data/leaderboard`
- [x] Эндпоинт `/widgets/data/current`
- [x] Эндпоинт `/widgets/data/progress`
- [x] Схемы ответов
- [ ] Rate limiting
### Этап 3: Frontend — страницы виджетов
- [ ] Роутинг `/widget/*`
- [ ] Компонент `LeaderboardWidget`
- [ ] Компонент `CurrentWidget`
- [ ] Компонент `ProgressWidget`
- [ ] Polling обновлений
### Этап 3: Frontend — страницы виджетов
- [x] Роутинг `/widget/*`
- [x] Компонент `LeaderboardWidget`
- [x] Компонент `CurrentWidget`
- [x] Компонент `ProgressWidget`
- [x] Polling обновлений (30 сек)
### Этап 4: Frontend — темы и стили
- [ ] Базовые стили виджетов
- [ ] Тема Dark
- [ ] Тема Light
- [ ] Тема Neon
- [ ] Поддержка прозрачного фона
- [ ] Параметры кастомизации через URL
### Этап 4: Frontend — темы и стили
- [x] Базовые стили виджетов
- [x] Тема Dark
- [x] Тема Light
- [x] Тема Neon
- [x] Поддержка прозрачного фона
- [x] Параметры кастомизации через URL (theme, count, avatars, transparent)
### Этап 5: Frontend — страница настроек
- [ ] Страница генерации виджетов
- [ ] Форма настроек (тема, количество и т.д.)
- [ ] Копирование URL
- [ ] Превью виджетов
- [ ] Инструкция по добавлению в OBS
### Этап 5: Frontend — страница настроек
- [x] Модальное окно настройки виджетов (WidgetSettingsModal)
- [x] Форма настроек (тема, количество, аватарки, прозрачность)
- [x] Копирование URL
- [x] Превью виджетов (iframe)
- [x] Инструкция по добавлению в OBS
### Этап 6: Тестирование
- [ ] Проверка в OBS Browser Source
@@ -600,6 +601,10 @@ async def validate_widget_token(token: str, marathon_id: int, db: AsyncSession)
- [ ] Тестирование на разных разрешениях
- [ ] Проверка производительности (polling)
### Не реализовано (опционально)
- [x] Комбинированный виджет
- [ ] Rate limiting для API виджетов
---
## Примеры виджетов

View File

@@ -32,6 +32,7 @@ import { InventoryPage } from '@/pages/InventoryPage'
import LeaderboardWidget from '@/pages/widget/LeaderboardWidget'
import CurrentWidget from '@/pages/widget/CurrentWidget'
import ProgressWidget from '@/pages/widget/ProgressWidget'
import CombinedWidget from '@/pages/widget/CombinedWidget'
// Admin Pages
import {
@@ -95,6 +96,7 @@ function App() {
<Route path="/widget/leaderboard" element={<LeaderboardWidget />} />
<Route path="/widget/current" element={<CurrentWidget />} />
<Route path="/widget/progress" element={<ProgressWidget />} />
<Route path="/widget/combined" element={<CombinedWidget />} />
<Route path="/" element={<Layout />}>
<Route index element={<HomePage />} />

View File

@@ -11,6 +11,8 @@ interface WidgetSettingsModalProps {
type WidgetTheme = 'dark' | 'light' | 'neon'
type WidgetType = 'leaderboard' | 'current' | 'progress' | 'combined'
export function WidgetSettingsModal({ marathonId, isOpen, onClose }: WidgetSettingsModalProps) {
const [token, setToken] = useState<WidgetToken | null>(null)
const [loading, setLoading] = useState(false)
@@ -18,6 +20,7 @@ export function WidgetSettingsModal({ marathonId, isOpen, onClose }: WidgetSetti
const [count, setCount] = useState(5)
const [showAvatars, setShowAvatars] = useState(true)
const [transparent, setTransparent] = useState(false)
const [previewType, setPreviewType] = useState<WidgetType>('leaderboard')
const toast = useToast()
useEffect(() => {
@@ -52,7 +55,7 @@ export function WidgetSettingsModal({ marathonId, isOpen, onClose }: WidgetSetti
}
}
const buildWidgetUrl = (type: 'leaderboard' | 'current' | 'progress') => {
const buildWidgetUrl = (type: WidgetType) => {
if (!token) return ''
const baseUrl = window.location.origin
const params = new URLSearchParams({
@@ -73,9 +76,16 @@ export function WidgetSettingsModal({ marathonId, isOpen, onClose }: WidgetSetti
if (!isOpen) return null
const widgetNames: Record<WidgetType, string> = {
leaderboard: 'Лидерборд',
current: 'Текущее задание',
progress: 'Прогресс',
combined: 'Всё в одном',
}
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="bg-dark-800 rounded-xl max-w-5xl 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>
@@ -90,14 +100,63 @@ export function WidgetSettingsModal({ marathonId, isOpen, onClose }: WidgetSetti
</div>
</div>
<div className="p-6 space-y-6">
<div className="p-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 ? (
<>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left column - Preview */}
<div className="space-y-4">
<h3 className="font-semibold text-lg">Превью</h3>
{/* Widget type tabs */}
<div className="flex flex-wrap gap-2">
{(['leaderboard', 'current', 'progress', 'combined'] as WidgetType[]).map((type) => (
<button
key={type}
onClick={() => setPreviewType(type)}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
previewType === type
? 'bg-primary text-white'
: 'bg-dark-700 text-gray-400 hover:text-white'
}`}
>
{widgetNames[type]}
</button>
))}
</div>
{/* Preview iframe */}
<div
className="rounded-lg overflow-hidden border border-dark-600"
style={{
background: transparent ? 'repeating-conic-gradient(#1a1a1a 0% 25%, #2a2a2a 0% 50%) 50% / 20px 20px'
: theme === 'light' ? '#f5f5f5' : '#121212'
}}
>
<iframe
key={`${previewType}-${theme}-${count}-${showAvatars}-${transparent}`}
src={buildWidgetUrl(previewType)}
className="w-full"
style={{ height: '320px', border: 'none' }}
title="Widget Preview"
/>
</div>
{/* Copy button for current preview */}
<button
onClick={() => copyToClipboard(buildWidgetUrl(previewType), widgetNames[previewType])}
className="w-full px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/80 transition-colors"
>
Копировать ссылку на {widgetNames[previewType]}
</button>
</div>
{/* Right column - Settings */}
<div className="space-y-6">
{/* Settings */}
<div className="space-y-4">
<h3 className="font-semibold text-lg">Настройки</h3>
@@ -152,59 +211,56 @@ export function WidgetSettingsModal({ marathonId, isOpen, onClose }: WidgetSetti
</div>
</div>
{/* Widget URLs */}
<div className="space-y-4">
<h3 className="font-semibold text-lg">Ссылки для OBS</h3>
{/* All Widget URLs */}
<div className="space-y-3">
<h3 className="font-semibold text-lg">Все ссылки</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">
{([
{ type: 'leaderboard' as const, desc: 'Таблица участников' },
{ type: 'current' as const, desc: 'Активное задание' },
{ type: 'progress' as const, desc: 'Статистика' },
{ type: 'combined' as const, desc: 'Всё в одном виджете' },
]).map(({ type, desc }) => (
<div key={type} className="flex items-center justify-between bg-dark-700 rounded-lg px-4 py-3">
<div>
<div className="font-medium">{name}</div>
<div className="text-sm text-gray-400">{desc}</div>
<div className="font-medium text-sm">{widgetNames[type]}</div>
<div className="text-xs text-gray-500">{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"
onClick={() => copyToClipboard(buildWidgetUrl(type), widgetNames[type])}
className="px-3 py-1 bg-dark-600 text-white text-sm rounded-lg hover:bg-dark-500 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">
<h4 className="font-medium mb-2 text-sm">Как добавить в OBS</h4>
<ol className="text-xs text-gray-400 space-y-1 list-decimal list-inside">
<li>Скопируйте нужную ссылку</li>
<li>В OBS нажмите "+" "Браузер"</li>
<li>В OBS: "+" "Браузер"</li>
<li>Вставьте ссылку в поле URL</li>
<li>Рекомендуемый размер: 400x300</li>
<li>Размер: 400×300 px</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 className="text-xs text-gray-500">
Токен: {token.token.substring(0, 16)}...
</div>
<button
onClick={regenerateToken}
className="text-sm text-red-400 hover:text-red-300"
className="text-xs text-red-400 hover:text-red-300"
>
Сбросить токен
</button>
</div>
</>
</div>
</div>
) : (
<div className="text-center py-8 text-gray-400">
Не удалось загрузить данные

View File

@@ -0,0 +1,158 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Flame, Trophy, Target, TrendingDown, CheckCircle } from 'lucide-react'
import { widgetsApi } from '@/api/widgets'
import type { WidgetLeaderboardData, WidgetCurrentData, WidgetProgressData } from '@/types'
import '@/styles/widget.css'
export default function CombinedWidget() {
const [searchParams] = useSearchParams()
const [leaderboard, setLeaderboard] = useState<WidgetLeaderboardData | null>(null)
const [current, setCurrent] = useState<WidgetCurrentData | null>(null)
const [progress, setProgress] = 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 [leaderboardData, currentData, progressData] = await Promise.all([
widgetsApi.getLeaderboard(parseInt(marathonId), token, 3),
widgetsApi.getCurrent(parseInt(marathonId), token),
widgetsApi.getProgress(parseInt(marathonId), token),
])
setLeaderboard(leaderboardData)
setCurrent(currentData)
setProgress(progressData)
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 (!leaderboard || !current || !progress) {
return (
<div className={`widget widget-theme-${theme} ${transparent ? 'widget-transparent' : ''}`}>
<div className="widget-loading">Loading...</div>
</div>
)
}
return (
<div className={`widget widget-combined widget-theme-${theme} ${transparent ? 'widget-transparent' : ''}`}>
{/* Header with user info */}
<div className="widget-combined-header">
{showAvatars && (
<div className="widget-avatar">
{progress.avatar_url ? (
<img src={progress.avatar_url} alt="" />
) : (
<div className="widget-avatar-placeholder">
{progress.nickname.charAt(0).toUpperCase()}
</div>
)}
</div>
)}
<div className="widget-combined-user">
<div className="widget-nickname">{progress.nickname}</div>
<div className="widget-combined-stats">
<span className="widget-combined-stat">
<Trophy className="w-3 h-3" />
#{progress.rank}
</span>
<span className="widget-combined-stat">
{progress.total_points} pts
</span>
{progress.current_streak > 0 && (
<span className="widget-combined-stat widget-streak-highlight">
<Flame className="w-3 h-3" />
{progress.current_streak}
</span>
)}
</div>
</div>
</div>
{/* Current assignment */}
{current.has_assignment && (
<div className="widget-combined-current">
<div className="widget-combined-current-header">
<Target className="w-4 h-4 text-primary" />
<span className="widget-combined-game">{current.game_title}</span>
</div>
<div className="widget-combined-challenge">
{current.challenge_title}
{current.points && (
<span className="widget-combined-points">+{current.points}</span>
)}
</div>
{current.bonus_total !== null && current.bonus_total > 0 && (
<div className="widget-combined-bonus">
Бонусы: {current.bonus_completed}/{current.bonus_total}
</div>
)}
</div>
)}
{/* Mini leaderboard */}
<div className="widget-combined-leaderboard">
{leaderboard.entries.map((entry) => (
<div
key={entry.rank}
className={`widget-combined-row ${entry.is_current_user ? 'widget-highlight' : ''}`}
>
<span className="widget-combined-rank">#{entry.rank}</span>
{showAvatars && (
<div className="widget-avatar widget-avatar-xs">
{entry.avatar_url ? (
<img src={entry.avatar_url} alt="" />
) : (
<div className="widget-avatar-placeholder">
{entry.nickname.charAt(0).toUpperCase()}
</div>
)}
</div>
)}
<span className="widget-combined-name">{entry.nickname}</span>
<span className="widget-combined-pts">{entry.total_points}</span>
</div>
))}
</div>
{/* Footer stats */}
<div className="widget-combined-footer">
<span className="widget-completed">
<CheckCircle className="w-3 h-3" />
{progress.completed_count}
</span>
<span className="widget-dropped">
<TrendingDown className="w-3 h-3" />
{progress.dropped_count}
</span>
</div>
</div>
)
}

View File

@@ -356,8 +356,151 @@
.widget-completed {
color: var(--widget-success);
display: flex;
align-items: center;
gap: 4px;
}
.widget-dropped {
color: var(--widget-danger);
display: flex;
align-items: center;
gap: 4px;
}
/* === Combined Widget === */
.widget-combined {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 320px;
max-width: 420px;
}
.widget-combined-header {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--widget-border);
}
.widget-combined-user {
flex: 1;
min-width: 0;
}
.widget-combined-stats {
display: flex;
gap: 12px;
margin-top: 2px;
}
.widget-combined-stat {
display: flex;
align-items: center;
gap: 3px;
font-size: 12px;
color: var(--widget-text-secondary);
}
.widget-streak-highlight {
color: #f97316;
}
.widget-combined-current {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 10px;
}
.widget-combined-current-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--widget-text-secondary);
margin-bottom: 4px;
}
.widget-combined-game {
font-weight: 500;
}
.widget-combined-challenge {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 13px;
font-weight: 500;
}
.widget-combined-points {
font-size: 12px;
color: var(--widget-accent);
font-weight: 600;
}
.widget-combined-bonus {
font-size: 11px;
color: var(--widget-text-secondary);
margin-top: 4px;
}
.widget-combined-leaderboard {
display: flex;
flex-direction: column;
gap: 4px;
}
.widget-combined-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
font-size: 13px;
}
.widget-combined-row.widget-highlight {
background: var(--widget-highlight);
}
.widget-combined-rank {
font-size: 11px;
color: var(--widget-text-secondary);
min-width: 24px;
}
.widget-avatar-xs {
width: 20px;
height: 20px;
}
.widget-avatar-xs .widget-avatar-placeholder {
font-size: 10px;
}
.widget-combined-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.widget-combined-pts {
font-size: 12px;
color: var(--widget-accent);
font-weight: 600;
}
.widget-combined-footer {
display: flex;
justify-content: center;
gap: 16px;
padding-top: 8px;
border-top: 1px solid var(--widget-border);
font-size: 12px;
}