+
Настройки
- {[
- { type: 'leaderboard' as const, name: 'Лидерборд', desc: 'Таблица участников с очками' },
- { type: 'current' as const, name: 'Текущее задание', desc: 'Активный челлендж / прохождение' },
- { type: 'progress' as const, name: 'Прогресс', desc: 'Статистика участника' },
- ].map(({ type, name, desc }) => (
-
-
+
+
+
+
+
+
+
+
+ setCount(parseInt(e.target.value) || 5)}
+ className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2"
+ />
+
+
+
+
+
+
+
+
+
+
+ {/* All Widget URLs */}
+
+
Все ссылки
+
+ {([
+ { type: 'leaderboard' as const, desc: 'Таблица участников' },
+ { type: 'current' as const, desc: 'Активное задание' },
+ { type: 'progress' as const, desc: 'Статистика' },
+ { type: 'combined' as const, desc: 'Всё в одном виджете' },
+ ]).map(({ type, desc }) => (
+
-
{name}
-
{desc}
+
{widgetNames[type]}
+
{desc}
-
- {buildWidgetUrl(type)}
-
-
- ))}
-
-
- {/* Instructions */}
-
-
Как добавить в OBS
-
- - Скопируйте нужную ссылку
- - В OBS нажмите "+" → "Браузер"
- - Вставьте ссылку в поле URL
- - Рекомендуемый размер: 400x300
-
-
-
- {/* Token actions */}
-
-
- Токен: {token.token.substring(0, 20)}...
+ ))}
+
+
+ {/* Instructions */}
+
+
Как добавить в OBS
+
+ - Скопируйте нужную ссылку
+ - В OBS: "+" → "Браузер"
+ - Вставьте ссылку в поле URL
+ - Размер: 400×300 px
+
+
+
+ {/* Token actions */}
+
+
+ Токен: {token.token.substring(0, 16)}...
+
+
-
- >
+
Не удалось загрузить данные
diff --git a/frontend/src/pages/widget/CombinedWidget.tsx b/frontend/src/pages/widget/CombinedWidget.tsx
new file mode 100644
index 0000000..882a3b6
--- /dev/null
+++ b/frontend/src/pages/widget/CombinedWidget.tsx
@@ -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
(null)
+ const [current, setCurrent] = useState(null)
+ const [progress, setProgress] = useState(null)
+ const [error, setError] = useState(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 (
+
+ )
+ }
+
+ if (!leaderboard || !current || !progress) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {/* Header with user info */}
+
+ {showAvatars && (
+
+ {progress.avatar_url ? (
+

+ ) : (
+
+ {progress.nickname.charAt(0).toUpperCase()}
+
+ )}
+
+ )}
+
+
{progress.nickname}
+
+
+
+ #{progress.rank}
+
+
+ {progress.total_points} pts
+
+ {progress.current_streak > 0 && (
+
+
+ {progress.current_streak}
+
+ )}
+
+
+
+
+ {/* Current assignment */}
+ {current.has_assignment && (
+
+
+
+ {current.game_title}
+
+
+ {current.challenge_title}
+ {current.points && (
+ +{current.points}
+ )}
+
+ {current.bonus_total !== null && current.bonus_total > 0 && (
+
+ Бонусы: {current.bonus_completed}/{current.bonus_total}
+
+ )}
+
+ )}
+
+ {/* Mini leaderboard */}
+
+ {leaderboard.entries.map((entry) => (
+
+
#{entry.rank}
+ {showAvatars && (
+
+ {entry.avatar_url ? (
+

+ ) : (
+
+ {entry.nickname.charAt(0).toUpperCase()}
+
+ )}
+
+ )}
+
{entry.nickname}
+
{entry.total_points}
+
+ ))}
+
+
+ {/* Footer stats */}
+
+
+
+ {progress.completed_count}
+
+
+
+ {progress.dropped_count}
+
+
+
+ )
+}
diff --git a/frontend/src/styles/widget.css b/frontend/src/styles/widget.css
index d6785ac..630be8a 100644
--- a/frontend/src/styles/widget.css
+++ b/frontend/src/styles/widget.css
@@ -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;
}