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 — модель и токены
|
### Этап 1: Backend — модель и токены ✅
|
||||||
- [ ] Создать модель `WidgetToken`
|
- [x] Создать модель `WidgetToken`
|
||||||
- [ ] Миграция для таблицы `widget_tokens`
|
- [x] Миграция для таблицы `widget_tokens`
|
||||||
- [ ] API создания токена (`POST /marathons/{id}/widget-token`)
|
- [x] API создания токена (`POST /widgets/marathons/{id}/token`)
|
||||||
- [ ] API отзыва токена (`DELETE /widget-tokens/{id}`)
|
- [x] API отзыва токена (`DELETE /widgets/tokens/{id}`)
|
||||||
- [ ] Валидация токена
|
- [x] API регенерации токена (`POST /widgets/tokens/{id}/regenerate`)
|
||||||
|
- [x] Валидация токена
|
||||||
|
|
||||||
### Этап 2: Backend — API виджетов
|
### Этап 2: Backend — API виджетов ✅
|
||||||
- [ ] Эндпоинт `/widget/leaderboard`
|
- [x] Эндпоинт `/widgets/data/leaderboard`
|
||||||
- [ ] Эндпоинт `/widget/current`
|
- [x] Эндпоинт `/widgets/data/current`
|
||||||
- [ ] Эндпоинт `/widget/progress`
|
- [x] Эндпоинт `/widgets/data/progress`
|
||||||
- [ ] Схемы ответов
|
- [x] Схемы ответов
|
||||||
- [ ] Rate limiting
|
- [ ] Rate limiting
|
||||||
|
|
||||||
### Этап 3: Frontend — страницы виджетов
|
### Этап 3: Frontend — страницы виджетов ✅
|
||||||
- [ ] Роутинг `/widget/*`
|
- [x] Роутинг `/widget/*`
|
||||||
- [ ] Компонент `LeaderboardWidget`
|
- [x] Компонент `LeaderboardWidget`
|
||||||
- [ ] Компонент `CurrentWidget`
|
- [x] Компонент `CurrentWidget`
|
||||||
- [ ] Компонент `ProgressWidget`
|
- [x] Компонент `ProgressWidget`
|
||||||
- [ ] Polling обновлений
|
- [x] Polling обновлений (30 сек)
|
||||||
|
|
||||||
### Этап 4: Frontend — темы и стили
|
### Этап 4: Frontend — темы и стили ✅
|
||||||
- [ ] Базовые стили виджетов
|
- [x] Базовые стили виджетов
|
||||||
- [ ] Тема Dark
|
- [x] Тема Dark
|
||||||
- [ ] Тема Light
|
- [x] Тема Light
|
||||||
- [ ] Тема Neon
|
- [x] Тема Neon
|
||||||
- [ ] Поддержка прозрачного фона
|
- [x] Поддержка прозрачного фона
|
||||||
- [ ] Параметры кастомизации через URL
|
- [x] Параметры кастомизации через URL (theme, count, avatars, transparent)
|
||||||
|
|
||||||
### Этап 5: Frontend — страница настроек
|
### Этап 5: Frontend — страница настроек ✅
|
||||||
- [ ] Страница генерации виджетов
|
- [x] Модальное окно настройки виджетов (WidgetSettingsModal)
|
||||||
- [ ] Форма настроек (тема, количество и т.д.)
|
- [x] Форма настроек (тема, количество, аватарки, прозрачность)
|
||||||
- [ ] Копирование URL
|
- [x] Копирование URL
|
||||||
- [ ] Превью виджетов
|
- [x] Превью виджетов (iframe)
|
||||||
- [ ] Инструкция по добавлению в OBS
|
- [x] Инструкция по добавлению в OBS
|
||||||
|
|
||||||
### Этап 6: Тестирование
|
### Этап 6: Тестирование
|
||||||
- [ ] Проверка в OBS Browser Source
|
- [ ] Проверка в OBS Browser Source
|
||||||
@@ -600,6 +601,10 @@ async def validate_widget_token(token: str, marathon_id: int, db: AsyncSession)
|
|||||||
- [ ] Тестирование на разных разрешениях
|
- [ ] Тестирование на разных разрешениях
|
||||||
- [ ] Проверка производительности (polling)
|
- [ ] Проверка производительности (polling)
|
||||||
|
|
||||||
|
### Не реализовано (опционально)
|
||||||
|
- [x] Комбинированный виджет
|
||||||
|
- [ ] Rate limiting для API виджетов
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Примеры виджетов
|
## Примеры виджетов
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { InventoryPage } from '@/pages/InventoryPage'
|
|||||||
import LeaderboardWidget from '@/pages/widget/LeaderboardWidget'
|
import LeaderboardWidget from '@/pages/widget/LeaderboardWidget'
|
||||||
import CurrentWidget from '@/pages/widget/CurrentWidget'
|
import CurrentWidget from '@/pages/widget/CurrentWidget'
|
||||||
import ProgressWidget from '@/pages/widget/ProgressWidget'
|
import ProgressWidget from '@/pages/widget/ProgressWidget'
|
||||||
|
import CombinedWidget from '@/pages/widget/CombinedWidget'
|
||||||
|
|
||||||
// Admin Pages
|
// Admin Pages
|
||||||
import {
|
import {
|
||||||
@@ -95,6 +96,7 @@ function App() {
|
|||||||
<Route path="/widget/leaderboard" element={<LeaderboardWidget />} />
|
<Route path="/widget/leaderboard" element={<LeaderboardWidget />} />
|
||||||
<Route path="/widget/current" element={<CurrentWidget />} />
|
<Route path="/widget/current" element={<CurrentWidget />} />
|
||||||
<Route path="/widget/progress" element={<ProgressWidget />} />
|
<Route path="/widget/progress" element={<ProgressWidget />} />
|
||||||
|
<Route path="/widget/combined" element={<CombinedWidget />} />
|
||||||
|
|
||||||
<Route path="/" element={<Layout />}>
|
<Route path="/" element={<Layout />}>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ interface WidgetSettingsModalProps {
|
|||||||
|
|
||||||
type WidgetTheme = 'dark' | 'light' | 'neon'
|
type WidgetTheme = 'dark' | 'light' | 'neon'
|
||||||
|
|
||||||
|
type WidgetType = 'leaderboard' | 'current' | 'progress' | 'combined'
|
||||||
|
|
||||||
export function WidgetSettingsModal({ marathonId, isOpen, onClose }: WidgetSettingsModalProps) {
|
export function WidgetSettingsModal({ marathonId, isOpen, onClose }: WidgetSettingsModalProps) {
|
||||||
const [token, setToken] = useState<WidgetToken | null>(null)
|
const [token, setToken] = useState<WidgetToken | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -18,6 +20,7 @@ export function WidgetSettingsModal({ marathonId, isOpen, onClose }: WidgetSetti
|
|||||||
const [count, setCount] = useState(5)
|
const [count, setCount] = useState(5)
|
||||||
const [showAvatars, setShowAvatars] = useState(true)
|
const [showAvatars, setShowAvatars] = useState(true)
|
||||||
const [transparent, setTransparent] = useState(false)
|
const [transparent, setTransparent] = useState(false)
|
||||||
|
const [previewType, setPreviewType] = useState<WidgetType>('leaderboard')
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
useEffect(() => {
|
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 ''
|
if (!token) return ''
|
||||||
const baseUrl = window.location.origin
|
const baseUrl = window.location.origin
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -73,9 +76,16 @@ export function WidgetSettingsModal({ marathonId, isOpen, onClose }: WidgetSetti
|
|||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const widgetNames: Record<WidgetType, string> = {
|
||||||
|
leaderboard: 'Лидерборд',
|
||||||
|
current: 'Текущее задание',
|
||||||
|
progress: 'Прогресс',
|
||||||
|
combined: 'Всё в одном',
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
<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="p-6 border-b border-dark-700">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h2 className="text-xl font-bold">Виджеты для OBS</h2>
|
<h2 className="text-xl font-bold">Виджеты для OBS</h2>
|
||||||
@@ -90,14 +100,63 @@ export function WidgetSettingsModal({ marathonId, isOpen, onClose }: WidgetSetti
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-8">
|
<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>
|
<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>
|
<p className="text-gray-400 mt-2">Загрузка...</p>
|
||||||
</div>
|
</div>
|
||||||
) : token ? (
|
) : token ? (
|
||||||
<>
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Left column - Preview */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-semibold text-lg">Превью</h3>
|
||||||
|
|
||||||
|
{/* Widget type tabs */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(['leaderboard', 'current', 'progress', 'combined'] as WidgetType[]).map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={() => setPreviewType(type)}
|
||||||
|
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
|
||||||
|
previewType === type
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-dark-700 text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{widgetNames[type]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview iframe */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg overflow-hidden border border-dark-600"
|
||||||
|
style={{
|
||||||
|
background: transparent ? 'repeating-conic-gradient(#1a1a1a 0% 25%, #2a2a2a 0% 50%) 50% / 20px 20px'
|
||||||
|
: theme === 'light' ? '#f5f5f5' : '#121212'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
key={`${previewType}-${theme}-${count}-${showAvatars}-${transparent}`}
|
||||||
|
src={buildWidgetUrl(previewType)}
|
||||||
|
className="w-full"
|
||||||
|
style={{ height: '320px', border: 'none' }}
|
||||||
|
title="Widget Preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Copy button for current preview */}
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(buildWidgetUrl(previewType), widgetNames[previewType])}
|
||||||
|
className="w-full px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
Копировать ссылку на {widgetNames[previewType]}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column - Settings */}
|
||||||
|
<div className="space-y-6">
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="font-semibold text-lg">Настройки</h3>
|
<h3 className="font-semibold text-lg">Настройки</h3>
|
||||||
@@ -152,59 +211,56 @@ export function WidgetSettingsModal({ marathonId, isOpen, onClose }: WidgetSetti
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Widget URLs */}
|
{/* All Widget URLs */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<h3 className="font-semibold text-lg">Ссылки для OBS</h3>
|
<h3 className="font-semibold text-lg">Все ссылки</h3>
|
||||||
|
|
||||||
{[
|
{([
|
||||||
{ type: 'leaderboard' as const, name: 'Лидерборд', desc: 'Таблица участников с очками' },
|
{ type: 'leaderboard' as const, desc: 'Таблица участников' },
|
||||||
{ type: 'current' as const, name: 'Текущее задание', desc: 'Активный челлендж / прохождение' },
|
{ type: 'current' as const, desc: 'Активное задание' },
|
||||||
{ type: 'progress' as const, name: 'Прогресс', desc: 'Статистика участника' },
|
{ type: 'progress' as const, desc: 'Статистика' },
|
||||||
].map(({ type, name, desc }) => (
|
{ type: 'combined' as const, desc: 'Всё в одном виджете' },
|
||||||
<div key={type} className="bg-dark-700 rounded-lg p-4">
|
]).map(({ type, desc }) => (
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div key={type} className="flex items-center justify-between bg-dark-700 rounded-lg px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{name}</div>
|
<div className="font-medium text-sm">{widgetNames[type]}</div>
|
||||||
<div className="text-sm text-gray-400">{desc}</div>
|
<div className="text-xs text-gray-500">{desc}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => copyToClipboard(buildWidgetUrl(type), name)}
|
onClick={() => copyToClipboard(buildWidgetUrl(type), widgetNames[type])}
|
||||||
className="px-3 py-1 bg-primary text-white text-sm rounded-lg hover:bg-primary/80 transition-colors"
|
className="px-3 py-1 bg-dark-600 text-white text-sm rounded-lg hover:bg-dark-500 transition-colors"
|
||||||
>
|
>
|
||||||
Копировать
|
Копировать
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Instructions */}
|
{/* Instructions */}
|
||||||
<div className="bg-dark-700/50 rounded-lg p-4">
|
<div className="bg-dark-700/50 rounded-lg p-4">
|
||||||
<h4 className="font-medium mb-2">Как добавить в OBS</h4>
|
<h4 className="font-medium mb-2 text-sm">Как добавить в OBS</h4>
|
||||||
<ol className="text-sm text-gray-400 space-y-1 list-decimal list-inside">
|
<ol className="text-xs text-gray-400 space-y-1 list-decimal list-inside">
|
||||||
<li>Скопируйте нужную ссылку</li>
|
<li>Скопируйте нужную ссылку</li>
|
||||||
<li>В OBS нажмите "+" → "Браузер"</li>
|
<li>В OBS: "+" → "Браузер"</li>
|
||||||
<li>Вставьте ссылку в поле URL</li>
|
<li>Вставьте ссылку в поле URL</li>
|
||||||
<li>Рекомендуемый размер: 400x300</li>
|
<li>Размер: 400×300 px</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Token actions */}
|
{/* Token actions */}
|
||||||
<div className="flex justify-between items-center pt-4 border-t border-dark-700">
|
<div className="flex justify-between items-center pt-4 border-t border-dark-700">
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
Токен: {token.token.substring(0, 20)}...
|
Токен: {token.token.substring(0, 16)}...
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={regenerateToken}
|
onClick={regenerateToken}
|
||||||
className="text-sm text-red-400 hover:text-red-300"
|
className="text-xs text-red-400 hover:text-red-300"
|
||||||
>
|
>
|
||||||
Сбросить токен
|
Сбросить токен
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 text-gray-400">
|
<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 {
|
.widget-completed {
|
||||||
color: var(--widget-success);
|
color: var(--widget-success);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-dropped {
|
.widget-dropped {
|
||||||
color: var(--widget-danger);
|
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