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:
@@ -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 виджетов
|
||||
|
||||
---
|
||||
|
||||
## Примеры виджетов
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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,121 +100,167 @@ 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 ? (
|
||||
<>
|
||||
{/* Settings */}
|
||||
<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>
|
||||
<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"
|
||||
{/* 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'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
{widgetNames[type]}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
{/* 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>
|
||||
|
||||
{/* Widget URLs */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold text-lg">Ссылки для OBS</h3>
|
||||
{/* Right column - Settings */}
|
||||
<div className="space-y-6">
|
||||
{/* Settings */}
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
{/* All Widget URLs */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg">Все ссылки</h3>
|
||||
|
||||
{([
|
||||
{ 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">
|
||||
<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>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="bg-dark-700/50 rounded-lg p-4">
|
||||
<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>Вставьте ссылку в поле URL</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-xs text-gray-500">
|
||||
Токен: {token.token.substring(0, 16)}...
|
||||
</div>
|
||||
<button
|
||||
onClick={regenerateToken}
|
||||
className="text-xs text-red-400 hover:text-red-300"
|
||||
>
|
||||
Сбросить токен
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={regenerateToken}
|
||||
className="text-sm text-red-400 hover:text-red-300"
|
||||
>
|
||||
Сбросить токен
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
Не удалось загрузить данные
|
||||
|
||||
158
frontend/src/pages/widget/CombinedWidget.tsx
Normal file
158
frontend/src/pages/widget/CombinedWidget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user