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 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")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
@@ -18,3 +18,4 @@ router.include_router(telegram.router)
|
|||||||
router.include_router(content.router)
|
router.include_router(content.router)
|
||||||
router.include_router(shop.router)
|
router.include_router(shop.router)
|
||||||
router.include_router(promo.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.coin_transaction import CoinTransaction, CoinTransactionType
|
||||||
from app.models.consumable_usage import ConsumableUsage
|
from app.models.consumable_usage import ConsumableUsage
|
||||||
from app.models.promo_code import PromoCode, PromoCodeRedemption
|
from app.models.promo_code import PromoCode, PromoCodeRedemption
|
||||||
|
from app.models.widget_token import WidgetToken
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -65,4 +66,5 @@ __all__ = [
|
|||||||
"ConsumableUsage",
|
"ConsumableUsage",
|
||||||
"PromoCode",
|
"PromoCode",
|
||||||
"PromoCodeRedemption",
|
"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,
|
PromoCodeRedemptionUser,
|
||||||
)
|
)
|
||||||
from app.schemas.user import ShopItemPublic
|
from app.schemas.user import ShopItemPublic
|
||||||
|
from app.schemas.widget import (
|
||||||
|
WidgetTokenCreate,
|
||||||
|
WidgetTokenResponse,
|
||||||
|
WidgetTokenListItem,
|
||||||
|
WidgetLeaderboardEntry,
|
||||||
|
WidgetLeaderboardResponse,
|
||||||
|
WidgetCurrentResponse,
|
||||||
|
WidgetProgressResponse,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# User
|
# User
|
||||||
@@ -260,4 +269,12 @@ __all__ = [
|
|||||||
"PromoCodeRedeemResponse",
|
"PromoCodeRedeemResponse",
|
||||||
"PromoCodeRedemptionResponse",
|
"PromoCodeRedemptionResponse",
|
||||||
"PromoCodeRedemptionUser",
|
"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 { ShopPage } from '@/pages/ShopPage'
|
||||||
import { InventoryPage } from '@/pages/InventoryPage'
|
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
|
// Admin Pages
|
||||||
import {
|
import {
|
||||||
AdminLayout,
|
AdminLayout,
|
||||||
@@ -86,6 +91,11 @@ function App() {
|
|||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<ConfirmModal />
|
<ConfirmModal />
|
||||||
<Routes>
|
<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 path="/" element={<Layout />}>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
|
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export { usersApi } from './users'
|
|||||||
export { telegramApi } from './telegram'
|
export { telegramApi } from './telegram'
|
||||||
export { shopApi } from './shop'
|
export { shopApi } from './shop'
|
||||||
export { promoApi } from './promo'
|
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 { EventControl } from '@/components/EventControl'
|
||||||
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
|
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
|
||||||
import { MarathonSettingsModal } from '@/components/MarathonSettingsModal'
|
import { MarathonSettingsModal } from '@/components/MarathonSettingsModal'
|
||||||
|
import { WidgetSettingsModal } from '@/components/WidgetSettingsModal'
|
||||||
import {
|
import {
|
||||||
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
|
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
|
||||||
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
|
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
|
||||||
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles,
|
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'
|
} from 'lucide-react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { ru } from 'date-fns/locale'
|
import { ru } from 'date-fns/locale'
|
||||||
@@ -38,6 +39,7 @@ export function MarathonPage() {
|
|||||||
const [showChallenges, setShowChallenges] = useState(false)
|
const [showChallenges, setShowChallenges] = useState(false)
|
||||||
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
|
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
const [showWidgets, setShowWidgets] = useState(false)
|
||||||
const activityFeedRef = useRef<ActivityFeedRef>(null)
|
const activityFeedRef = useRef<ActivityFeedRef>(null)
|
||||||
|
|
||||||
// Disputes for organizers
|
// Disputes for organizers
|
||||||
@@ -663,6 +665,30 @@ export function MarathonPage() {
|
|||||||
</GlassCard>
|
</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 */}
|
{/* My stats */}
|
||||||
{marathon.my_participation && (
|
{marathon.my_participation && (
|
||||||
<GlassCard variant="neon">
|
<GlassCard variant="neon">
|
||||||
@@ -821,6 +847,13 @@ export function MarathonPage() {
|
|||||||
onClose={() => setShowSettings(false)}
|
onClose={() => setShowSettings(false)}
|
||||||
onUpdate={setMarathon}
|
onUpdate={setMarathon}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Widgets Modal */}
|
||||||
|
<WidgetSettingsModal
|
||||||
|
marathonId={marathon.id}
|
||||||
|
isOpen={showWidgets}
|
||||||
|
onClose={() => setShowWidgets(false)}
|
||||||
|
/>
|
||||||
</div>
|
</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
|
new_balance: number
|
||||||
message: string
|
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