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:
@@ -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 />} />
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@ export { usersApi } from './users'
|
||||
export { telegramApi } from './telegram'
|
||||
export { shopApi } from './shop'
|
||||
export { promoApi } from './promo'
|
||||
export { widgetsApi } from './widgets'
|
||||
|
||||
52
frontend/src/api/widgets.ts
Normal file
52
frontend/src/api/widgets.ts
Normal 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
|
||||
},
|
||||
}
|
||||
217
frontend/src/components/WidgetSettingsModal.tsx
Normal file
217
frontend/src/components/WidgetSettingsModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
109
frontend/src/pages/widget/CurrentWidget.tsx
Normal file
109
frontend/src/pages/widget/CurrentWidget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
frontend/src/pages/widget/LeaderboardWidget.tsx
Normal file
98
frontend/src/pages/widget/LeaderboardWidget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
frontend/src/pages/widget/ProgressWidget.tsx
Normal file
102
frontend/src/pages/widget/ProgressWidget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
363
frontend/src/styles/widget.css
Normal file
363
frontend/src/styles/widget.css
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user