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

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