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:
36
backend/alembic/versions/029_add_widget_tokens.py
Normal file
36
backend/alembic/versions/029_add_widget_tokens.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Add widget tokens
|
||||
|
||||
Revision ID: 029
|
||||
Revises: 028
|
||||
Create Date: 2025-01-09
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '029_add_widget_tokens'
|
||||
down_revision = '028_add_promo_codes'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'widget_tokens',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('token', sa.String(64), nullable=False),
|
||||
sa.Column('participant_id', sa.Integer(), nullable=False),
|
||||
sa.Column('marathon_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['participant_id'], ['participants.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['marathon_id'], ['marathons.id'], ondelete='CASCADE'),
|
||||
)
|
||||
op.create_index('ix_widget_tokens_token', 'widget_tokens', ['token'], unique=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('ix_widget_tokens_token', table_name='widget_tokens')
|
||||
op.drop_table('widget_tokens')
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop, promo
|
||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop, promo, widgets
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -18,3 +18,4 @@ router.include_router(telegram.router)
|
||||
router.include_router(content.router)
|
||||
router.include_router(shop.router)
|
||||
router.include_router(promo.router)
|
||||
router.include_router(widgets.router)
|
||||
|
||||
423
backend/app/api/v1/widgets.py
Normal file
423
backend/app/api/v1/widgets.py
Normal file
@@ -0,0 +1,423 @@
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException, status, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser, require_participant
|
||||
from app.models import (
|
||||
WidgetToken, Participant, Marathon, Assignment, AssignmentStatus,
|
||||
BonusAssignment, BonusAssignmentStatus,
|
||||
)
|
||||
from app.schemas.widget import (
|
||||
WidgetTokenResponse,
|
||||
WidgetTokenListItem,
|
||||
WidgetLeaderboardEntry,
|
||||
WidgetLeaderboardResponse,
|
||||
WidgetCurrentResponse,
|
||||
WidgetProgressResponse,
|
||||
)
|
||||
from app.schemas.common import MessageResponse
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter(prefix="/widgets", tags=["widgets"])
|
||||
|
||||
|
||||
def get_avatar_url(user) -> str | None:
|
||||
"""Get avatar URL - through backend API if user has avatar, else telegram"""
|
||||
if user.avatar_path:
|
||||
return f"/api/v1/users/{user.id}/avatar"
|
||||
return user.telegram_avatar_url
|
||||
|
||||
|
||||
def generate_widget_token() -> str:
|
||||
"""Generate a secure widget token"""
|
||||
return f"wgt_{secrets.token_urlsafe(32)}"
|
||||
|
||||
|
||||
def build_widget_urls(marathon_id: int, token: str) -> dict[str, str]:
|
||||
"""Build widget URLs for the token"""
|
||||
base_url = settings.FRONTEND_URL or "http://localhost:5173"
|
||||
params = f"marathon={marathon_id}&token={token}"
|
||||
return {
|
||||
"leaderboard": f"{base_url}/widget/leaderboard?{params}",
|
||||
"current": f"{base_url}/widget/current?{params}",
|
||||
"progress": f"{base_url}/widget/progress?{params}",
|
||||
}
|
||||
|
||||
|
||||
# === Token management (authenticated) ===
|
||||
|
||||
@router.post("/marathons/{marathon_id}/token", response_model=WidgetTokenResponse)
|
||||
async def create_widget_token(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Create a widget token for the current user in a marathon"""
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
# Check if user already has an active token
|
||||
existing = await db.scalar(
|
||||
select(WidgetToken).where(
|
||||
WidgetToken.participant_id == participant.id,
|
||||
WidgetToken.marathon_id == marathon_id,
|
||||
WidgetToken.is_active == True,
|
||||
)
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Return existing token
|
||||
return WidgetTokenResponse(
|
||||
id=existing.id,
|
||||
token=existing.token,
|
||||
created_at=existing.created_at,
|
||||
expires_at=existing.expires_at,
|
||||
is_active=existing.is_active,
|
||||
urls=build_widget_urls(marathon_id, existing.token),
|
||||
)
|
||||
|
||||
# Create new token
|
||||
token = generate_widget_token()
|
||||
widget_token = WidgetToken(
|
||||
token=token,
|
||||
participant_id=participant.id,
|
||||
marathon_id=marathon_id,
|
||||
)
|
||||
db.add(widget_token)
|
||||
await db.commit()
|
||||
await db.refresh(widget_token)
|
||||
|
||||
return WidgetTokenResponse(
|
||||
id=widget_token.id,
|
||||
token=widget_token.token,
|
||||
created_at=widget_token.created_at,
|
||||
expires_at=widget_token.expires_at,
|
||||
is_active=widget_token.is_active,
|
||||
urls=build_widget_urls(marathon_id, widget_token.token),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/marathons/{marathon_id}/tokens", response_model=list[WidgetTokenListItem])
|
||||
async def list_widget_tokens(
|
||||
marathon_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""List all widget tokens for the current user in a marathon"""
|
||||
participant = await require_participant(db, current_user.id, marathon_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(WidgetToken)
|
||||
.where(
|
||||
WidgetToken.participant_id == participant.id,
|
||||
WidgetToken.marathon_id == marathon_id,
|
||||
)
|
||||
.order_by(WidgetToken.created_at.desc())
|
||||
)
|
||||
tokens = result.scalars().all()
|
||||
|
||||
return [
|
||||
WidgetTokenListItem(
|
||||
id=t.id,
|
||||
token=t.token,
|
||||
created_at=t.created_at,
|
||||
is_active=t.is_active,
|
||||
)
|
||||
for t in tokens
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/tokens/{token_id}", response_model=MessageResponse)
|
||||
async def revoke_widget_token(
|
||||
token_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Revoke a widget token"""
|
||||
result = await db.execute(
|
||||
select(WidgetToken)
|
||||
.options(selectinload(WidgetToken.participant))
|
||||
.where(WidgetToken.id == token_id)
|
||||
)
|
||||
widget_token = result.scalar_one_or_none()
|
||||
|
||||
if not widget_token:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
|
||||
if widget_token.participant.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to revoke this token")
|
||||
|
||||
widget_token.is_active = False
|
||||
await db.commit()
|
||||
|
||||
return MessageResponse(message="Token revoked")
|
||||
|
||||
|
||||
@router.post("/tokens/{token_id}/regenerate", response_model=WidgetTokenResponse)
|
||||
async def regenerate_widget_token(
|
||||
token_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Regenerate a widget token (deactivates old, creates new)"""
|
||||
result = await db.execute(
|
||||
select(WidgetToken)
|
||||
.options(selectinload(WidgetToken.participant))
|
||||
.where(WidgetToken.id == token_id)
|
||||
)
|
||||
old_token = result.scalar_one_or_none()
|
||||
|
||||
if not old_token:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
|
||||
if old_token.participant.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
# Deactivate old token
|
||||
old_token.is_active = False
|
||||
|
||||
# Create new token
|
||||
new_token = WidgetToken(
|
||||
token=generate_widget_token(),
|
||||
participant_id=old_token.participant_id,
|
||||
marathon_id=old_token.marathon_id,
|
||||
)
|
||||
db.add(new_token)
|
||||
await db.commit()
|
||||
await db.refresh(new_token)
|
||||
|
||||
return WidgetTokenResponse(
|
||||
id=new_token.id,
|
||||
token=new_token.token,
|
||||
created_at=new_token.created_at,
|
||||
expires_at=new_token.expires_at,
|
||||
is_active=new_token.is_active,
|
||||
urls=build_widget_urls(new_token.marathon_id, new_token.token),
|
||||
)
|
||||
|
||||
|
||||
# === Public widget endpoints (authenticated via widget token) ===
|
||||
|
||||
async def validate_widget_token(token: str, marathon_id: int, db) -> WidgetToken:
|
||||
"""Validate widget token and return it"""
|
||||
result = await db.execute(
|
||||
select(WidgetToken)
|
||||
.options(
|
||||
selectinload(WidgetToken.participant).selectinload(Participant.user),
|
||||
selectinload(WidgetToken.marathon),
|
||||
)
|
||||
.where(
|
||||
WidgetToken.token == token,
|
||||
WidgetToken.marathon_id == marathon_id,
|
||||
WidgetToken.is_active == True,
|
||||
)
|
||||
)
|
||||
widget_token = result.scalar_one_or_none()
|
||||
|
||||
if not widget_token:
|
||||
raise HTTPException(status_code=401, detail="Invalid widget token")
|
||||
|
||||
if widget_token.expires_at and widget_token.expires_at < datetime.utcnow():
|
||||
raise HTTPException(status_code=401, detail="Widget token expired")
|
||||
|
||||
return widget_token
|
||||
|
||||
|
||||
@router.get("/data/leaderboard", response_model=WidgetLeaderboardResponse)
|
||||
async def widget_leaderboard(
|
||||
marathon: int = Query(..., description="Marathon ID"),
|
||||
token: str = Query(..., description="Widget token"),
|
||||
count: int = Query(5, ge=1, le=50, description="Number of participants"),
|
||||
db: DbSession = None,
|
||||
):
|
||||
"""Get leaderboard data for widget"""
|
||||
widget_token = await validate_widget_token(token, marathon, db)
|
||||
current_participant = widget_token.participant
|
||||
|
||||
# Get all participants ordered by points
|
||||
result = await db.execute(
|
||||
select(Participant)
|
||||
.options(selectinload(Participant.user))
|
||||
.where(Participant.marathon_id == marathon)
|
||||
.order_by(Participant.total_points.desc())
|
||||
)
|
||||
all_participants = result.scalars().all()
|
||||
|
||||
total_participants = len(all_participants)
|
||||
current_user_rank = None
|
||||
|
||||
# Find current user rank and build entries
|
||||
entries = []
|
||||
for rank, p in enumerate(all_participants, 1):
|
||||
if p.id == current_participant.id:
|
||||
current_user_rank = rank
|
||||
|
||||
if rank <= count:
|
||||
user = p.user
|
||||
entries.append(WidgetLeaderboardEntry(
|
||||
rank=rank,
|
||||
nickname=user.nickname,
|
||||
avatar_url=get_avatar_url(user),
|
||||
total_points=p.total_points,
|
||||
current_streak=p.current_streak,
|
||||
is_current_user=(p.id == current_participant.id),
|
||||
))
|
||||
|
||||
return WidgetLeaderboardResponse(
|
||||
entries=entries,
|
||||
current_user_rank=current_user_rank,
|
||||
total_participants=total_participants,
|
||||
marathon_title=widget_token.marathon.title,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/data/current", response_model=WidgetCurrentResponse)
|
||||
async def widget_current_assignment(
|
||||
marathon: int = Query(..., description="Marathon ID"),
|
||||
token: str = Query(..., description="Widget token"),
|
||||
db: DbSession = None,
|
||||
):
|
||||
"""Get current assignment data for widget"""
|
||||
widget_token = await validate_widget_token(token, marathon, db)
|
||||
participant = widget_token.participant
|
||||
|
||||
# Get active assignment
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge),
|
||||
selectinload(Assignment.game),
|
||||
)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.status.in_([
|
||||
AssignmentStatus.ACTIVE.value,
|
||||
AssignmentStatus.RETURNED.value,
|
||||
]),
|
||||
)
|
||||
.order_by(Assignment.started_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
assignment = result.scalar_one_or_none()
|
||||
|
||||
if not assignment:
|
||||
return WidgetCurrentResponse(has_assignment=False)
|
||||
|
||||
# Determine assignment type and details
|
||||
if assignment.is_playthrough:
|
||||
game = assignment.game
|
||||
assignment_type = "playthrough"
|
||||
challenge_title = "Прохождение"
|
||||
challenge_description = game.playthrough_description
|
||||
points = game.playthrough_points
|
||||
difficulty = None
|
||||
|
||||
# Count bonus challenges
|
||||
bonus_result = await db.execute(
|
||||
select(func.count()).select_from(BonusAssignment)
|
||||
.where(BonusAssignment.main_assignment_id == assignment.id)
|
||||
)
|
||||
bonus_total = bonus_result.scalar() or 0
|
||||
|
||||
completed_result = await db.execute(
|
||||
select(func.count()).select_from(BonusAssignment)
|
||||
.where(
|
||||
BonusAssignment.main_assignment_id == assignment.id,
|
||||
BonusAssignment.status == BonusAssignmentStatus.COMPLETED.value,
|
||||
)
|
||||
)
|
||||
bonus_completed = completed_result.scalar() or 0
|
||||
|
||||
game_title = game.title
|
||||
game_cover_url = f"/api/v1/games/{game.id}/cover" if game.cover_path else None
|
||||
else:
|
||||
challenge = assignment.challenge
|
||||
assignment_type = "challenge"
|
||||
challenge_title = challenge.title
|
||||
challenge_description = challenge.description
|
||||
points = challenge.points
|
||||
difficulty = challenge.difficulty
|
||||
bonus_completed = None
|
||||
bonus_total = None
|
||||
|
||||
game = challenge.game if hasattr(challenge, 'game') else None
|
||||
if not game:
|
||||
# Load game via challenge
|
||||
from app.models import Game
|
||||
game_result = await db.execute(
|
||||
select(Game).where(Game.id == challenge.game_id)
|
||||
)
|
||||
game = game_result.scalar_one_or_none()
|
||||
|
||||
game_title = game.title if game else None
|
||||
game_cover_url = f"/api/v1/games/{game.id}/cover" if game and game.cover_path else None
|
||||
|
||||
return WidgetCurrentResponse(
|
||||
has_assignment=True,
|
||||
game_title=game_title,
|
||||
game_cover_url=game_cover_url,
|
||||
assignment_type=assignment_type,
|
||||
challenge_title=challenge_title,
|
||||
challenge_description=challenge_description,
|
||||
points=points,
|
||||
difficulty=difficulty,
|
||||
bonus_completed=bonus_completed,
|
||||
bonus_total=bonus_total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/data/progress", response_model=WidgetProgressResponse)
|
||||
async def widget_progress(
|
||||
marathon: int = Query(..., description="Marathon ID"),
|
||||
token: str = Query(..., description="Widget token"),
|
||||
db: DbSession = None,
|
||||
):
|
||||
"""Get participant progress data for widget"""
|
||||
widget_token = await validate_widget_token(token, marathon, db)
|
||||
participant = widget_token.participant
|
||||
user = participant.user
|
||||
|
||||
# Calculate rank
|
||||
result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Participant)
|
||||
.where(
|
||||
Participant.marathon_id == marathon,
|
||||
Participant.total_points > participant.total_points,
|
||||
)
|
||||
)
|
||||
higher_count = result.scalar() or 0
|
||||
rank = higher_count + 1
|
||||
|
||||
# Count completed and dropped assignments
|
||||
completed_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Assignment)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.status == AssignmentStatus.COMPLETED.value,
|
||||
)
|
||||
)
|
||||
completed_count = completed_result.scalar() or 0
|
||||
|
||||
dropped_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Assignment)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.status == AssignmentStatus.DROPPED.value,
|
||||
)
|
||||
)
|
||||
dropped_count = dropped_result.scalar() or 0
|
||||
|
||||
return WidgetProgressResponse(
|
||||
nickname=user.nickname,
|
||||
avatar_url=get_avatar_url(user),
|
||||
rank=rank,
|
||||
total_points=participant.total_points,
|
||||
current_streak=participant.current_streak,
|
||||
completed_count=completed_count,
|
||||
dropped_count=dropped_count,
|
||||
marathon_title=widget_token.marathon.title,
|
||||
)
|
||||
@@ -18,6 +18,7 @@ from app.models.inventory import UserInventory
|
||||
from app.models.coin_transaction import CoinTransaction, CoinTransactionType
|
||||
from app.models.consumable_usage import ConsumableUsage
|
||||
from app.models.promo_code import PromoCode, PromoCodeRedemption
|
||||
from app.models.widget_token import WidgetToken
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -65,4 +66,5 @@ __all__ = [
|
||||
"ConsumableUsage",
|
||||
"PromoCode",
|
||||
"PromoCodeRedemption",
|
||||
"WidgetToken",
|
||||
]
|
||||
|
||||
22
backend/app/models/widget_token.py
Normal file
22
backend/app/models/widget_token.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class WidgetToken(Base):
|
||||
"""Токен для авторизации OBS виджетов"""
|
||||
__tablename__ = "widget_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"))
|
||||
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Relationships
|
||||
participant: Mapped["Participant"] = relationship("Participant")
|
||||
marathon: Mapped["Marathon"] = relationship("Marathon")
|
||||
@@ -134,6 +134,15 @@ from app.schemas.promo_code import (
|
||||
PromoCodeRedemptionUser,
|
||||
)
|
||||
from app.schemas.user import ShopItemPublic
|
||||
from app.schemas.widget import (
|
||||
WidgetTokenCreate,
|
||||
WidgetTokenResponse,
|
||||
WidgetTokenListItem,
|
||||
WidgetLeaderboardEntry,
|
||||
WidgetLeaderboardResponse,
|
||||
WidgetCurrentResponse,
|
||||
WidgetProgressResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# User
|
||||
@@ -260,4 +269,12 @@ __all__ = [
|
||||
"PromoCodeRedeemResponse",
|
||||
"PromoCodeRedemptionResponse",
|
||||
"PromoCodeRedemptionUser",
|
||||
# Widget
|
||||
"WidgetTokenCreate",
|
||||
"WidgetTokenResponse",
|
||||
"WidgetTokenListItem",
|
||||
"WidgetLeaderboardEntry",
|
||||
"WidgetLeaderboardResponse",
|
||||
"WidgetCurrentResponse",
|
||||
"WidgetProgressResponse",
|
||||
]
|
||||
|
||||
79
backend/app/schemas/widget.py
Normal file
79
backend/app/schemas/widget.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# === Token schemas ===
|
||||
|
||||
class WidgetTokenCreate(BaseModel):
|
||||
"""Создание токена виджета"""
|
||||
pass # Не требует параметров
|
||||
|
||||
|
||||
class WidgetTokenResponse(BaseModel):
|
||||
"""Ответ с токеном виджета"""
|
||||
id: int
|
||||
token: str
|
||||
created_at: datetime
|
||||
expires_at: datetime | None
|
||||
is_active: bool
|
||||
urls: dict[str, str] # Готовые URL для виджетов
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class WidgetTokenListItem(BaseModel):
|
||||
"""Элемент списка токенов"""
|
||||
id: int
|
||||
token: str
|
||||
created_at: datetime
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# === Widget data schemas ===
|
||||
|
||||
class WidgetLeaderboardEntry(BaseModel):
|
||||
"""Запись в лидерборде виджета"""
|
||||
rank: int
|
||||
nickname: str
|
||||
avatar_url: str | None
|
||||
total_points: int
|
||||
current_streak: int
|
||||
is_current_user: bool # Для подсветки
|
||||
|
||||
|
||||
class WidgetLeaderboardResponse(BaseModel):
|
||||
"""Ответ лидерборда для виджета"""
|
||||
entries: list[WidgetLeaderboardEntry]
|
||||
current_user_rank: int | None
|
||||
total_participants: int
|
||||
marathon_title: str
|
||||
|
||||
|
||||
class WidgetCurrentResponse(BaseModel):
|
||||
"""Текущее задание для виджета"""
|
||||
has_assignment: bool
|
||||
game_title: str | None = None
|
||||
game_cover_url: str | None = None
|
||||
assignment_type: str | None = None # "challenge" | "playthrough"
|
||||
challenge_title: str | None = None
|
||||
challenge_description: str | None = None
|
||||
points: int | None = None
|
||||
difficulty: str | None = None # easy, medium, hard
|
||||
bonus_completed: int | None = None # Для прохождений
|
||||
bonus_total: int | None = None
|
||||
|
||||
|
||||
class WidgetProgressResponse(BaseModel):
|
||||
"""Прогресс участника для виджета"""
|
||||
nickname: str
|
||||
avatar_url: str | None
|
||||
rank: int
|
||||
total_points: int
|
||||
current_streak: int
|
||||
completed_count: int
|
||||
dropped_count: int
|
||||
marathon_title: str
|
||||
664
docs/tz-obs-widget.md
Normal file
664
docs/tz-obs-widget.md
Normal file
@@ -0,0 +1,664 @@
|
||||
# ТЗ: OBS Виджеты для стрима
|
||||
|
||||
## Описание задачи
|
||||
|
||||
Создать набор виджетов для отображения информации о марафоне в OBS через Browser Source. Виджеты позволяют стримерам показывать зрителям актуальную информацию о марафоне в реальном времени.
|
||||
|
||||
---
|
||||
|
||||
## Виджеты
|
||||
|
||||
### 1. Лидерборд
|
||||
|
||||
Таблица участников марафона с их позициями и очками.
|
||||
|
||||
| Поле | Описание |
|
||||
|------|----------|
|
||||
| Место | Позиция в рейтинге (1, 2, 3...) |
|
||||
| Аватар | Аватарка участника (круглая, 32x32 px) |
|
||||
| Никнейм | Имя участника |
|
||||
| Очки | Текущее количество очков |
|
||||
| Стрик | Текущий стрик (опционально) |
|
||||
|
||||
**Настройки:**
|
||||
- Количество отображаемых участников (3, 5, 10, все)
|
||||
- Подсветка текущего стримера
|
||||
- Показ/скрытие аватарок
|
||||
- Показ/скрытие стриков
|
||||
|
||||
---
|
||||
|
||||
### 2. Текущее задание
|
||||
|
||||
Отображает активное задание стримера.
|
||||
|
||||
| Поле | Описание |
|
||||
|------|----------|
|
||||
| Игра | Название игры |
|
||||
| Задание | Описание челленджа / прохождения |
|
||||
| Очки | Количество очков за выполнение |
|
||||
| Тип | Челлендж / Прохождение |
|
||||
| Прогресс бонусов | Для прохождений: X/Y бонусных челленджей |
|
||||
|
||||
**Состояния:**
|
||||
- Активное задание — показывает детали
|
||||
- Нет задания — "Ожидание спина" или скрыт
|
||||
|
||||
---
|
||||
|
||||
### 3. Прогресс марафона
|
||||
|
||||
Общая статистика стримера в марафоне.
|
||||
|
||||
| Поле | Описание |
|
||||
|------|----------|
|
||||
| Позиция | Текущее место в рейтинге |
|
||||
| Очки | Набранные очки |
|
||||
| Стрик | Текущий стрик |
|
||||
| Выполнено | Количество выполненных заданий |
|
||||
| Дропнуто | Количество дропнутых заданий |
|
||||
|
||||
---
|
||||
|
||||
### 4. Комбинированный виджет (опционально)
|
||||
|
||||
Объединяет несколько блоков в одном виджете:
|
||||
- Мини-лидерборд (топ-3)
|
||||
- Текущее задание
|
||||
- Статистика стримера
|
||||
|
||||
---
|
||||
|
||||
## Техническая реализация
|
||||
|
||||
### Архитектура
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OBS Browser Source │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ /widget/{type}?params │ │
|
||||
│ │ │ │
|
||||
│ │ Frontend страница │ │
|
||||
│ │ (React / статический HTML)│ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ WebSocket / Polling │ │
|
||||
│ │ Обновление данных │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ Backend API │ │
|
||||
│ │ /api/v1/widget/* │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### URL структура
|
||||
|
||||
```
|
||||
/widget/leaderboard?marathon={id}&token={token}&theme={theme}&count={count}
|
||||
/widget/current?marathon={id}&token={token}&theme={theme}
|
||||
/widget/progress?marathon={id}&token={token}&theme={theme}
|
||||
/widget/combined?marathon={id}&token={token}&theme={theme}
|
||||
```
|
||||
|
||||
### Параметры URL
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|--------------|----------|
|
||||
| `marathon` | Да | ID марафона |
|
||||
| `token` | Да | Токен виджета (привязан к участнику) |
|
||||
| `theme` | Нет | Тема оформления (dark, light, custom) |
|
||||
| `count` | Нет | Количество участников (для лидерборда) |
|
||||
| `highlight` | Нет | Подсветить пользователя (true/false) |
|
||||
| `avatars` | Нет | Показывать аватарки (true/false, по умолчанию true) |
|
||||
| `fontSize` | Нет | Размер шрифта (sm, md, lg) |
|
||||
| `width` | Нет | Ширина виджета в пикселях |
|
||||
| `transparent` | Нет | Прозрачный фон (true/false) |
|
||||
|
||||
---
|
||||
|
||||
## Backend API
|
||||
|
||||
### Токен виджета
|
||||
|
||||
Для авторизации виджетов используется специальный токен, привязанный к участнику марафона. Это позволяет:
|
||||
- Идентифицировать стримера для подсветки в лидерборде
|
||||
- Показывать личную статистику и задания
|
||||
- Не требовать полной авторизации в OBS
|
||||
|
||||
#### Генерация токена
|
||||
|
||||
```
|
||||
POST /api/v1/marathons/{marathon_id}/widget-token
|
||||
Authorization: Bearer {jwt_token}
|
||||
|
||||
Response:
|
||||
{
|
||||
"token": "wgt_abc123xyz...",
|
||||
"expires_at": null, // Бессрочный или с датой
|
||||
"urls": {
|
||||
"leaderboard": "https://marathon.example.com/widget/leaderboard?marathon=1&token=wgt_abc123xyz",
|
||||
"current": "https://marathon.example.com/widget/current?marathon=1&token=wgt_abc123xyz",
|
||||
"progress": "https://marathon.example.com/widget/progress?marathon=1&token=wgt_abc123xyz"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Модель токена
|
||||
|
||||
```python
|
||||
class WidgetToken(Base):
|
||||
__tablename__ = "widget_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id"))
|
||||
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
participant: Mapped["Participant"] = relationship()
|
||||
marathon: Mapped["Marathon"] = relationship()
|
||||
```
|
||||
|
||||
### Эндпоинты виджетов
|
||||
|
||||
```python
|
||||
# Публичные эндпоинты (авторизация через widget token)
|
||||
|
||||
@router.get("/widget/leaderboard")
|
||||
async def widget_leaderboard(
|
||||
marathon: int,
|
||||
token: str,
|
||||
count: int = 10,
|
||||
db: DbSession
|
||||
) -> WidgetLeaderboardResponse:
|
||||
"""
|
||||
Получить данные лидерборда для виджета.
|
||||
Возвращает топ участников и позицию владельца токена.
|
||||
"""
|
||||
|
||||
@router.get("/widget/current")
|
||||
async def widget_current_assignment(
|
||||
marathon: int,
|
||||
token: str,
|
||||
db: DbSession
|
||||
) -> WidgetCurrentResponse:
|
||||
"""
|
||||
Получить текущее задание владельца токена.
|
||||
"""
|
||||
|
||||
@router.get("/widget/progress")
|
||||
async def widget_progress(
|
||||
marathon: int,
|
||||
token: str,
|
||||
db: DbSession
|
||||
) -> WidgetProgressResponse:
|
||||
"""
|
||||
Получить статистику владельца токена.
|
||||
"""
|
||||
```
|
||||
|
||||
### Схемы ответов
|
||||
|
||||
```python
|
||||
class WidgetLeaderboardEntry(BaseModel):
|
||||
rank: int
|
||||
nickname: str
|
||||
avatar_url: str | None
|
||||
total_points: int
|
||||
current_streak: int
|
||||
is_current_user: bool # Для подсветки
|
||||
|
||||
class WidgetLeaderboardResponse(BaseModel):
|
||||
entries: list[WidgetLeaderboardEntry]
|
||||
current_user_rank: int | None
|
||||
total_participants: int
|
||||
marathon_title: str
|
||||
|
||||
class WidgetCurrentResponse(BaseModel):
|
||||
has_assignment: bool
|
||||
game_title: str | None
|
||||
game_cover_url: str | None
|
||||
assignment_type: str | None # "challenge" | "playthrough"
|
||||
challenge_title: str | None
|
||||
challenge_description: str | None
|
||||
points: int | None
|
||||
bonus_completed: int | None # Для прохождений
|
||||
bonus_total: int | None
|
||||
|
||||
class WidgetProgressResponse(BaseModel):
|
||||
nickname: str
|
||||
avatar_url: str | None
|
||||
rank: int
|
||||
total_points: int
|
||||
current_streak: int
|
||||
completed_count: int
|
||||
dropped_count: int
|
||||
marathon_title: str
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### Структура файлов
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── pages/
|
||||
│ │ └── widget/
|
||||
│ │ ├── LeaderboardWidget.tsx
|
||||
│ │ ├── CurrentWidget.tsx
|
||||
│ │ ├── ProgressWidget.tsx
|
||||
│ │ └── CombinedWidget.tsx
|
||||
│ ├── components/
|
||||
│ │ └── widget/
|
||||
│ │ ├── WidgetContainer.tsx
|
||||
│ │ ├── LeaderboardRow.tsx
|
||||
│ │ ├── AssignmentCard.tsx
|
||||
│ │ └── StatsBlock.tsx
|
||||
│ └── styles/
|
||||
│ └── widget/
|
||||
│ ├── themes/
|
||||
│ │ ├── dark.css
|
||||
│ │ ├── light.css
|
||||
│ │ └── neon.css
|
||||
│ └── widget.css
|
||||
```
|
||||
|
||||
### Роутинг
|
||||
|
||||
```tsx
|
||||
// App.tsx или router config
|
||||
<Route path="/widget/leaderboard" element={<LeaderboardWidget />} />
|
||||
<Route path="/widget/current" element={<CurrentWidget />} />
|
||||
<Route path="/widget/progress" element={<ProgressWidget />} />
|
||||
<Route path="/widget/combined" element={<CombinedWidget />} />
|
||||
```
|
||||
|
||||
### Компонент виджета
|
||||
|
||||
```tsx
|
||||
// pages/widget/LeaderboardWidget.tsx
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { widgetApi } from '@/api/widget'
|
||||
|
||||
const LeaderboardWidget = () => {
|
||||
const [params] = useSearchParams()
|
||||
const marathon = params.get('marathon')
|
||||
const token = params.get('token')
|
||||
const theme = params.get('theme') || 'dark'
|
||||
const count = parseInt(params.get('count') || '5')
|
||||
const highlight = params.get('highlight') !== 'false'
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['widget-leaderboard', marathon, token],
|
||||
queryFn: () => widgetApi.getLeaderboard(marathon, token, count),
|
||||
refetchInterval: 30000, // Обновление каждые 30 сек
|
||||
})
|
||||
|
||||
if (isLoading) return <WidgetLoader />
|
||||
if (!data) return null
|
||||
|
||||
return (
|
||||
<WidgetContainer theme={theme} transparent={params.get('transparent') === 'true'}>
|
||||
<div className="widget-leaderboard">
|
||||
<h3 className="widget-title">{data.marathon_title}</h3>
|
||||
{data.entries.map((entry) => (
|
||||
<LeaderboardRow
|
||||
key={entry.rank}
|
||||
entry={entry}
|
||||
highlight={highlight && entry.is_current_user}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</WidgetContainer>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Темы оформления
|
||||
|
||||
### Базовые темы
|
||||
|
||||
#### Dark (по умолчанию)
|
||||
```css
|
||||
.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);
|
||||
}
|
||||
```
|
||||
|
||||
#### Light
|
||||
```css
|
||||
.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);
|
||||
}
|
||||
```
|
||||
|
||||
#### Neon
|
||||
```css
|
||||
.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;
|
||||
}
|
||||
```
|
||||
|
||||
#### Transparent
|
||||
```css
|
||||
.widget-transparent {
|
||||
--widget-bg: transparent;
|
||||
}
|
||||
```
|
||||
|
||||
### Кастомизация через URL
|
||||
|
||||
```
|
||||
?theme=dark
|
||||
?theme=light
|
||||
?theme=neon
|
||||
?theme=custom&bg=1a1a1a&text=ffffff&accent=ff6600
|
||||
?transparent=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Обновление данных
|
||||
|
||||
### Варианты
|
||||
|
||||
| Способ | Описание | Плюсы | Минусы |
|
||||
|--------|----------|-------|--------|
|
||||
| Polling | Периодический запрос (30 сек) | Простота | Задержка, нагрузка |
|
||||
| WebSocket | Реал-тайм обновления | Мгновенно | Сложность |
|
||||
| SSE | Server-Sent Events | Простой real-time | Односторонний |
|
||||
|
||||
### Рекомендация
|
||||
|
||||
**Polling с интервалом 30 секунд** — оптимальный баланс:
|
||||
- Простая реализация
|
||||
- Минимальная нагрузка на сервер
|
||||
- Достаточная актуальность для стрима
|
||||
|
||||
Для будущего развития можно добавить WebSocket.
|
||||
|
||||
---
|
||||
|
||||
## Интерфейс настройки
|
||||
|
||||
### Страница генерации виджетов
|
||||
|
||||
В личном кабинете участника добавить раздел "Виджеты для стрима":
|
||||
|
||||
```tsx
|
||||
// pages/WidgetSettingsPage.tsx
|
||||
const WidgetSettingsPage = () => {
|
||||
const [widgetToken, setWidgetToken] = useState<string | null>(null)
|
||||
const [selectedTheme, setSelectedTheme] = useState('dark')
|
||||
const [leaderboardCount, setLeaderboardCount] = useState(5)
|
||||
|
||||
const generateToken = async () => {
|
||||
const response = await api.createWidgetToken(marathonId)
|
||||
setWidgetToken(response.token)
|
||||
}
|
||||
|
||||
const widgetUrl = (type: string) => {
|
||||
const params = new URLSearchParams({
|
||||
marathon: marathonId.toString(),
|
||||
token: widgetToken,
|
||||
theme: selectedTheme,
|
||||
...(type === 'leaderboard' && { count: leaderboardCount.toString() }),
|
||||
})
|
||||
return `${window.location.origin}/widget/${type}?${params}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Виджеты для OBS</h1>
|
||||
|
||||
{!widgetToken ? (
|
||||
<Button onClick={generateToken}>Создать токен</Button>
|
||||
) : (
|
||||
<>
|
||||
<Section title="Настройки">
|
||||
<Select
|
||||
label="Тема"
|
||||
value={selectedTheme}
|
||||
options={['dark', 'light', 'neon']}
|
||||
onChange={setSelectedTheme}
|
||||
/>
|
||||
<Input
|
||||
label="Участников в лидерборде"
|
||||
type="number"
|
||||
value={leaderboardCount}
|
||||
onChange={setLeaderboardCount}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Ссылки для OBS">
|
||||
<WidgetUrlBlock
|
||||
title="Лидерборд"
|
||||
url={widgetUrl('leaderboard')}
|
||||
preview={<LeaderboardPreview />}
|
||||
/>
|
||||
<WidgetUrlBlock
|
||||
title="Текущее задание"
|
||||
url={widgetUrl('current')}
|
||||
preview={<CurrentPreview />}
|
||||
/>
|
||||
<WidgetUrlBlock
|
||||
title="Прогресс"
|
||||
url={widgetUrl('progress')}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Инструкция">
|
||||
<ol>
|
||||
<li>Скопируйте нужную ссылку</li>
|
||||
<li>В OBS добавьте источник "Browser"</li>
|
||||
<li>Вставьте ссылку в поле URL</li>
|
||||
<li>Установите размер (рекомендуется: 400x300)</li>
|
||||
</ol>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Превью виджетов
|
||||
|
||||
Показывать живой превью виджета с текущими настройками:
|
||||
|
||||
```tsx
|
||||
const WidgetPreview = ({ type, params }) => {
|
||||
return (
|
||||
<div className="widget-preview">
|
||||
<iframe
|
||||
src={`/widget/${type}?${params}`}
|
||||
width="400"
|
||||
height="300"
|
||||
style={{ border: 'none', borderRadius: 8 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Безопасность
|
||||
|
||||
### Токены виджетов
|
||||
|
||||
- Токен привязан к конкретному участнику и марафону
|
||||
- Токен можно отозвать (деактивировать)
|
||||
- Токен даёт доступ только к публичной информации марафона
|
||||
- Нельзя использовать для изменения данных
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```python
|
||||
# Ограничения для widget эндпоинтов
|
||||
WIDGET_RATE_LIMIT = "60/minute" # 60 запросов в минуту на токен
|
||||
```
|
||||
|
||||
### Валидация токена
|
||||
|
||||
```python
|
||||
async def validate_widget_token(token: str, marathon_id: int, db: AsyncSession) -> WidgetToken:
|
||||
widget_token = await db.scalar(
|
||||
select(WidgetToken)
|
||||
.options(selectinload(WidgetToken.participant))
|
||||
.where(
|
||||
WidgetToken.token == token,
|
||||
WidgetToken.marathon_id == marathon_id,
|
||||
WidgetToken.is_active == True,
|
||||
)
|
||||
)
|
||||
|
||||
if not widget_token:
|
||||
raise HTTPException(status_code=401, detail="Invalid widget token")
|
||||
|
||||
if widget_token.expires_at and widget_token.expires_at < datetime.utcnow():
|
||||
raise HTTPException(status_code=401, detail="Widget token expired")
|
||||
|
||||
return widget_token
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## План реализации
|
||||
|
||||
### Этап 1: Backend — модель и токены
|
||||
- [ ] Создать модель `WidgetToken`
|
||||
- [ ] Миграция для таблицы `widget_tokens`
|
||||
- [ ] API создания токена (`POST /marathons/{id}/widget-token`)
|
||||
- [ ] API отзыва токена (`DELETE /widget-tokens/{id}`)
|
||||
- [ ] Валидация токена
|
||||
|
||||
### Этап 2: Backend — API виджетов
|
||||
- [ ] Эндпоинт `/widget/leaderboard`
|
||||
- [ ] Эндпоинт `/widget/current`
|
||||
- [ ] Эндпоинт `/widget/progress`
|
||||
- [ ] Схемы ответов
|
||||
- [ ] Rate limiting
|
||||
|
||||
### Этап 3: Frontend — страницы виджетов
|
||||
- [ ] Роутинг `/widget/*`
|
||||
- [ ] Компонент `LeaderboardWidget`
|
||||
- [ ] Компонент `CurrentWidget`
|
||||
- [ ] Компонент `ProgressWidget`
|
||||
- [ ] Polling обновлений
|
||||
|
||||
### Этап 4: Frontend — темы и стили
|
||||
- [ ] Базовые стили виджетов
|
||||
- [ ] Тема Dark
|
||||
- [ ] Тема Light
|
||||
- [ ] Тема Neon
|
||||
- [ ] Поддержка прозрачного фона
|
||||
- [ ] Параметры кастомизации через URL
|
||||
|
||||
### Этап 5: Frontend — страница настроек
|
||||
- [ ] Страница генерации виджетов
|
||||
- [ ] Форма настроек (тема, количество и т.д.)
|
||||
- [ ] Копирование URL
|
||||
- [ ] Превью виджетов
|
||||
- [ ] Инструкция по добавлению в OBS
|
||||
|
||||
### Этап 6: Тестирование
|
||||
- [ ] Проверка в OBS Browser Source
|
||||
- [ ] Тестирование тем
|
||||
- [ ] Проверка обновления данных
|
||||
- [ ] Тестирование на разных разрешениях
|
||||
- [ ] Проверка производительности (polling)
|
||||
|
||||
---
|
||||
|
||||
## Примеры виджетов
|
||||
|
||||
### Лидерборд (Dark theme)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🏆 Game Marathon │
|
||||
├─────────────────────────────────────┤
|
||||
│ 1. 🟣 PlayerOne 1250 pts │
|
||||
│ 2. 🔵 StreamerPro 980 pts │
|
||||
│ ▶3. 🟢 CurrentUser 875 pts ◀│
|
||||
│ 4. 🟡 GamerX 720 pts │
|
||||
│ 5. 🔴 ProPlayer 650 pts │
|
||||
└─────────────────────────────────────┘
|
||||
↑
|
||||
аватарки
|
||||
```
|
||||
|
||||
### Текущее задание
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ 🎮 Dark Souls III │
|
||||
├─────────────────────────────────┤
|
||||
│ Челлендж: │
|
||||
│ Победить Намлесс Кинга │
|
||||
│ без брони │
|
||||
│ │
|
||||
│ Очки: +150 │
|
||||
│ │
|
||||
│ Сложность: ⭐⭐⭐ │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Прогресс
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ 🟢 CurrentUser │
|
||||
│ ↑ │
|
||||
│ аватарка │
|
||||
├─────────────────────────────────┤
|
||||
│ Место: #3 │
|
||||
│ Очки: 875 │
|
||||
│ Стрик: 🔥 5 │
|
||||
│ Выполнено: 12 │
|
||||
│ Дропнуто: 2 │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Дополнительные идеи (будущее)
|
||||
|
||||
- **Анимации** — анимация при изменении позиций в лидерборде
|
||||
- **Звуковые оповещения** — звук при выполнении задания
|
||||
- **WebSocket** — мгновенные обновления без polling
|
||||
- **Кастомный CSS** — возможность вставить свой CSS
|
||||
- **Виджет событий** — показ активных событий марафона
|
||||
- **Виджет колеса** — мини-версия колеса фортуны
|
||||
@@ -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