18 Commits

Author SHA1 Message Date
7b1490dec8 Исправлена ошибка MissingGreenlet в use_skip_exile
Убрана проверка assignment.challenge, которая вызывала lazy loading
в асинхронном контексте. Теперь всегда выполняется явный запрос
для получения game_id из Challenge.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-24 10:16:06 +03:00
765da3c37f update tracjer 2026-01-21 23:29:52 +07:00
9f79daf796 Добавлена поддержка обмена играми с типом прохождения (playthrough)
- Обновлены схемы SwapCandidate и SwapRequestChallengeInfo для поддержки прохождений
- get_swap_candidates теперь возвращает и челленджи, и прохождения
- accept_swap_request теперь корректно меняет challenge_id, game_id, is_playthrough и bonus_assignments
- Обновлён UI для отображения прохождений в списке кандидатов и запросах обмена

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:56:34 +03:00
58c390c768 Исправлена ошибка MissingGreenlet при использовании Wild Card
Добавлена загрузка bonus_assignments через selectinload для wild_card,
чтобы избежать lazy loading в асинхронном контексте.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 18:54:46 +03:00
72089d1b47 Исправлены ошибки Wild Card и skip-assignment
- Wild Card: исправлен game.name → game.title
- Wild Card: добавлена поддержка игр типа playthrough
- points.py: добавлена проверка на None для challenge_points
- PlaythroughInfo: поля сделаны Optional (description, points, proof_type)
- organizer_skip_assignment: добавлен фильтр is_event_assignment

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 18:10:40 +03:00
9cfe99ff7e Добавлено tracked_time_minutes в ответы API
Время из трекера не отправлялось в API, так как AssignmentResponse
создавался вручную без этого поля. Теперь tracked_time_minutes
передаётся во всех местах создания ответа.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 11:15:20 +03:00
2d8e80f258 Исправлена зависимость миграции exiled_games
Переименована 030 -> 031, зависит от 030_merge_029_heads

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-10 23:06:39 +03:00
f78eacb1a5 Добавлен Skip with Exile, модерация марафонов и выдача предметов
## Skip with Exile (новый расходник)
- Новая модель ExiledGame для хранения изгнанных игр
- Расходник skip_exile: пропуск без штрафа + игра исключается из пула навсегда
- Фильтрация изгнанных игр при выдаче заданий
- UI кнопка в PlayPage для использования skip_exile

## Модерация марафонов (для организаторов)
- Эндпоинты: skip-assignment, exiled-games, restore-exiled-game
- UI в LeaderboardPage: кнопка скипа у каждого участника
- Выбор типа скипа (обычный/с изгнанием) + причина
- Telegram уведомления о модерации

## Админская выдача предметов
- Эндпоинты: admin grant/remove items, get user inventory
- Новая страница AdminGrantItemPage (как магазин)
- Telegram уведомление при получении подарка

## Исправления миграций
- Миграции 029/030 теперь идемпотентны (проверка существования таблиц)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-10 23:02:37 +03:00
cf0df928b1 Fix app 2026-01-10 09:22:28 +07:00
5c452c5c74 Fix migrations 2026-01-10 08:54:23 +07:00
2b6f2888ee Fix site 2026-01-10 08:48:53 +07:00
b6eecc4483 Time tracker app 2026-01-10 08:48:52 +07:00
3256c40841 Add widget preview and combined widget
- Add live preview iframe in widget settings modal
- Create combined widget (all-in-one: leaderboard + current + progress)
- Add widget type tabs for switching preview
- Update documentation with completed tasks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-09 19:55:48 +03:00
146ed5e489 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>
2026-01-09 19:16:50 +03:00
cd78a99ce7 Remove points limit 2026-01-09 19:02:08 +07:00
76de7ccbdb fix 2026-01-08 10:06:59 +07:00
e63d6c8489 Promocode system 2026-01-08 10:02:15 +07:00
1751c4dd4c rework shop 2026-01-08 08:49:51 +07:00
109 changed files with 18856 additions and 298 deletions

View File

@@ -450,13 +450,13 @@ def upgrade() -> None:
'item_type': 'consumable',
'code': 'boost',
'name': 'Буст x1.5',
'description': 'Множитель очков x1.5 на следующие 2 часа',
'description': 'Множитель очков x1.5 на текущее задание',
'price': 200,
'rarity': 'rare',
'asset_data': {
'effect': 'boost',
'multiplier': 1.5,
'duration_hours': 2,
'one_time': True,
'icon': 'zap'
},
'is_active': True,

View File

@@ -0,0 +1,46 @@
"""Update boost description to one-time usage
Revision ID: 026_update_boost_desc
Revises: 025_simplify_boost
Create Date: 2026-01-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '026_update_boost_desc'
down_revision: Union[str, None] = '025_simplify_boost'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Update boost description in shop_items table
op.execute("""
UPDATE shop_items
SET description = 'Множитель очков x1.5 на текущее задание',
asset_data = jsonb_set(
asset_data::jsonb - 'duration_hours',
'{one_time}',
'true'
)
WHERE code = 'boost' AND item_type = 'consumable'
""")
def downgrade() -> None:
# Revert boost description
op.execute("""
UPDATE shop_items
SET description = 'Множитель очков x1.5 на следующие 2 часа',
asset_data = jsonb_set(
asset_data::jsonb - 'one_time',
'{duration_hours}',
'2'
)
WHERE code = 'boost' AND item_type = 'consumable'
""")

View File

@@ -0,0 +1,83 @@
"""Consumables redesign: remove shield/reroll, add wild_card/lucky_dice/copycat/undo
Revision ID: 027_consumables_redesign
Revises: 026_update_boost_desc
Create Date: 2026-01-08
"""
from typing import Sequence, Union
from datetime import datetime
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '027_consumables_redesign'
down_revision: Union[str, None] = '026_update_boost_desc'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Remove has_shield column from participants
op.drop_column('participants', 'has_shield')
# 2. Add new columns for lucky_dice and undo
op.add_column('participants', sa.Column('has_lucky_dice', sa.Boolean(), nullable=False, server_default='false'))
op.add_column('participants', sa.Column('lucky_dice_multiplier', sa.Float(), nullable=True))
op.add_column('participants', sa.Column('last_drop_points', sa.Integer(), nullable=True))
op.add_column('participants', sa.Column('last_drop_streak_before', sa.Integer(), nullable=True))
op.add_column('participants', sa.Column('can_undo', sa.Boolean(), nullable=False, server_default='false'))
# 3. Remove old consumables from shop
op.execute("DELETE FROM shop_items WHERE code IN ('reroll', 'shield')")
# 4. Update boost price from 200 to 150
op.execute("UPDATE shop_items SET price = 150 WHERE code = 'boost'")
# 5. Add new consumables to shop
now = datetime.utcnow().isoformat()
op.execute(f"""
INSERT INTO shop_items (item_type, code, name, description, price, rarity, asset_data, is_active, created_at)
VALUES
('consumable', 'wild_card', 'Дикая карта', 'Выбери игру и получи случайное задание из неё', 150, 'uncommon',
'{{"effect": "wild_card", "icon": "shuffle"}}', true, '{now}'),
('consumable', 'lucky_dice', 'Счастливые кости', 'Случайный множитель очков (1.5x - 4.0x)', 250, 'rare',
'{{"effect": "lucky_dice", "multipliers": [1.5, 2.0, 2.5, 3.0, 3.5, 4.0], "icon": "dice"}}', true, '{now}'),
('consumable', 'copycat', 'Копикэт', 'Скопируй задание любого участника марафона', 300, 'epic',
'{{"effect": "copycat", "icon": "copy"}}', true, '{now}'),
('consumable', 'undo', 'Отмена', 'Отмени последний дроп и верни очки со стриком', 300, 'epic',
'{{"effect": "undo", "icon": "undo"}}', true, '{now}')
""")
def downgrade() -> None:
# 1. Remove new columns
op.drop_column('participants', 'can_undo')
op.drop_column('participants', 'last_drop_streak_before')
op.drop_column('participants', 'last_drop_points')
op.drop_column('participants', 'lucky_dice_multiplier')
op.drop_column('participants', 'has_lucky_dice')
# 2. Add back has_shield
op.add_column('participants', sa.Column('has_shield', sa.Boolean(), nullable=False, server_default='false'))
# 3. Remove new consumables
op.execute("DELETE FROM shop_items WHERE code IN ('wild_card', 'lucky_dice', 'copycat', 'undo')")
# 4. Restore boost price back to 200
op.execute("UPDATE shop_items SET price = 200 WHERE code = 'boost'")
# 5. Add back old consumables
now = datetime.utcnow().isoformat()
op.execute(f"""
INSERT INTO shop_items (item_type, code, name, description, price, rarity, asset_data, is_active, created_at)
VALUES
('consumable', 'shield', 'Щит', 'Защита от штрафа при следующем дропе. Streak сохраняется.', 150, 'uncommon',
'{{"effect": "shield", "icon": "shield"}}', true, '{now}'),
('consumable', 'reroll', 'Перекрут', 'Перекрутить колесо и получить новое задание', 80, 'common',
'{{"effect": "reroll", "icon": "refresh-cw"}}', true, '{now}')
""")

View File

@@ -0,0 +1,58 @@
"""Add promo codes system
Revision ID: 028_add_promo_codes
Revises: 027_consumables_redesign
Create Date: 2026-01-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '028_add_promo_codes'
down_revision: Union[str, None] = '027_consumables_redesign'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create promo_codes table
op.create_table(
'promo_codes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(50), nullable=False),
sa.Column('coins_amount', sa.Integer(), nullable=False),
sa.Column('max_uses', sa.Integer(), nullable=True),
sa.Column('uses_count', sa.Integer(), nullable=False, server_default='0'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('created_by_id', sa.Integer(), nullable=False),
sa.Column('valid_from', sa.DateTime(), nullable=True),
sa.Column('valid_until', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='CASCADE'),
)
op.create_index('ix_promo_codes_code', 'promo_codes', ['code'], unique=True)
# Create promo_code_redemptions table
op.create_table(
'promo_code_redemptions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('promo_code_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('coins_awarded', sa.Integer(), nullable=False),
sa.Column('redeemed_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['promo_code_id'], ['promo_codes.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.UniqueConstraint('promo_code_id', 'user_id', name='uq_promo_code_user'),
)
op.create_index('ix_promo_code_redemptions_user_id', 'promo_code_redemptions', ['user_id'])
def downgrade() -> None:
op.drop_table('promo_code_redemptions')
op.drop_table('promo_codes')

View File

@@ -0,0 +1,30 @@
"""Add tracked_time_minutes to assignments
Revision ID: 029_add_tracked_time
Revises: 028_add_promo_codes
Create Date: 2026-01-10
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '029_add_tracked_time'
down_revision: Union[str, None] = '028_add_promo_codes'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add tracked_time_minutes column to assignments table
op.add_column(
'assignments',
sa.Column('tracked_time_minutes', sa.Integer(), nullable=False, server_default='0')
)
def downgrade() -> None:
op.drop_column('assignments', 'tracked_time_minutes')

View File

@@ -0,0 +1,46 @@
"""Add widget tokens
Revision ID: 029
Revises: 028
Create Date: 2025-01-09
"""
from alembic import op
from sqlalchemy import inspect
import sqlalchemy as sa
revision = '029_add_widget_tokens'
down_revision = '028_add_promo_codes'
branch_labels = None
depends_on = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def upgrade():
if table_exists('widget_tokens'):
return
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')

View File

@@ -0,0 +1,28 @@
"""Merge 029 heads
Revision ID: 030_merge_029_heads
Revises: 029_add_tracked_time, 029_add_widget_tokens
Create Date: 2026-01-10
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '030_merge_029_heads'
down_revision: Union[str, Sequence[str]] = ('029_add_tracked_time', '029_add_widget_tokens')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Merge migration - no changes needed
pass
def downgrade() -> None:
# Merge migration - no changes needed
pass

View File

@@ -0,0 +1,65 @@
"""Add exiled games and skip_exile consumable
Revision ID: 030
Revises: 029
Create Date: 2025-01-10
"""
from alembic import op
from sqlalchemy import inspect
import sqlalchemy as sa
revision = '031_add_exiled_games'
down_revision = '030_merge_029_heads'
branch_labels = None
depends_on = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def upgrade():
# Create exiled_games table if not exists
if not table_exists('exiled_games'):
op.create_table(
'exiled_games',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('participant_id', sa.Integer(), nullable=False),
sa.Column('game_id', sa.Integer(), nullable=False),
sa.Column('assignment_id', sa.Integer(), nullable=True),
sa.Column('exiled_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('exiled_by', sa.String(20), nullable=False),
sa.Column('reason', sa.String(500), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('unexiled_at', sa.DateTime(), nullable=True),
sa.Column('unexiled_by', sa.String(20), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['participant_id'], ['participants.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['assignment_id'], ['assignments.id'], ondelete='SET NULL'),
sa.UniqueConstraint('participant_id', 'game_id', name='unique_participant_game_exile'),
)
op.create_index('ix_exiled_games_participant_id', 'exiled_games', ['participant_id'])
op.create_index('ix_exiled_games_active', 'exiled_games', ['participant_id', 'is_active'])
# Add skip_exile consumable to shop if not exists
op.execute("""
INSERT INTO shop_items (item_type, code, name, description, price, rarity, asset_data, is_active, created_at)
SELECT 'consumable', 'skip_exile', 'Скип с изгнанием',
'Пропустить текущее задание без штрафа. Игра навсегда исключается из вашего пула и больше не выпадет.',
150, 'rare', '{"effect": "skip_exile", "icon": "x-circle"}', true, NOW()
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE code = 'skip_exile')
""")
def downgrade():
# Remove skip_exile from shop
op.execute("DELETE FROM shop_items WHERE code = 'skip_exile'")
# Drop exiled_games table
op.drop_index('ix_exiled_games_active', table_name='exiled_games')
op.drop_index('ix_exiled_games_participant_id', table_name='exiled_games')
op.drop_table('exiled_games')

View File

@@ -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
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")
@@ -17,3 +17,5 @@ router.include_router(assignments.router)
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)

View File

@@ -366,7 +366,7 @@ async def save_challenges(
description=ch_data.description,
type=ch_type,
difficulty=difficulty,
points=max(1, min(500, ch_data.points)),
points=max(1, ch_data.points),
estimated_time=ch_data.estimated_time,
proof_type=proof_type,
proof_hint=ch_data.proof_hint,

View File

@@ -10,6 +10,7 @@ from app.models import (
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Game,
SwapRequest as SwapRequestModel, SwapRequestStatus, User,
)
from app.models.bonus_assignment import BonusAssignment
from fastapi import UploadFile, File, Form
from app.schemas import (
@@ -275,6 +276,26 @@ async def stop_event(
return MessageResponse(message="Event stopped")
def build_assignment_info(assignment: Assignment) -> SwapRequestChallengeInfo:
"""Build SwapRequestChallengeInfo from assignment (challenge or playthrough)"""
if assignment.is_playthrough:
return SwapRequestChallengeInfo(
is_playthrough=True,
playthrough_description=assignment.game.playthrough_description,
playthrough_points=assignment.game.playthrough_points,
game_title=assignment.game.title,
)
else:
return SwapRequestChallengeInfo(
is_playthrough=False,
title=assignment.challenge.title,
description=assignment.challenge.description,
points=assignment.challenge.points,
difficulty=assignment.challenge.difficulty,
game_title=assignment.challenge.game.title,
)
def build_swap_request_response(
swap_req: SwapRequestModel,
) -> SwapRequestResponse:
@@ -298,20 +319,8 @@ def build_swap_request_response(
role=swap_req.to_participant.user.role,
created_at=swap_req.to_participant.user.created_at,
),
from_challenge=SwapRequestChallengeInfo(
title=swap_req.from_assignment.challenge.title,
description=swap_req.from_assignment.challenge.description,
points=swap_req.from_assignment.challenge.points,
difficulty=swap_req.from_assignment.challenge.difficulty,
game_title=swap_req.from_assignment.challenge.game.title,
),
to_challenge=SwapRequestChallengeInfo(
title=swap_req.to_assignment.challenge.title,
description=swap_req.to_assignment.challenge.description,
points=swap_req.to_assignment.challenge.points,
difficulty=swap_req.to_assignment.challenge.difficulty,
game_title=swap_req.to_assignment.challenge.game.title,
),
from_challenge=build_assignment_info(swap_req.from_assignment),
to_challenge=build_assignment_info(swap_req.to_assignment),
created_at=swap_req.created_at,
responded_at=swap_req.responded_at,
)
@@ -349,11 +358,12 @@ async def create_swap_request(
if target.id == participant.id:
raise HTTPException(status_code=400, detail="Cannot swap with yourself")
# Get both active assignments
# Get both active assignments (with challenge.game or game for playthrough)
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
)
.where(
Assignment.participant_id == participant.id,
@@ -365,7 +375,8 @@ async def create_swap_request(
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
)
.where(
Assignment.participant_id == target.id,
@@ -417,7 +428,7 @@ async def create_swap_request(
await db.commit()
await db.refresh(swap_request)
# Load relationships for response
# Load relationships for response (including game for playthrough)
result = await db.execute(
select(SwapRequestModel)
.options(
@@ -426,9 +437,13 @@ async def create_swap_request(
selectinload(SwapRequestModel.from_assignment)
.selectinload(Assignment.challenge)
.selectinload(Challenge.game),
selectinload(SwapRequestModel.from_assignment)
.selectinload(Assignment.game),
selectinload(SwapRequestModel.to_assignment)
.selectinload(Assignment.challenge)
.selectinload(Challenge.game),
selectinload(SwapRequestModel.to_assignment)
.selectinload(Assignment.game),
)
.where(SwapRequestModel.id == swap_request.id)
)
@@ -461,9 +476,13 @@ async def get_my_swap_requests(
selectinload(SwapRequestModel.from_assignment)
.selectinload(Assignment.challenge)
.selectinload(Challenge.game),
selectinload(SwapRequestModel.from_assignment)
.selectinload(Assignment.game),
selectinload(SwapRequestModel.to_assignment)
.selectinload(Assignment.challenge)
.selectinload(Challenge.game),
selectinload(SwapRequestModel.to_assignment)
.selectinload(Assignment.game),
)
.where(
SwapRequestModel.event_id == event.id,
@@ -553,10 +572,39 @@ async def accept_swap_request(
await db.commit()
raise HTTPException(status_code=400, detail="One or both assignments are no longer active")
# Perform the swap
# Perform the swap (swap challenge_id, game_id, and is_playthrough)
from_challenge_id = from_assignment.challenge_id
from_game_id = from_assignment.game_id
from_is_playthrough = from_assignment.is_playthrough
from_assignment.challenge_id = to_assignment.challenge_id
from_assignment.game_id = to_assignment.game_id
from_assignment.is_playthrough = to_assignment.is_playthrough
to_assignment.challenge_id = from_challenge_id
to_assignment.game_id = from_game_id
to_assignment.is_playthrough = from_is_playthrough
# Swap bonus assignments between the two assignments
from sqlalchemy import update as sql_update
# Get bonus assignments for both
from_bonus_result = await db.execute(
select(BonusAssignment).where(BonusAssignment.main_assignment_id == from_assignment.id)
)
from_bonus_assignments = from_bonus_result.scalars().all()
to_bonus_result = await db.execute(
select(BonusAssignment).where(BonusAssignment.main_assignment_id == to_assignment.id)
)
to_bonus_assignments = to_bonus_result.scalars().all()
# Move bonus assignments: from -> to, to -> from
for bonus in from_bonus_assignments:
bonus.main_assignment_id = to_assignment.id
for bonus in to_bonus_assignments:
bonus.main_assignment_id = from_assignment.id
# Update request status
swap_request.status = SwapRequestStatus.ACCEPTED.value
@@ -865,8 +913,10 @@ async def get_swap_candidates(
if not event or event.type != EventType.SWAP.value:
raise HTTPException(status_code=400, detail="No active swap event")
# Get all participants except current user with active assignments
from app.models import Game
candidates = []
# Get challenge-based assignments
result = await db.execute(
select(Participant, Assignment, Challenge, Game)
.join(Assignment, Assignment.participant_id == Participant.id)
@@ -877,12 +927,11 @@ async def get_swap_candidates(
Participant.marathon_id == marathon_id,
Participant.id != participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
Assignment.is_playthrough == False,
)
)
rows = result.all()
return [
SwapCandidate(
for p, assignment, challenge, game in result.all():
candidates.append(SwapCandidate(
participant_id=p.id,
user=UserPublic(
id=p.user.id,
@@ -892,14 +941,45 @@ async def get_swap_candidates(
role=p.user.role,
created_at=p.user.created_at,
),
is_playthrough=False,
challenge_title=challenge.title,
challenge_description=challenge.description,
challenge_points=challenge.points,
challenge_difficulty=challenge.difficulty,
game_title=game.title,
))
# Get playthrough-based assignments
result = await db.execute(
select(Participant, Assignment, Game)
.join(Assignment, Assignment.participant_id == Participant.id)
.join(Game, Assignment.game_id == Game.id)
.options(selectinload(Participant.user))
.where(
Participant.marathon_id == marathon_id,
Participant.id != participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
Assignment.is_playthrough == True,
)
for p, assignment, challenge, game in rows
]
)
for p, assignment, game in result.all():
candidates.append(SwapCandidate(
participant_id=p.id,
user=UserPublic(
id=p.user.id,
login=p.user.login,
nickname=p.user.nickname,
avatar_url=None,
role=p.user.role,
created_at=p.user.created_at,
),
is_playthrough=True,
playthrough_description=game.playthrough_description,
playthrough_points=game.playthrough_points,
game_title=game.title,
))
return candidates
@router.get("/marathons/{marathon_id}/common-enemy-leaderboard", response_model=list[CommonEnemyLeaderboard])
@@ -993,6 +1073,7 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at,
completed_at=assignment.completed_at,
tracked_time_minutes=assignment.tracked_time_minutes,
)
# Regular challenge assignment
@@ -1026,6 +1107,7 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at,
completed_at=assignment.completed_at,
tracked_time_minutes=assignment.tracked_time_minutes,
)

View File

@@ -9,7 +9,8 @@ from app.api.deps import (
from app.core.config import settings
from app.models import (
Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, GameType,
Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant, User
Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant, User,
ExiledGame
)
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
from app.schemas.assignment import AvailableGamesCount
@@ -519,9 +520,23 @@ async def get_available_games_for_participant(
)
completed_challenge_ids = set(challenges_result.scalars().all())
# Получаем изгнанные игры (is_active=True означает что игра изгнана)
exiled_result = await db.execute(
select(ExiledGame.game_id)
.where(
ExiledGame.participant_id == participant.id,
ExiledGame.is_active == True,
)
)
exiled_game_ids = set(exiled_result.scalars().all())
# Фильтруем доступные игры
available_games = []
for game in games_with_content:
# Исключаем изгнанные игры
if game.id in exiled_game_ids:
continue
if game.game_type == GameType.PLAYTHROUGH.value:
# Исключаем если игра уже завершена/дропнута
if game.id not in finished_playthrough_game_ids:

View File

@@ -21,6 +21,7 @@ from app.models import (
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus, User,
ExiledGame,
)
from app.schemas import (
MarathonCreate,
@@ -35,6 +36,8 @@ from app.schemas import (
MessageResponse,
UserPublic,
SetParticipantRole,
OrganizerSkipRequest,
ExiledGameResponse,
)
from app.services.telegram_notifier import telegram_notifier
@@ -1004,3 +1007,224 @@ async def resolve_marathon_dispute(
return MessageResponse(
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
)
# ============= Moderation Endpoints =============
@router.post("/{marathon_id}/participants/{user_id}/skip-assignment", response_model=MessageResponse)
async def organizer_skip_assignment(
marathon_id: int,
user_id: int,
data: OrganizerSkipRequest,
current_user: CurrentUser,
db: DbSession,
):
"""
Organizer skips a participant's current assignment.
- No penalty for participant
- Streak is preserved
- Optionally exile the game from participant's pool
"""
await require_organizer(db, current_user, marathon_id)
# Get marathon
result = await db.execute(
select(Marathon).where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
# Get target participant
result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(
Participant.marathon_id == marathon_id,
Participant.user_id == user_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
# Get active assignment (exclude event assignments)
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game),
)
.where(
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
Assignment.is_event_assignment == False,
)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=400, detail="Participant has no active assignment")
# Get game info
if assignment.is_playthrough:
game = assignment.game
game_id = game.id
game_title = game.title
else:
game = assignment.challenge.game
game_id = game.id
game_title = game.title
# Skip the assignment (no penalty)
from datetime import datetime
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Note: We do NOT reset streak or increment drop_count
# Exile the game if requested
if data.exile:
# Check if already exiled
existing = await db.execute(
select(ExiledGame).where(
ExiledGame.participant_id == participant.id,
ExiledGame.game_id == game_id,
ExiledGame.is_active == True,
)
)
if not existing.scalar_one_or_none():
exiled = ExiledGame(
participant_id=participant.id,
game_id=game_id,
assignment_id=assignment.id,
exiled_by="organizer",
reason=data.reason,
)
db.add(exiled)
# Log activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.MODERATION.value,
data={
"action": "skip_assignment",
"target_user_id": user_id,
"target_nickname": participant.user.nickname,
"assignment_id": assignment.id,
"game_id": game_id,
"game_title": game_title,
"exile": data.exile,
"reason": data.reason,
}
)
db.add(activity)
await db.commit()
# Send notification
await telegram_notifier.notify_assignment_skipped_by_moderator(
db,
user=participant.user,
marathon_title=marathon.title,
game_title=game_title,
exiled=data.exile,
reason=data.reason,
moderator_nickname=current_user.nickname,
)
exile_msg = " and exiled from pool" if data.exile else ""
return MessageResponse(message=f"Assignment skipped{exile_msg}")
@router.get("/{marathon_id}/participants/{user_id}/exiled-games", response_model=list[ExiledGameResponse])
async def get_participant_exiled_games(
marathon_id: int,
user_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get list of exiled games for a participant (organizers only)"""
await require_organizer(db, current_user, marathon_id)
# Get participant
result = await db.execute(
select(Participant).where(
Participant.marathon_id == marathon_id,
Participant.user_id == user_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
# Get exiled games
result = await db.execute(
select(ExiledGame)
.options(selectinload(ExiledGame.game))
.where(
ExiledGame.participant_id == participant.id,
ExiledGame.is_active == True,
)
.order_by(ExiledGame.exiled_at.desc())
)
exiled_games = result.scalars().all()
return [
ExiledGameResponse(
id=eg.id,
game_id=eg.game_id,
game_title=eg.game.title,
exiled_at=eg.exiled_at,
exiled_by=eg.exiled_by,
reason=eg.reason,
)
for eg in exiled_games
]
@router.post("/{marathon_id}/participants/{user_id}/exiled-games/{game_id}/restore", response_model=MessageResponse)
async def restore_exiled_game(
marathon_id: int,
user_id: int,
game_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Restore an exiled game back to participant's pool (organizers only)"""
await require_organizer(db, current_user, marathon_id)
# Get participant
result = await db.execute(
select(Participant).where(
Participant.marathon_id == marathon_id,
Participant.user_id == user_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
# Get exiled game
result = await db.execute(
select(ExiledGame)
.options(selectinload(ExiledGame.game))
.where(
ExiledGame.participant_id == participant.id,
ExiledGame.game_id == game_id,
ExiledGame.is_active == True,
)
)
exiled_game = result.scalar_one_or_none()
if not exiled_game:
raise HTTPException(status_code=404, detail="Exiled game not found")
# Restore (soft-delete)
from datetime import datetime
exiled_game.is_active = False
exiled_game.unexiled_at = datetime.utcnow()
exiled_game.unexiled_by = "organizer"
await db.commit()
return MessageResponse(message=f"Game '{exiled_game.game.title}' restored to pool")

299
backend/app/api/v1/promo.py Normal file
View File

@@ -0,0 +1,299 @@
"""
Promo Code API endpoints - user redemption and admin management
"""
import secrets
import string
from datetime import datetime
from fastapi import APIRouter, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import CurrentUser, DbSession, require_admin_with_2fa
from app.models import User, CoinTransaction, CoinTransactionType
from app.models.promo_code import PromoCode, PromoCodeRedemption
from app.schemas.promo_code import (
PromoCodeCreate,
PromoCodeUpdate,
PromoCodeResponse,
PromoCodeRedeemRequest,
PromoCodeRedeemResponse,
PromoCodeRedemptionResponse,
PromoCodeRedemptionUser,
)
from app.schemas.common import MessageResponse
router = APIRouter(prefix="/promo", tags=["promo"])
def generate_promo_code(length: int = 8) -> str:
"""Generate a random promo code"""
chars = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(chars) for _ in range(length))
# === User endpoints ===
@router.post("/redeem", response_model=PromoCodeRedeemResponse)
async def redeem_promo_code(
data: PromoCodeRedeemRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Redeem a promo code to receive coins"""
# Find promo code
result = await db.execute(
select(PromoCode).where(PromoCode.code == data.code.upper().strip())
)
promo = result.scalar_one_or_none()
if not promo:
raise HTTPException(status_code=404, detail="Промокод не найден")
# Check if valid
if not promo.is_active:
raise HTTPException(status_code=400, detail="Промокод деактивирован")
now = datetime.utcnow()
if promo.valid_from and now < promo.valid_from:
raise HTTPException(status_code=400, detail="Промокод ещё не активен")
if promo.valid_until and now > promo.valid_until:
raise HTTPException(status_code=400, detail="Промокод истёк")
if promo.max_uses is not None and promo.uses_count >= promo.max_uses:
raise HTTPException(status_code=400, detail="Лимит использований исчерпан")
# Check if user already redeemed
result = await db.execute(
select(PromoCodeRedemption).where(
PromoCodeRedemption.promo_code_id == promo.id,
PromoCodeRedemption.user_id == current_user.id,
)
)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Вы уже использовали этот промокод")
# Create redemption record
redemption = PromoCodeRedemption(
promo_code_id=promo.id,
user_id=current_user.id,
coins_awarded=promo.coins_amount,
)
db.add(redemption)
# Update uses count
promo.uses_count += 1
# Award coins
transaction = CoinTransaction(
user_id=current_user.id,
amount=promo.coins_amount,
transaction_type=CoinTransactionType.PROMO_CODE.value,
reference_type="promo_code",
reference_id=promo.id,
description=f"Промокод: {promo.code}",
)
db.add(transaction)
current_user.coins_balance += promo.coins_amount
await db.commit()
await db.refresh(current_user)
return PromoCodeRedeemResponse(
success=True,
coins_awarded=promo.coins_amount,
new_balance=current_user.coins_balance,
message=f"Вы получили {promo.coins_amount} монет!",
)
# === Admin endpoints ===
@router.get("/admin/list", response_model=list[PromoCodeResponse])
async def admin_list_promo_codes(
current_user: CurrentUser,
db: DbSession,
include_inactive: bool = False,
):
"""Get all promo codes (admin only)"""
require_admin_with_2fa(current_user)
query = select(PromoCode).options(selectinload(PromoCode.created_by))
if not include_inactive:
query = query.where(PromoCode.is_active == True)
query = query.order_by(PromoCode.created_at.desc())
result = await db.execute(query)
promos = result.scalars().all()
return [
PromoCodeResponse(
id=p.id,
code=p.code,
coins_amount=p.coins_amount,
max_uses=p.max_uses,
uses_count=p.uses_count,
is_active=p.is_active,
valid_from=p.valid_from,
valid_until=p.valid_until,
created_at=p.created_at,
created_by_nickname=p.created_by.nickname if p.created_by else None,
)
for p in promos
]
@router.post("/admin/create", response_model=PromoCodeResponse)
async def admin_create_promo_code(
data: PromoCodeCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Create a new promo code (admin only)"""
require_admin_with_2fa(current_user)
# Generate or use provided code
code = data.code.upper().strip() if data.code else generate_promo_code()
# Check uniqueness
result = await db.execute(
select(PromoCode).where(PromoCode.code == code)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail=f"Промокод '{code}' уже существует")
promo = PromoCode(
code=code,
coins_amount=data.coins_amount,
max_uses=data.max_uses,
valid_from=data.valid_from,
valid_until=data.valid_until,
created_by_id=current_user.id,
)
db.add(promo)
await db.commit()
await db.refresh(promo)
return PromoCodeResponse(
id=promo.id,
code=promo.code,
coins_amount=promo.coins_amount,
max_uses=promo.max_uses,
uses_count=promo.uses_count,
is_active=promo.is_active,
valid_from=promo.valid_from,
valid_until=promo.valid_until,
created_at=promo.created_at,
created_by_nickname=current_user.nickname,
)
@router.put("/admin/{promo_id}", response_model=PromoCodeResponse)
async def admin_update_promo_code(
promo_id: int,
data: PromoCodeUpdate,
current_user: CurrentUser,
db: DbSession,
):
"""Update a promo code (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(
select(PromoCode)
.options(selectinload(PromoCode.created_by))
.where(PromoCode.id == promo_id)
)
promo = result.scalar_one_or_none()
if not promo:
raise HTTPException(status_code=404, detail="Промокод не найден")
if data.is_active is not None:
promo.is_active = data.is_active
if data.max_uses is not None:
promo.max_uses = data.max_uses
if data.valid_until is not None:
promo.valid_until = data.valid_until
await db.commit()
await db.refresh(promo)
return PromoCodeResponse(
id=promo.id,
code=promo.code,
coins_amount=promo.coins_amount,
max_uses=promo.max_uses,
uses_count=promo.uses_count,
is_active=promo.is_active,
valid_from=promo.valid_from,
valid_until=promo.valid_until,
created_at=promo.created_at,
created_by_nickname=promo.created_by.nickname if promo.created_by else None,
)
@router.delete("/admin/{promo_id}", response_model=MessageResponse)
async def admin_delete_promo_code(
promo_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Delete a promo code (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(
select(PromoCode).where(PromoCode.id == promo_id)
)
promo = result.scalar_one_or_none()
if not promo:
raise HTTPException(status_code=404, detail="Промокод не найден")
await db.delete(promo)
await db.commit()
return MessageResponse(message=f"Промокод '{promo.code}' удалён")
@router.get("/admin/{promo_id}/redemptions", response_model=list[PromoCodeRedemptionResponse])
async def admin_get_promo_redemptions(
promo_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get list of users who redeemed a promo code (admin only)"""
require_admin_with_2fa(current_user)
# Check promo exists
result = await db.execute(
select(PromoCode).where(PromoCode.id == promo_id)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Промокод не найден")
# Get redemptions
result = await db.execute(
select(PromoCodeRedemption)
.options(selectinload(PromoCodeRedemption.user))
.where(PromoCodeRedemption.promo_code_id == promo_id)
.order_by(PromoCodeRedemption.redeemed_at.desc())
)
redemptions = result.scalars().all()
return [
PromoCodeRedemptionResponse(
id=r.id,
user=PromoCodeRedemptionUser(
id=r.user.id,
nickname=r.user.nickname,
),
coins_awarded=r.coins_awarded,
redeemed_at=r.redeemed_at,
)
for r in redemptions
]

View File

@@ -10,7 +10,7 @@ from app.api.deps import CurrentUser, DbSession, require_participant, require_ad
from app.models import (
User, Marathon, Participant, Assignment, AssignmentStatus,
ShopItem, UserInventory, CoinTransaction, ShopItemType,
CertificationStatus,
CertificationStatus, Challenge, Game,
)
from app.schemas import (
ShopItemResponse, ShopItemCreate, ShopItemUpdate,
@@ -19,11 +19,14 @@ from app.schemas import (
EquipItemRequest, EquipItemResponse,
CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest,
CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse,
ConsumablesStatusResponse, MessageResponse,
ConsumablesStatusResponse, MessageResponse, SwapCandidate,
AdminGrantItemRequest,
)
from app.schemas.user import UserPublic
from app.services.shop import shop_service
from app.services.coins import coins_service
from app.services.consumables import consumables_service
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(prefix="/shop", tags=["shop"])
@@ -181,12 +184,23 @@ async def use_consumable(
# Get participant
participant = await require_participant(db, current_user.id, data.marathon_id)
# For skip and reroll, we need the assignment
# For some consumables, we need the assignment
assignment = None
if data.item_code in ["skip", "reroll"]:
if data.item_code in ["skip", "skip_exile", "wild_card", "copycat"]:
if not data.assignment_id:
raise HTTPException(status_code=400, detail="assignment_id is required for skip/reroll")
raise HTTPException(status_code=400, detail=f"assignment_id is required for {data.item_code}")
# For copycat and wild_card, we need bonus_assignments to properly handle playthrough
if data.item_code in ("copycat", "wild_card"):
result = await db.execute(
select(Assignment)
.options(selectinload(Assignment.bonus_assignments))
.where(
Assignment.id == data.assignment_id,
Assignment.participant_id == participant.id,
)
)
else:
result = await db.execute(
select(Assignment).where(
Assignment.id == data.assignment_id,
@@ -201,15 +215,32 @@ async def use_consumable(
if data.item_code == "skip":
effect = await consumables_service.use_skip(db, current_user, participant, marathon, assignment)
effect_description = "Assignment skipped without penalty"
elif data.item_code == "shield":
effect = await consumables_service.use_shield(db, current_user, participant, marathon)
effect_description = "Shield activated - next drop will be free"
elif data.item_code == "skip_exile":
effect = await consumables_service.use_skip_exile(db, current_user, participant, marathon, assignment)
effect_description = "Assignment skipped, game exiled from pool"
elif data.item_code == "boost":
effect = await consumables_service.use_boost(db, current_user, participant, marathon)
effect_description = f"Boost x{effect['multiplier']} activated for next complete"
elif data.item_code == "reroll":
effect = await consumables_service.use_reroll(db, current_user, participant, marathon, assignment)
effect_description = "Assignment rerolled - you can spin again"
effect_description = f"Boost x{effect['multiplier']} activated for current assignment"
elif data.item_code == "wild_card":
if data.game_id is None:
raise HTTPException(status_code=400, detail="game_id is required for wild_card")
effect = await consumables_service.use_wild_card(
db, current_user, participant, marathon, assignment, data.game_id
)
effect_description = f"New challenge from {effect['game_name']}: {effect['challenge_title']}"
elif data.item_code == "lucky_dice":
effect = await consumables_service.use_lucky_dice(db, current_user, participant, marathon)
effect_description = f"Lucky Dice rolled: x{effect['multiplier']} multiplier"
elif data.item_code == "copycat":
if data.target_participant_id is None:
raise HTTPException(status_code=400, detail="target_participant_id is required for copycat")
effect = await consumables_service.use_copycat(
db, current_user, participant, marathon, assignment, data.target_participant_id
)
effect_description = f"Copied challenge: {effect['challenge_title']}"
elif data.item_code == "undo":
effect = await consumables_service.use_undo(db, current_user, participant, marathon)
effect_description = f"Restored {effect['points_restored']} points and streak {effect['streak_restored']}"
else:
raise HTTPException(status_code=400, detail=f"Unknown consumable: {data.item_code}")
@@ -243,9 +274,12 @@ async def get_consumables_status(
# Get inventory counts for all consumables
skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip")
shields_available = await consumables_service.get_consumable_count(db, current_user.id, "shield")
skip_exiles_available = await consumables_service.get_consumable_count(db, current_user.id, "skip_exile")
boosts_available = await consumables_service.get_consumable_count(db, current_user.id, "boost")
rerolls_available = await consumables_service.get_consumable_count(db, current_user.id, "reroll")
wild_cards_available = await consumables_service.get_consumable_count(db, current_user.id, "wild_card")
lucky_dice_available = await consumables_service.get_consumable_count(db, current_user.id, "lucky_dice")
copycats_available = await consumables_service.get_consumable_count(db, current_user.id, "copycat")
undos_available = await consumables_service.get_consumable_count(db, current_user.id, "undo")
# Calculate remaining skips for this marathon
skips_remaining = None
@@ -254,17 +288,107 @@ async def get_consumables_status(
return ConsumablesStatusResponse(
skips_available=skips_available,
skip_exiles_available=skip_exiles_available,
skips_used=participant.skips_used,
skips_remaining=skips_remaining,
shields_available=shields_available,
has_shield=participant.has_shield,
boosts_available=boosts_available,
has_active_boost=participant.has_active_boost,
boost_multiplier=consumables_service.BOOST_MULTIPLIER if participant.has_active_boost else None,
rerolls_available=rerolls_available,
wild_cards_available=wild_cards_available,
lucky_dice_available=lucky_dice_available,
has_lucky_dice=participant.has_lucky_dice,
lucky_dice_multiplier=participant.lucky_dice_multiplier,
copycats_available=copycats_available,
undos_available=undos_available,
can_undo=participant.can_undo,
)
@router.get("/copycat-candidates/{marathon_id}", response_model=list[SwapCandidate])
async def get_copycat_candidates(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get participants with active assignments available for copycat (no event required)"""
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
participant = await require_participant(db, current_user.id, marathon_id)
# Get all participants except current user with active assignments
# Support both challenge assignments and playthrough assignments
result = await db.execute(
select(Participant, Assignment, Challenge, Game)
.join(Assignment, Assignment.participant_id == Participant.id)
.outerjoin(Challenge, Assignment.challenge_id == Challenge.id)
.outerjoin(Game, Challenge.game_id == Game.id)
.options(selectinload(Participant.user))
.where(
Participant.marathon_id == marathon_id,
Participant.id != participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
)
)
rows = result.all()
candidates = []
for p, assignment, challenge, game in rows:
# For playthrough assignments, challenge is None
if assignment.is_playthrough:
# Need to get game info for playthrough
game_result = await db.execute(
select(Game).where(Game.id == assignment.game_id)
)
playthrough_game = game_result.scalar_one_or_none()
if playthrough_game:
candidates.append(SwapCandidate(
participant_id=p.id,
user=UserPublic(
id=p.user.id,
nickname=p.user.nickname,
avatar_url=p.user.avatar_url,
role=p.user.role,
telegram_avatar_url=p.user.telegram_avatar_url,
created_at=p.user.created_at,
equipped_frame=None,
equipped_title=None,
equipped_name_color=None,
equipped_background=None,
),
challenge_title=f"Прохождение: {playthrough_game.title}",
challenge_description=playthrough_game.playthrough_description or "Прохождение игры",
challenge_points=playthrough_game.playthrough_points or 0,
challenge_difficulty="medium",
game_title=playthrough_game.title,
))
elif challenge and game:
candidates.append(SwapCandidate(
participant_id=p.id,
user=UserPublic(
id=p.user.id,
nickname=p.user.nickname,
avatar_url=p.user.avatar_url,
role=p.user.role,
telegram_avatar_url=p.user.telegram_avatar_url,
created_at=p.user.created_at,
equipped_frame=None,
equipped_title=None,
equipped_name_color=None,
equipped_background=None,
),
challenge_title=challenge.title,
challenge_description=challenge.description,
challenge_points=challenge.points,
challenge_difficulty=challenge.difficulty,
game_title=game.title,
))
return candidates
# === Coins ===
@router.get("/balance", response_model=CoinsBalanceResponse)
@@ -632,3 +756,149 @@ async def admin_review_certification(
certified_by_nickname=current_user.nickname if data.approve else None,
rejection_reason=marathon.certification_rejection_reason,
)
# === Admin Item Granting ===
@router.post("/admin/users/{user_id}/items/grant", response_model=MessageResponse)
async def admin_grant_item(
user_id: int,
data: AdminGrantItemRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Grant an item to a user (admin only)"""
require_admin_with_2fa(current_user)
# Get target user
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get item
item = await shop_service.get_item_by_id(db, data.item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
# Check if user already has this item in inventory
result = await db.execute(
select(UserInventory).where(
UserInventory.user_id == user_id,
UserInventory.item_id == data.item_id,
)
)
existing = result.scalar_one_or_none()
if existing:
# Add to quantity
existing.quantity += data.quantity
else:
# Create new inventory item
inventory_item = UserInventory(
user_id=user_id,
item_id=data.item_id,
quantity=data.quantity,
)
db.add(inventory_item)
# Log the action (using coin transaction as audit log)
transaction = CoinTransaction(
user_id=user_id,
amount=0,
transaction_type="admin_grant_item",
description=f"Admin granted {item.name} x{data.quantity}: {data.reason}",
reference_type="admin_action",
reference_id=current_user.id,
)
db.add(transaction)
await db.commit()
# Send Telegram notification
await telegram_notifier.notify_item_granted(
user=user,
item_name=item.name,
quantity=data.quantity,
reason=data.reason,
admin_nickname=current_user.nickname,
)
return MessageResponse(message=f"Granted {item.name} x{data.quantity} to {user.nickname}")
@router.get("/admin/users/{user_id}/inventory", response_model=list[InventoryItemResponse])
async def admin_get_user_inventory(
user_id: int,
current_user: CurrentUser,
db: DbSession,
item_type: str | None = None,
):
"""Get a user's inventory (admin only)"""
require_admin_with_2fa(current_user)
# Check user exists
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
inventory = await shop_service.get_user_inventory(db, user_id, item_type)
return [InventoryItemResponse.model_validate(inv) for inv in inventory]
@router.delete("/admin/users/{user_id}/inventory/{inventory_id}", response_model=MessageResponse)
async def admin_remove_inventory_item(
user_id: int,
inventory_id: int,
current_user: CurrentUser,
db: DbSession,
quantity: int = 1,
):
"""Remove an item from user's inventory (admin only)"""
require_admin_with_2fa(current_user)
# Check user exists
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get inventory item
result = await db.execute(
select(UserInventory)
.options(selectinload(UserInventory.item))
.where(
UserInventory.id == inventory_id,
UserInventory.user_id == user_id,
)
)
inv = result.scalar_one_or_none()
if not inv:
raise HTTPException(status_code=404, detail="Inventory item not found")
item_name = inv.item.name
if quantity >= inv.quantity:
# Remove entirely
await db.delete(inv)
removed_qty = inv.quantity
else:
# Reduce quantity
inv.quantity -= quantity
removed_qty = quantity
# Log the action
transaction = CoinTransaction(
user_id=user_id,
amount=0,
transaction_type="admin_remove_item",
description=f"Admin removed {item_name} x{removed_qty}",
reference_type="admin_action",
reference_id=current_user.id,
)
db.add(transaction)
await db.commit()
return MessageResponse(message=f"Removed {item_name} x{removed_qty} from {user.nickname}")

View File

@@ -15,6 +15,7 @@ from app.models import (
from app.schemas import (
SpinResult, AssignmentResponse, CompleteResult, DropResult,
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse,
TrackTimeRequest,
)
from app.schemas.game import PlaythroughInfo
from app.services.points import PointsService
@@ -441,6 +442,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
drop_penalty=drop_penalty,
bonus_challenges=bonus_responses,
event_type=assignment.event_type,
tracked_time_minutes=assignment.tracked_time_minutes,
)
# Regular challenge assignment
@@ -476,6 +478,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
completed_at=assignment.completed_at,
drop_penalty=drop_penalty,
event_type=assignment.event_type,
tracked_time_minutes=assignment.tracked_time_minutes,
)
@@ -589,6 +592,13 @@ async def complete_assignment(
if assignment.is_playthrough:
game = assignment.game
marathon_id = game.marathon_id
# If tracked time exists (from desktop app), calculate points as hours * 30
# Otherwise use admin-set playthrough_points
if assignment.tracked_time_minutes > 0:
hours = assignment.tracked_time_minutes / 60
base_playthrough_points = int(hours * 30)
else:
base_playthrough_points = game.playthrough_points
# Calculate BASE bonus points from completed bonus assignments (before multiplier)
@@ -621,10 +631,12 @@ async def complete_assignment(
if ba.status == BonusAssignmentStatus.COMPLETED.value:
ba.points_earned = int(ba.challenge.points * multiplier)
# Apply boost multiplier from consumable
# Apply boost and lucky dice multipliers from consumables
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
if boost_multiplier > 1.0:
total_points = int(total_points * boost_multiplier)
lucky_dice_multiplier = consumables_service.consume_lucky_dice_on_complete(participant)
combined_multiplier = boost_multiplier * lucky_dice_multiplier
if combined_multiplier != 1.0:
total_points = int(total_points * combined_multiplier)
# Update assignment
assignment.status = AssignmentStatus.COMPLETED.value
@@ -666,6 +678,8 @@ async def complete_assignment(
activity_data["is_redo"] = True
if boost_multiplier > 1.0:
activity_data["boost_multiplier"] = boost_multiplier
if lucky_dice_multiplier != 1.0:
activity_data["lucky_dice_multiplier"] = lucky_dice_multiplier
if coins_earned > 0:
activity_data["coins_earned"] = coins_earned
if playthrough_event:
@@ -728,10 +742,12 @@ async def complete_assignment(
total_points += common_enemy_bonus
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
# Apply boost multiplier from consumable
# Apply boost and lucky dice multipliers from consumables
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
if boost_multiplier > 1.0:
total_points = int(total_points * boost_multiplier)
lucky_dice_multiplier = consumables_service.consume_lucky_dice_on_complete(participant)
combined_multiplier = boost_multiplier * lucky_dice_multiplier
if combined_multiplier != 1.0:
total_points = int(total_points * combined_multiplier)
# Update assignment
assignment.status = AssignmentStatus.COMPLETED.value
@@ -772,6 +788,8 @@ async def complete_assignment(
activity_data["is_redo"] = True
if boost_multiplier > 1.0:
activity_data["boost_multiplier"] = boost_multiplier
if lucky_dice_multiplier != 1.0:
activity_data["lucky_dice_multiplier"] = lucky_dice_multiplier
if coins_earned > 0:
activity_data["coins_earned"] = coins_earned
if assignment.event_type == EventType.JACKPOT.value:
@@ -842,6 +860,37 @@ async def complete_assignment(
)
@router.patch("/assignments/{assignment_id}/track-time", response_model=MessageResponse)
async def track_assignment_time(
assignment_id: int,
data: TrackTimeRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Update tracked time for an assignment (from desktop app)"""
result = await db.execute(
select(Assignment)
.options(selectinload(Assignment.participant))
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
if assignment.participant.user_id != current_user.id:
raise HTTPException(status_code=403, detail="This is not your assignment")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Assignment is not active")
# Update tracked time (replace with new value)
assignment.tracked_time_minutes = max(0, data.minutes)
await db.commit()
return MessageResponse(message=f"Tracked time updated to {data.minutes} minutes")
@router.post("/assignments/{assignment_id}/drop", response_model=DropResult)
async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession):
"""Drop current assignment"""
@@ -887,11 +936,10 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
participant.drop_count, game.playthrough_points, playthrough_event
)
# Check for shield - if active, no penalty
shield_used = False
if consumables_service.consume_shield(participant):
penalty = 0
shield_used = True
# Save drop data for potential undo
consumables_service.save_drop_for_undo(
participant, penalty, participant.current_streak
)
# Update assignment
assignment.status = AssignmentStatus.DROPPED.value
@@ -921,8 +969,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
"penalty": penalty,
"lost_bonuses": completed_bonuses_count,
}
if shield_used:
activity_data["shield_used"] = True
if playthrough_event:
activity_data["event_type"] = playthrough_event.type
activity_data["free_drop"] = True
@@ -941,7 +987,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
penalty=penalty,
total_points=participant.total_points,
new_drop_count=participant.drop_count,
shield_used=shield_used,
)
# Regular challenge drop
@@ -953,11 +998,10 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
# Calculate penalty (0 if double_risk event is active)
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
# Check for shield - if active, no penalty
shield_used = False
if consumables_service.consume_shield(participant):
penalty = 0
shield_used = True
# Save drop data for potential undo
consumables_service.save_drop_for_undo(
participant, penalty, participant.current_streak
)
# Update assignment
assignment.status = AssignmentStatus.DROPPED.value
@@ -975,8 +1019,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
"difficulty": assignment.challenge.difficulty,
"penalty": penalty,
}
if shield_used:
activity_data["shield_used"] = True
if active_event:
activity_data["event_type"] = active_event.type
if active_event.type == EventType.DOUBLE_RISK.value:
@@ -996,7 +1038,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
penalty=penalty,
total_points=participant.total_points,
new_drop_count=participant.drop_count,
shield_used=shield_used,
)
@@ -1076,6 +1117,7 @@ async def get_my_history(
started_at=a.started_at,
completed_at=a.completed_at,
bonus_challenges=bonus_responses,
tracked_time_minutes=a.tracked_time_minutes,
))
else:
# Regular challenge assignment
@@ -1108,6 +1150,7 @@ async def get_my_history(
streak_at_completion=a.streak_at_completion,
started_at=a.started_at,
completed_at=a.completed_at,
tracked_time_minutes=a.tracked_time_minutes,
))
return responses

View 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,
)

View File

@@ -60,7 +60,12 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_origins=[
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:5173", # Desktop app dev
"http://127.0.0.1:5173",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],

View File

@@ -17,6 +17,9 @@ from app.models.shop import ShopItem, ShopItemType, ItemRarity, ConsumableType
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
from app.models.exiled_game import ExiledGame
__all__ = [
"User",
@@ -62,4 +65,8 @@ __all__ = [
"CoinTransaction",
"CoinTransactionType",
"ConsumableUsage",
"PromoCode",
"PromoCodeRedemption",
"WidgetToken",
"ExiledGame",
]

View File

@@ -20,6 +20,7 @@ class ActivityType(str, Enum):
EVENT_END = "event_end"
SWAP = "swap"
GAME_CHOICE = "game_choice"
MODERATION = "moderation"
class Activity(Base):

View File

@@ -32,6 +32,7 @@ class Assignment(Base):
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
points_earned: Mapped[int] = mapped_column(Integer, default=0)
streak_at_completion: Mapped[int | None] = mapped_column(Integer, nullable=True)
tracked_time_minutes: Mapped[int] = mapped_column(Integer, default=0) # Time tracked by desktop app
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

View File

@@ -20,6 +20,7 @@ class CoinTransactionType(str, Enum):
REFUND = "refund"
ADMIN_GRANT = "admin_grant"
ADMIN_DEDUCT = "admin_deduct"
PROMO_CODE = "promo_code"
class CoinTransaction(Base):

View File

@@ -0,0 +1,37 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Boolean, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class ExiledGame(Base):
"""Изгнанные игры участника - не будут выпадать при спине"""
__tablename__ = "exiled_games"
__table_args__ = (
UniqueConstraint("participant_id", "game_id", name="unique_participant_game_exile"),
)
id: Mapped[int] = mapped_column(primary_key=True)
participant_id: Mapped[int] = mapped_column(
Integer, ForeignKey("participants.id", ondelete="CASCADE"), index=True
)
game_id: Mapped[int] = mapped_column(
Integer, ForeignKey("games.id", ondelete="CASCADE")
)
assignment_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("assignments.id", ondelete="SET NULL"), nullable=True
)
exiled_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
exiled_by: Mapped[str] = mapped_column(String(20)) # "user" | "organizer" | "admin"
reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Soft-delete для истории
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
unexiled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
unexiled_by: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Relationships
participant: Mapped["Participant"] = relationship("Participant")
game: Mapped["Game"] = relationship("Game")
assignment: Mapped["Assignment"] = relationship("Assignment")

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean, Float
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -32,7 +32,15 @@ class Participant(Base):
# Shop: consumables state
skips_used: Mapped[int] = mapped_column(Integer, default=0)
has_active_boost: Mapped[bool] = mapped_column(Boolean, default=False)
has_shield: Mapped[bool] = mapped_column(Boolean, default=False)
# Lucky Dice state
has_lucky_dice: Mapped[bool] = mapped_column(Boolean, default=False)
lucky_dice_multiplier: Mapped[float | None] = mapped_column(Float, nullable=True)
# Undo state - stores last drop data for potential rollback
last_drop_points: Mapped[int | None] = mapped_column(Integer, nullable=True)
last_drop_streak_before: Mapped[int | None] = mapped_column(Integer, nullable=True)
can_undo: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships
user: Mapped["User"] = relationship("User", back_populates="participations")

View File

@@ -0,0 +1,67 @@
"""
Promo Code models for coins distribution
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Boolean, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class PromoCode(Base):
"""Promo code for giving coins to users"""
__tablename__ = "promo_codes"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
coins_amount: Mapped[int] = mapped_column(Integer, nullable=False)
max_uses: Mapped[int | None] = mapped_column(Integer, nullable=True) # None = unlimited
uses_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
valid_from: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
valid_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), nullable=False)
# Relationships
created_by: Mapped["User"] = relationship("User", foreign_keys=[created_by_id])
redemptions: Mapped[list["PromoCodeRedemption"]] = relationship(
"PromoCodeRedemption", back_populates="promo_code", cascade="all, delete-orphan"
)
def is_valid(self) -> bool:
"""Check if promo code is currently valid"""
if not self.is_active:
return False
now = datetime.utcnow()
if self.valid_from and now < self.valid_from:
return False
if self.valid_until and now > self.valid_until:
return False
if self.max_uses is not None and self.uses_count >= self.max_uses:
return False
return True
class PromoCodeRedemption(Base):
"""Record of promo code redemption by a user"""
__tablename__ = "promo_code_redemptions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
promo_code_id: Mapped[int] = mapped_column(ForeignKey("promo_codes.id", ondelete="CASCADE"), nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
coins_awarded: Mapped[int] = mapped_column(Integer, nullable=False)
redeemed_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), nullable=False)
__table_args__ = (
UniqueConstraint('promo_code_id', 'user_id', name='uq_promo_code_user'),
)
# Relationships
promo_code: Mapped["PromoCode"] = relationship("PromoCode", back_populates="redemptions")
user: Mapped["User"] = relationship("User")

View File

@@ -28,9 +28,12 @@ class ItemRarity(str, Enum):
class ConsumableType(str, Enum):
SKIP = "skip"
SHIELD = "shield"
SKIP_EXILE = "skip_exile" # Скип с изгнанием игры из пула
BOOST = "boost"
REROLL = "reroll"
WILD_CARD = "wild_card"
LUCKY_DICE = "lucky_dice"
COPYCAT = "copycat"
UNDO = "undo"
class ShopItem(Base):

View 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")

View File

@@ -23,6 +23,8 @@ from app.schemas.marathon import (
JoinMarathon,
LeaderboardEntry,
SetParticipantRole,
OrganizerSkipRequest,
ExiledGameResponse,
)
from app.schemas.game import (
GameCreate,
@@ -52,6 +54,7 @@ from app.schemas.assignment import (
CompleteBonusAssignment,
BonusCompleteResult,
AvailableGamesCount,
TrackTimeRequest,
)
from app.schemas.activity import (
ActivityResponse,
@@ -123,8 +126,27 @@ from app.schemas.shop import (
CertificationReviewRequest,
CertificationStatusResponse,
ConsumablesStatusResponse,
AdminGrantItemRequest,
)
from app.schemas.promo_code import (
PromoCodeCreate,
PromoCodeUpdate,
PromoCodeResponse,
PromoCodeRedeemRequest,
PromoCodeRedeemResponse,
PromoCodeRedemptionResponse,
PromoCodeRedemptionUser,
)
from app.schemas.user import ShopItemPublic
from app.schemas.widget import (
WidgetTokenCreate,
WidgetTokenResponse,
WidgetTokenListItem,
WidgetLeaderboardEntry,
WidgetLeaderboardResponse,
WidgetCurrentResponse,
WidgetProgressResponse,
)
__all__ = [
# User
@@ -151,6 +173,8 @@ __all__ = [
"JoinMarathon",
"LeaderboardEntry",
"SetParticipantRole",
"OrganizerSkipRequest",
"ExiledGameResponse",
# Game
"GameCreate",
"GameUpdate",
@@ -243,4 +267,21 @@ __all__ = [
"CertificationReviewRequest",
"CertificationStatusResponse",
"ConsumablesStatusResponse",
"AdminGrantItemRequest",
# Promo
"PromoCodeCreate",
"PromoCodeUpdate",
"PromoCodeResponse",
"PromoCodeRedeemRequest",
"PromoCodeRedeemResponse",
"PromoCodeRedemptionResponse",
"PromoCodeRedemptionUser",
# Widget
"WidgetTokenCreate",
"WidgetTokenResponse",
"WidgetTokenListItem",
"WidgetLeaderboardEntry",
"WidgetLeaderboardResponse",
"WidgetCurrentResponse",
"WidgetProgressResponse",
]

View File

@@ -52,6 +52,7 @@ class AssignmentResponse(BaseModel):
proof_comment: str | None = None
points_earned: int
streak_at_completion: int | None = None
tracked_time_minutes: int = 0 # Time tracked by desktop app
started_at: datetime
completed_at: datetime | None = None
drop_penalty: int = 0 # Calculated penalty if dropped
@@ -62,6 +63,11 @@ class AssignmentResponse(BaseModel):
from_attributes = True
class TrackTimeRequest(BaseModel):
"""Request to update tracked time for an assignment"""
minutes: int # Total minutes tracked (replaces previous value)
class SpinResult(BaseModel):
assignment_id: int
game: GameResponse
@@ -86,7 +92,6 @@ class DropResult(BaseModel):
penalty: int
total_points: int
new_drop_count: int
shield_used: bool = False # Whether shield consumable was used to prevent penalty
class EventAssignmentResponse(BaseModel):

View File

@@ -19,7 +19,7 @@ class ChallengeBase(BaseModel):
description: str = Field(..., min_length=1)
type: ChallengeType
difficulty: Difficulty
points: int = Field(..., ge=1, le=1000)
points: int = Field(..., ge=1)
estimated_time: int | None = Field(None, ge=1) # minutes
proof_type: ProofType
proof_hint: str | None = None
@@ -34,7 +34,7 @@ class ChallengeUpdate(BaseModel):
description: str | None = None
type: ChallengeType | None = None
difficulty: Difficulty | None = None
points: int | None = Field(None, ge=1, le=1000)
points: int | None = Field(None, ge=1)
estimated_time: int | None = None
proof_type: ProofType | None = None
proof_hint: str | None = None

View File

@@ -128,10 +128,16 @@ class SwapCandidate(BaseModel):
"""Participant available for assignment swap"""
participant_id: int
user: UserPublic
challenge_title: str
challenge_description: str
challenge_points: int
challenge_difficulty: str
is_playthrough: bool = False
# Challenge fields (used when is_playthrough=False)
challenge_title: str | None = None
challenge_description: str | None = None
challenge_points: int | None = None
challenge_difficulty: str | None = None
# Playthrough fields (used when is_playthrough=True)
playthrough_description: str | None = None
playthrough_points: int | None = None
# Common field
game_title: str
@@ -145,11 +151,17 @@ class SwapRequestCreate(BaseModel):
class SwapRequestChallengeInfo(BaseModel):
"""Challenge info for swap request display"""
title: str
description: str
points: int
difficulty: str
"""Challenge or playthrough info for swap request display"""
is_playthrough: bool = False
# Challenge fields (used when is_playthrough=False)
title: str | None = None
description: str | None = None
points: int | None = None
difficulty: str | None = None
# Playthrough fields (used when is_playthrough=True)
playthrough_description: str | None = None
playthrough_points: int | None = None
# Common field
game_title: str

View File

@@ -20,7 +20,7 @@ class GameCreate(GameBase):
game_type: GameType = GameType.CHALLENGES
# Поля для типа "Прохождение"
playthrough_points: int | None = Field(None, ge=1, le=1000)
playthrough_points: int | None = Field(None, ge=1)
playthrough_description: str | None = None
playthrough_proof_type: ProofType | None = None
playthrough_proof_hint: str | None = None
@@ -46,7 +46,7 @@ class GameUpdate(BaseModel):
game_type: GameType | None = None
# Поля для типа "Прохождение"
playthrough_points: int | None = Field(None, ge=1, le=1000)
playthrough_points: int | None = Field(None, ge=1)
playthrough_description: str | None = None
playthrough_proof_type: ProofType | None = None
playthrough_proof_hint: str | None = None
@@ -87,7 +87,7 @@ class GameResponse(GameBase):
class PlaythroughInfo(BaseModel):
"""Информация о прохождении для игр типа playthrough"""
description: str
points: int
proof_type: str
description: str | None = None
points: int | None = None
proof_type: str | None = None
proof_hint: str | None = None

View File

@@ -43,10 +43,10 @@ class ParticipantInfo(BaseModel):
# Shop: coins and consumables status
coins_earned: int = 0
skips_used: int = 0
has_shield: bool = False
has_active_boost: bool = False
boost_multiplier: float | None = None
boost_expires_at: datetime | None = None
has_lucky_dice: bool = False
lucky_dice_multiplier: float | None = None
can_undo: bool = False
class Config:
from_attributes = True
@@ -128,3 +128,23 @@ class LeaderboardEntry(BaseModel):
current_streak: int
completed_count: int
dropped_count: int
# Moderation schemas
class OrganizerSkipRequest(BaseModel):
"""Request to skip a participant's assignment by organizer"""
exile: bool = False # If true, also exile the game from participant's pool
reason: str | None = None
class ExiledGameResponse(BaseModel):
"""Exiled game info"""
id: int
game_id: int
game_title: str
exiled_at: datetime
exiled_by: str # "user" | "organizer" | "admin"
reason: str | None
class Config:
from_attributes = True

View File

@@ -0,0 +1,74 @@
"""
Promo Code schemas
"""
from datetime import datetime
from pydantic import BaseModel, Field
# === Create/Update ===
class PromoCodeCreate(BaseModel):
"""Schema for creating a promo code"""
code: str | None = Field(None, min_length=3, max_length=50) # None = auto-generate
coins_amount: int = Field(..., ge=1, le=100000)
max_uses: int | None = Field(None, ge=1) # None = unlimited
valid_from: datetime | None = None
valid_until: datetime | None = None
class PromoCodeUpdate(BaseModel):
"""Schema for updating a promo code"""
is_active: bool | None = None
max_uses: int | None = None
valid_until: datetime | None = None
# === Response ===
class PromoCodeResponse(BaseModel):
"""Schema for promo code in responses"""
id: int
code: str
coins_amount: int
max_uses: int | None
uses_count: int
is_active: bool
valid_from: datetime | None
valid_until: datetime | None
created_at: datetime
created_by_nickname: str | None = None
class Config:
from_attributes = True
class PromoCodeRedemptionUser(BaseModel):
"""User info for redemption"""
id: int
nickname: str
class PromoCodeRedemptionResponse(BaseModel):
"""Schema for redemption record"""
id: int
user: PromoCodeRedemptionUser
coins_awarded: int
redeemed_at: datetime
class Config:
from_attributes = True
# === Redeem ===
class PromoCodeRedeemRequest(BaseModel):
"""Schema for redeeming a promo code"""
code: str = Field(..., min_length=1, max_length=50)
class PromoCodeRedeemResponse(BaseModel):
"""Schema for redeem response"""
success: bool
coins_awarded: int
new_balance: int
message: str

View File

@@ -94,9 +94,11 @@ class PurchaseResponse(BaseModel):
class UseConsumableRequest(BaseModel):
"""Schema for using a consumable"""
item_code: str # 'skip', 'shield', 'boost', 'reroll'
item_code: str # 'skip', 'boost', 'wild_card', 'lucky_dice', 'copycat', 'undo'
marathon_id: int
assignment_id: int | None = None # Required for skip and reroll
assignment_id: int | None = None # Required for skip, wild_card, copycat
game_id: int | None = None # Required for wild_card
target_participant_id: int | None = None # Required for copycat
class UseConsumableResponse(BaseModel):
@@ -190,11 +192,25 @@ class CertificationStatusResponse(BaseModel):
class ConsumablesStatusResponse(BaseModel):
"""Schema for participant's consumables status in a marathon"""
skips_available: int # From inventory
skip_exiles_available: int = 0 # From inventory (skip with exile)
skips_used: int # In this marathon
skips_remaining: int | None # Based on marathon limit
shields_available: int # From inventory
has_shield: bool # Currently activated
boosts_available: int # From inventory
has_active_boost: bool # Currently activated (one-time for next complete)
has_active_boost: bool # Currently activated (one-time for current assignment)
boost_multiplier: float | None # 1.5 if boost active
rerolls_available: int # From inventory
wild_cards_available: int # From inventory
lucky_dice_available: int # From inventory
has_lucky_dice: bool # Currently activated
lucky_dice_multiplier: float | None # Rolled multiplier if active
copycats_available: int # From inventory
undos_available: int # From inventory
can_undo: bool # Has drop data to undo
# === Admin Item Granting ===
class AdminGrantItemRequest(BaseModel):
"""Schema for admin granting item to user"""
item_id: int
quantity: int = Field(default=1, ge=1, le=100)
reason: str = Field(..., min_length=1, max_length=500)

View 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

View File

@@ -14,19 +14,19 @@ class CoinsService:
# Coins awarded per challenge difficulty (only in certified marathons)
CHALLENGE_COINS = {
Difficulty.EASY.value: 5,
Difficulty.MEDIUM.value: 12,
Difficulty.HARD.value: 25,
Difficulty.EASY.value: 10,
Difficulty.MEDIUM.value: 20,
Difficulty.HARD.value: 35,
}
# Coins for playthrough = points * this ratio
PLAYTHROUGH_COIN_RATIO = 0.05 # 5% of points
PLAYTHROUGH_COIN_RATIO = 0.10 # 10% of points
# Coins awarded for marathon placements
MARATHON_PLACE_COINS = {
1: 100, # 1st place
2: 50, # 2nd place
3: 30, # 3rd place
1: 500, # 1st place
2: 250, # 2nd place
3: 150, # 3rd place
}
# Bonus coins for Common Enemy event winners

View File

@@ -1,15 +1,26 @@
"""
Consumables Service - handles consumable items usage (skip, shield, boost, reroll)
Consumables Service - handles consumable items usage
Consumables:
- skip: Skip current assignment without penalty
- skip_exile: Skip + permanently exile game from pool
- boost: x1.5 multiplier for current assignment
- wild_card: Choose a game, get random challenge from it
- lucky_dice: Random multiplier (0.5, 1.0, 1.5, 2.0, 2.5, 3.0)
- copycat: Copy another participant's assignment
- undo: Restore points and streak from last drop
"""
import random
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import (
User, Participant, Marathon, Assignment, AssignmentStatus,
ShopItem, UserInventory, ConsumableUsage, ConsumableType
ShopItem, UserInventory, ConsumableUsage, ConsumableType, Game, Challenge,
BonusAssignment, ExiledGame, GameType
)
@@ -19,6 +30,9 @@ class ConsumablesService:
# Boost settings
BOOST_MULTIPLIER = 1.5
# Lucky Dice multipliers (equal probability, starts from 1.5x)
LUCKY_DICE_MULTIPLIERS = [1.5, 2.0, 2.5, 3.0, 3.5, 4.0]
async def use_skip(
self,
db: AsyncSession,
@@ -85,51 +99,104 @@ class ConsumablesService:
"streak_preserved": True,
}
async def use_shield(
async def use_skip_exile(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
assignment: Assignment,
) -> dict:
"""
Activate a Shield - protects from next drop penalty.
Use Skip with Exile - skip assignment AND permanently exile game from pool.
- Next drop will not cause point penalty
- Streak is preserved on next drop
- No streak loss
- No drop penalty
- Game is permanently excluded from participant's pool
Returns: dict with result info
Raises:
HTTPException: If consumables not allowed or shield already active
HTTPException: If skips not allowed or limit reached
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
# Check marathon settings (same as regular skip)
if not marathon.allow_skips:
raise HTTPException(status_code=400, detail="Skips are not allowed in this marathon")
if participant.has_shield:
raise HTTPException(status_code=400, detail="Shield is already active")
if marathon.max_skips_per_participant is not None:
if participant.skips_used >= marathon.max_skips_per_participant:
raise HTTPException(
status_code=400,
detail=f"Skip limit reached ({marathon.max_skips_per_participant} per participant)"
)
# Consume shield from inventory
item = await self._consume_item(db, user, ConsumableType.SHIELD.value)
# Check assignment is active
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Can only skip active assignments")
# Activate shield
participant.has_shield = True
# Get game_id (different for playthrough vs challenges)
if assignment.is_playthrough:
game_id = assignment.game_id
else:
# Load challenge to get game_id
result = await db.execute(
select(Challenge).where(Challenge.id == assignment.challenge_id)
)
challenge = result.scalar_one()
game_id = challenge.game_id
# Check if game is already exiled
existing = await db.execute(
select(ExiledGame).where(
ExiledGame.participant_id == participant.id,
ExiledGame.game_id == game_id,
ExiledGame.is_active == True,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Game is already exiled")
# Consume skip_exile from inventory
item = await self._consume_item(db, user, ConsumableType.SKIP_EXILE.value)
# Mark assignment as dropped (without penalty)
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Track skip usage
participant.skips_used += 1
# Add game to exiled list
exiled = ExiledGame(
participant_id=participant.id,
game_id=game_id,
assignment_id=assignment.id,
exiled_by="user",
)
db.add(exiled)
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
assignment_id=assignment.id,
effect_data={
"type": "shield",
"activated": True,
"type": "skip_exile",
"skipped_without_penalty": True,
"game_exiled": True,
"game_id": game_id,
},
)
db.add(usage)
return {
"success": True,
"shield_activated": True,
"skipped": True,
"exiled": True,
"game_id": game_id,
"penalty": 0,
"streak_preserved": True,
}
async def use_boost(
@@ -140,10 +207,10 @@ class ConsumablesService:
marathon: Marathon,
) -> dict:
"""
Activate a Boost - multiplies points for NEXT complete only.
Activate a Boost - multiplies points for current assignment on complete.
- Points for next completed challenge are multiplied by BOOST_MULTIPLIER
- One-time use (consumed on next complete)
- Points for completed challenge are multiplied by BOOST_MULTIPLIER
- One-time use (consumed on complete)
Returns: dict with result info
@@ -181,41 +248,97 @@ class ConsumablesService:
"multiplier": self.BOOST_MULTIPLIER,
}
async def use_reroll(
async def use_wild_card(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
assignment: Assignment,
game_id: int,
) -> dict:
"""
Use a Reroll - discard current assignment and spin again.
Use Wild Card - choose a game and switch to it.
- Current assignment is cancelled (not dropped)
- User can spin the wheel again
- No penalty
For challenges game type:
- New challenge is randomly selected from the chosen game
- Assignment becomes a regular challenge
Returns: dict with result info
For playthrough game type:
- Assignment becomes a playthrough of the chosen game
- Bonus assignments are created from game's challenges
Returns: dict with new assignment info
Raises:
HTTPException: If consumables not allowed or assignment not active
HTTPException: If game not in marathon or no challenges available
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Can only reroll active assignments")
raise HTTPException(status_code=400, detail="Can only use wild card on active assignments")
# Consume reroll from inventory
item = await self._consume_item(db, user, ConsumableType.REROLL.value)
# Verify game is in this marathon and load challenges
result = await db.execute(
select(Game)
.options(selectinload(Game.challenges))
.where(
Game.id == game_id,
Game.marathon_id == marathon.id,
)
)
game = result.scalar_one_or_none()
# Cancel current assignment
old_challenge_id = assignment.challenge_id
if not game:
raise HTTPException(status_code=400, detail="Game not found in this marathon")
# Store old assignment info for logging
old_game_id = assignment.game_id
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Note: We do NOT increase drop_count (this is a reroll, not a real drop)
old_challenge_id = assignment.challenge_id
old_is_playthrough = assignment.is_playthrough
# Consume wild card from inventory
item = await self._consume_item(db, user, ConsumableType.WILD_CARD.value)
# Delete existing bonus assignments if any
if assignment.bonus_assignments:
for ba in assignment.bonus_assignments:
await db.delete(ba)
new_challenge_id = None
new_challenge_title = None
if game.game_type == GameType.PLAYTHROUGH.value:
# Switch to playthrough mode
assignment.game_id = game_id
assignment.challenge_id = None
assignment.is_playthrough = True
# Create bonus assignments from game's challenges
for ch in game.challenges:
bonus = BonusAssignment(
main_assignment_id=assignment.id,
challenge_id=ch.id,
)
db.add(bonus)
else:
# Switch to challenge mode - get random challenge
if not game.challenges:
raise HTTPException(status_code=400, detail="No challenges available for this game")
new_challenge = random.choice(game.challenges)
new_challenge_id = new_challenge.id
new_challenge_title = new_challenge.title
assignment.game_id = game_id
assignment.challenge_id = new_challenge_id
assignment.is_playthrough = False
# Reset timestamps since it's a new assignment
assignment.started_at = datetime.utcnow()
assignment.deadline = None
# Log usage
usage = ConsumableUsage(
@@ -224,17 +347,279 @@ class ConsumablesService:
marathon_id=marathon.id,
assignment_id=assignment.id,
effect_data={
"type": "reroll",
"rerolled_from_challenge_id": old_challenge_id,
"rerolled_from_game_id": old_game_id,
"type": "wild_card",
"old_game_id": old_game_id,
"old_challenge_id": old_challenge_id,
"old_is_playthrough": old_is_playthrough,
"new_game_id": game_id,
"new_challenge_id": new_challenge_id,
"new_is_playthrough": game.game_type == GameType.PLAYTHROUGH.value,
},
)
db.add(usage)
return {
"success": True,
"rerolled": True,
"can_spin_again": True,
"game_id": game_id,
"game_name": game.title,
"game_type": game.game_type,
"is_playthrough": game.game_type == GameType.PLAYTHROUGH.value,
"challenge_id": new_challenge_id,
"challenge_title": new_challenge_title,
}
async def use_lucky_dice(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
) -> dict:
"""
Use Lucky Dice - get a random multiplier for current assignment.
- Random multiplier from [0.5, 1.0, 1.5, 2.0, 2.5, 3.0]
- Applied on next complete (stacks with boost if both active)
- One-time use
Returns: dict with rolled multiplier
Raises:
HTTPException: If consumables not allowed or lucky dice already active
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if participant.has_lucky_dice:
raise HTTPException(status_code=400, detail="Lucky Dice is already active")
# Consume lucky dice from inventory
item = await self._consume_item(db, user, ConsumableType.LUCKY_DICE.value)
# Roll the dice
multiplier = random.choice(self.LUCKY_DICE_MULTIPLIERS)
# Activate lucky dice
participant.has_lucky_dice = True
participant.lucky_dice_multiplier = multiplier
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
effect_data={
"type": "lucky_dice",
"multiplier": multiplier,
},
)
db.add(usage)
return {
"success": True,
"lucky_dice_activated": True,
"multiplier": multiplier,
}
async def use_copycat(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
assignment: Assignment,
target_participant_id: int,
) -> dict:
"""
Use Copycat - copy another participant's assignment.
- Current assignment is replaced with target's current/last assignment
- Can copy even if target already completed theirs
- Cannot copy your own assignment
Returns: dict with copied assignment info
Raises:
HTTPException: If target not found or no assignment to copy
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Can only use copycat on active assignments")
if target_participant_id == participant.id:
raise HTTPException(status_code=400, detail="Cannot copy your own assignment")
# Find target participant
result = await db.execute(
select(Participant)
.where(
Participant.id == target_participant_id,
Participant.marathon_id == marathon.id,
)
)
target_participant = result.scalar_one_or_none()
if not target_participant:
raise HTTPException(status_code=400, detail="Target participant not found")
# Get target's most recent assignment (active or completed)
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge),
selectinload(Assignment.game).selectinload(Game.challenges),
)
.where(
Assignment.participant_id == target_participant_id,
Assignment.status.in_([
AssignmentStatus.ACTIVE.value,
AssignmentStatus.COMPLETED.value
])
)
.order_by(Assignment.started_at.desc())
.limit(1)
)
target_assignment = result.scalar_one_or_none()
if not target_assignment:
raise HTTPException(status_code=400, detail="Target has no assignment to copy")
# Consume copycat from inventory
item = await self._consume_item(db, user, ConsumableType.COPYCAT.value)
# Store old assignment info for logging
old_game_id = assignment.game_id
old_challenge_id = assignment.challenge_id
old_is_playthrough = assignment.is_playthrough
# Copy the assignment - handle both challenge and playthrough
assignment.game_id = target_assignment.game_id
assignment.challenge_id = target_assignment.challenge_id
assignment.is_playthrough = target_assignment.is_playthrough
# Reset timestamps
assignment.started_at = datetime.utcnow()
assignment.deadline = None
# If copying a playthrough, recreate bonus assignments
if target_assignment.is_playthrough:
# Delete existing bonus assignments
for ba in assignment.bonus_assignments:
await db.delete(ba)
# Create new bonus assignments from target game's challenges
if target_assignment.game and target_assignment.game.challenges:
for ch in target_assignment.game.challenges:
bonus = BonusAssignment(
main_assignment_id=assignment.id,
challenge_id=ch.id,
)
db.add(bonus)
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
assignment_id=assignment.id,
effect_data={
"type": "copycat",
"old_challenge_id": old_challenge_id,
"old_game_id": old_game_id,
"old_is_playthrough": old_is_playthrough,
"copied_from_participant_id": target_participant_id,
"new_challenge_id": target_assignment.challenge_id,
"new_game_id": target_assignment.game_id,
"new_is_playthrough": target_assignment.is_playthrough,
},
)
db.add(usage)
# Prepare response
if target_assignment.is_playthrough:
title = f"Прохождение: {target_assignment.game.title}" if target_assignment.game else "Прохождение"
else:
title = target_assignment.challenge.title if target_assignment.challenge else None
return {
"success": True,
"copied": True,
"game_id": target_assignment.game_id,
"challenge_id": target_assignment.challenge_id,
"is_playthrough": target_assignment.is_playthrough,
"challenge_title": title,
}
async def use_undo(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
) -> dict:
"""
Use Undo - restore points and streak from last drop.
- Only works if there was a drop in this marathon
- Can only undo once per drop
- Restores both points and streak
Returns: dict with restored values
Raises:
HTTPException: If no drop to undo
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if not participant.can_undo:
raise HTTPException(status_code=400, detail="No drop to undo")
if participant.last_drop_points is None or participant.last_drop_streak_before is None:
raise HTTPException(status_code=400, detail="No drop data to restore")
# Consume undo from inventory
item = await self._consume_item(db, user, ConsumableType.UNDO.value)
# Store values for logging
points_restored = participant.last_drop_points
streak_restored = participant.last_drop_streak_before
current_points = participant.total_points
current_streak = participant.current_streak
# Restore points and streak
participant.total_points += points_restored
participant.current_streak = streak_restored
participant.drop_count = max(0, participant.drop_count - 1)
# Clear undo data
participant.can_undo = False
participant.last_drop_points = None
participant.last_drop_streak_before = None
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
effect_data={
"type": "undo",
"points_restored": points_restored,
"streak_restored_to": streak_restored,
"points_before": current_points,
"streak_before": current_streak,
},
)
db.add(usage)
return {
"success": True,
"undone": True,
"points_restored": points_restored,
"streak_restored": streak_restored,
"new_total_points": participant.total_points,
"new_streak": participant.current_streak,
}
async def _consume_item(
@@ -292,17 +677,6 @@ class ConsumablesService:
quantity = result.scalar_one_or_none()
return quantity or 0
def consume_shield(self, participant: Participant) -> bool:
"""
Consume shield when dropping (called from wheel.py).
Returns: True if shield was consumed, False otherwise
"""
if participant.has_shield:
participant.has_shield = False
return True
return False
def consume_boost_on_complete(self, participant: Participant) -> float:
"""
Consume boost when completing assignment (called from wheel.py).
@@ -315,6 +689,33 @@ class ConsumablesService:
return self.BOOST_MULTIPLIER
return 1.0
def consume_lucky_dice_on_complete(self, participant: Participant) -> float:
"""
Consume lucky dice when completing assignment (called from wheel.py).
One-time use - consumed after single complete.
Returns: Multiplier value (rolled multiplier if active, 1.0 otherwise)
"""
if participant.has_lucky_dice and participant.lucky_dice_multiplier is not None:
multiplier = participant.lucky_dice_multiplier
participant.has_lucky_dice = False
participant.lucky_dice_multiplier = None
return multiplier
return 1.0
def save_drop_for_undo(
self,
participant: Participant,
points_lost: int,
streak_before: int,
) -> None:
"""
Save drop data for potential undo (called from wheel.py before dropping).
"""
participant.last_drop_points = points_lost
participant.last_drop_streak_before = streak_before
participant.can_undo = True
# Singleton instance
consumables_service = ConsumablesService()

View File

@@ -124,12 +124,6 @@ points: easy=20-40, medium=45-75, hard=90-150
points = ch.get("points", 30)
if not isinstance(points, int) or points < 1:
points = 30
if difficulty == "easy":
points = max(20, min(40, points))
elif difficulty == "medium":
points = max(45, min(75, points))
elif difficulty == "hard":
points = max(90, min(150, points))
return ChallengeGenerated(
title=ch.get("title", "Unnamed Challenge")[:100],

View File

@@ -66,7 +66,7 @@ class PointsService:
def calculate_drop_penalty(
self,
consecutive_drops: int,
challenge_points: int,
challenge_points: int | None,
event: Event | None = None
) -> int:
"""
@@ -80,6 +80,10 @@ class PointsService:
Returns:
Penalty points to subtract
"""
# No penalty if no points defined
if challenge_points is None:
return 0
# Double risk event = free drops
if event and event.type == EventType.DOUBLE_RISK.value:
return 0

View File

@@ -608,6 +608,57 @@ class TelegramNotifier:
reply_markup=reply_markup
)
async def notify_assignment_skipped_by_moderator(
self,
db,
user,
marathon_title: str,
game_title: str,
exiled: bool,
reason: str | None,
moderator_nickname: str,
) -> bool:
"""Notify participant that their assignment was skipped by organizer"""
if not user.telegram_id or not user.notify_moderation:
return False
exile_text = "\n🚫 Игра исключена из вашего пула" if exiled else ""
reason_text = f"\n📝 Причина: {reason}" if reason else ""
message = (
f"⏭️ <b>Задание пропущено</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Игра: {game_title}\n"
f"Организатор: {moderator_nickname}"
f"{exile_text}"
f"{reason_text}\n\n"
f"Вы можете крутить колесо заново."
)
return await self.send_message(user.telegram_id, message)
async def notify_item_granted(
self,
user,
item_name: str,
quantity: int,
reason: str,
admin_nickname: str,
) -> bool:
"""Notify user that they received an item from admin"""
if not user.telegram_id:
return False
message = (
f"🎁 <b>Вы получили подарок!</b>\n\n"
f"Предмет: {item_name}\n"
f"Количество: {quantity}\n"
f"От: {admin_nickname}\n"
f"Причина: {reason}"
)
return await self.send_message(user.telegram_id, message)
# Global instance
telegram_notifier = TelegramNotifier()

32
desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Dependencies
node_modules/
# Build output
dist/
release/
# Logs
*.log
npm-debug.log*
# IDE
.idea/
.vscode/
*.swp
*.swo
.claude/
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
.env.*.local
# Electron
*.asar
# Lock files (optional - remove if you want to commit)
package-lock.json

6893
desktop/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

89
desktop/package.json Normal file
View File

@@ -0,0 +1,89 @@
{
"name": "game-marathon-tracker",
"version": "1.0.1",
"description": "Desktop app for tracking game time in Game Marathon",
"main": "dist/main/main/index.js",
"author": "Game Marathon",
"license": "MIT",
"scripts": {
"dev": "concurrently -k \"npm run dev:main\" \"npm run dev:renderer\" \"npm run dev:electron\"",
"dev:main": "tsc -p tsconfig.main.json --watch",
"dev:renderer": "vite",
"dev:electron": "wait-on http://localhost:5173 && electron .",
"build": "npm run build:main && npm run build:renderer",
"build:main": "tsc -p tsconfig.main.json",
"build:renderer": "vite build && node -e \"require('fs').copyFileSync('src/renderer/splash.html', 'dist/renderer/splash.html'); require('fs').copyFileSync('src/renderer/logo.jpg', 'dist/renderer/logo.jpg')\"",
"start": "electron .",
"pack": "electron-builder --dir",
"dist": "npm run build && electron-builder --win"
},
"dependencies": {
"auto-launch": "^5.0.6",
"axios": "^1.6.7",
"clsx": "^2.1.0",
"electron-store": "^8.1.0",
"electron-updater": "^6.7.3",
"lucide-react": "^0.323.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"tailwind-merge": "^2.2.1",
"vdf-parser": "^1.0.3",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/auto-launch": "^5.0.5",
"@types/node": "^20.11.16",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"concurrently": "^8.2.2",
"electron": "^28.2.0",
"electron-builder": "^24.9.1",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.1.0",
"wait-on": "^7.2.0"
},
"build": {
"appId": "com.gamemarathon.tracker",
"productName": "Game Marathon Tracker",
"directories": {
"output": "release"
},
"files": [
"dist/**/*"
],
"extraResources": [
{
"from": "resources",
"to": "resources"
}
],
"win": {
"target": [
{
"target": "nsis",
"arch": ["x64"]
}
],
"icon": "resources/icon.ico",
"signAndEditExecutable": false
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"runAfterFinish": false,
"artifactName": "Game-Marathon-Tracker-Setup-${version}.${ext}"
},
"publish": {
"provider": "github",
"owner": "Oronemu",
"repo": "marathon_tracker"
}
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,2 @@
# Resources placeholder
# Add icon.ico and tray-icon.png here

BIN
desktop/resources/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

BIN
desktop/resources/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,145 @@
import https from 'https'
import http from 'http'
import { URL } from 'url'
import type { StoreType } from './storeTypes'
interface ApiResponse<T = unknown> {
data: T
status: number
}
interface ApiError {
status: number
message: string
detail?: unknown
}
export class ApiClient {
private store: StoreType
constructor(store: StoreType) {
this.store = store
}
private getBaseUrl(): string {
return this.store.get('settings').apiUrl || 'https://marathon.animeenigma.ru/api/v1'
}
private getToken(): string | null {
return this.store.get('token')
}
async request<T>(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
endpoint: string,
data?: unknown
): Promise<ApiResponse<T>> {
const baseUrl = this.getBaseUrl().replace(/\/$/, '') // Remove trailing slash
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
const fullUrl = `${baseUrl}${cleanEndpoint}`
const url = new URL(fullUrl)
const token = this.getToken()
const isHttps = url.protocol === 'https:'
const httpModule = isHttps ? https : http
const body = data ? JSON.stringify(data) : undefined
const options = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname + url.search,
method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...(body ? { 'Content-Length': Buffer.byteLength(body) } : {}),
},
}
console.log(`[ApiClient] ${method} ${url.href}`)
return new Promise((resolve, reject) => {
const req = httpModule.request(options, (res) => {
let responseData = ''
res.on('data', (chunk) => {
responseData += chunk
})
res.on('end', () => {
console.log(`[ApiClient] Response status: ${res.statusCode}`)
console.log(`[ApiClient] Response body: ${responseData.substring(0, 500)}`)
try {
const parsed = responseData ? JSON.parse(responseData) : {}
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve({
data: parsed as T,
status: res.statusCode,
})
} else {
const error: ApiError = {
status: res.statusCode || 500,
message: parsed.detail || 'Request failed',
detail: parsed.detail,
}
reject(error)
}
} catch (e) {
console.error('[ApiClient] Parse error:', e)
console.error('[ApiClient] Raw response:', responseData)
reject({
status: res.statusCode || 500,
message: 'Failed to parse response',
})
}
})
})
req.on('error', (e) => {
console.error('[ApiClient] Request error:', e)
reject({
status: 0,
message: e.message || 'Network error',
})
})
req.setTimeout(30000, () => {
console.error('[ApiClient] Request timeout')
req.destroy()
reject({
status: 0,
message: 'Request timeout',
})
})
if (body) {
req.write(body)
}
req.end()
})
}
async get<T>(endpoint: string): Promise<ApiResponse<T>> {
return this.request<T>('GET', endpoint)
}
async post<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
return this.request<T>('POST', endpoint, data)
}
async put<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
return this.request<T>('PUT', endpoint, data)
}
async patch<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
return this.request<T>('PATCH', endpoint, data)
}
async delete<T>(endpoint: string): Promise<ApiResponse<T>> {
return this.request<T>('DELETE', endpoint)
}
}

View File

@@ -0,0 +1,42 @@
import AutoLaunch from 'auto-launch'
import { app } from 'electron'
let autoLauncher: AutoLaunch | null = null
export async function setupAutoLaunch(enabled: boolean): Promise<void> {
if (!autoLauncher) {
autoLauncher = new AutoLaunch({
name: 'Game Marathon Tracker',
path: app.getPath('exe'),
})
}
try {
const isEnabled = await autoLauncher.isEnabled()
if (enabled && !isEnabled) {
await autoLauncher.enable()
console.log('Auto-launch enabled')
} else if (!enabled && isEnabled) {
await autoLauncher.disable()
console.log('Auto-launch disabled')
}
} catch (error) {
console.error('Failed to setup auto-launch:', error)
}
}
export async function isAutoLaunchEnabled(): Promise<boolean> {
if (!autoLauncher) {
autoLauncher = new AutoLaunch({
name: 'Game Marathon Tracker',
path: app.getPath('exe'),
})
}
try {
return await autoLauncher.isEnabled()
} catch {
return false
}
}

197
desktop/src/main/index.ts Normal file
View File

@@ -0,0 +1,197 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import * as path from 'path'
import Store from 'electron-store'
import { setupTray, destroyTray } from './tray'
import { setupAutoLaunch } from './autolaunch'
import { setupIpcHandlers } from './ipc'
import { ProcessTracker } from './tracking/processTracker'
import { createSplashWindow, setupAutoUpdater, setupUpdateIpcHandlers } from './updater'
import type { StoreType } from './storeTypes'
import './storeTypes' // Import for global type declarations
// Initialize electron store
const store = new Store({
defaults: {
settings: {
autoLaunch: false,
minimizeToTray: true,
trackingInterval: 5000,
apiUrl: 'https://marathon.animeenigma.ru/api/v1',
theme: 'dark',
},
token: null,
trackedGames: {},
trackingData: {},
},
}) as StoreType
let mainWindow: BrowserWindow | null = null
let processTracker: ProcessTracker | null = null
let isMonitoringEnabled = false
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
// Prevent multiple instances
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.exit(0)
}
// Someone tried to run a second instance, focus our window
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.show()
mainWindow.focus()
}
})
function createWindow() {
// In dev: use project resources folder, in prod: use app resources
const iconPath = isDev
? path.join(__dirname, '../../../resources/icon.ico')
: path.join(process.resourcesPath, 'resources/icon.ico')
mainWindow = new BrowserWindow({
width: 450,
height: 750,
resizable: false,
frame: false,
titleBarStyle: 'hidden',
backgroundColor: '#0d0e14',
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
nodeIntegration: false,
contextIsolation: true,
},
icon: iconPath,
})
// Load the app
if (isDev) {
mainWindow.loadURL('http://localhost:5173')
mainWindow.webContents.openDevTools({ mode: 'detach' })
} else {
// In production: __dirname is dist/main/main/, so go up twice to dist/renderer/
mainWindow.loadFile(path.join(__dirname, '../../renderer/index.html'))
}
// Handle close to tray
mainWindow.on('close', (event) => {
const settings = store.get('settings')
if (settings.minimizeToTray && !app.isQuitting) {
event.preventDefault()
mainWindow?.hide()
}
})
mainWindow.on('closed', () => {
mainWindow = null
})
// Setup tray icon
setupTray(mainWindow, store)
return mainWindow
}
app.whenReady().then(async () => {
// Setup IPC handlers
setupIpcHandlers(store, () => mainWindow)
setupUpdateIpcHandlers()
// Show splash screen and check for updates
createSplashWindow()
setupAutoUpdater(async () => {
// This runs after update check is complete (or skipped)
// Create the main window
createWindow()
// Setup auto-launch
const settings = store.get('settings')
await setupAutoLaunch(settings.autoLaunch)
// Initialize process tracker (but don't start automatically)
processTracker = new ProcessTracker(
store,
(stats) => {
mainWindow?.webContents.send('tracking-update', stats)
},
(event) => {
// Game started
mainWindow?.webContents.send('game-started', event.gameName, event.gameId)
},
(event) => {
// Game stopped
mainWindow?.webContents.send('game-stopped', event.gameName, event.duration || 0)
}
)
// Don't start automatically - user will start via button
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
// Don't quit on Windows if minimize to tray is enabled
const settings = store.get('settings')
if (!settings.minimizeToTray) {
app.quit()
}
}
})
app.on('before-quit', () => {
app.isQuitting = true
processTracker?.stop()
destroyTray()
})
// Handle IPC for window controls
ipcMain.on('minimize-to-tray', () => {
mainWindow?.hide()
})
ipcMain.on('close-window', () => {
// This triggers the 'close' event handler which checks minimizeToTray setting
mainWindow?.close()
})
ipcMain.on('quit-app', () => {
app.isQuitting = true
app.quit()
})
// Monitoring control
ipcMain.handle('start-monitoring', () => {
if (!isMonitoringEnabled && processTracker) {
processTracker.start()
isMonitoringEnabled = true
console.log('Monitoring started')
}
return isMonitoringEnabled
})
ipcMain.handle('stop-monitoring', () => {
if (isMonitoringEnabled && processTracker) {
processTracker.stop()
isMonitoringEnabled = false
console.log('Monitoring stopped')
}
return isMonitoringEnabled
})
ipcMain.handle('get-monitoring-status', () => {
return isMonitoringEnabled
})
// Export for use in other modules
export { store, mainWindow }

174
desktop/src/main/ipc.ts Normal file
View File

@@ -0,0 +1,174 @@
import { ipcMain, BrowserWindow } from 'electron'
import { setupAutoLaunch } from './autolaunch'
import { getRunningProcesses, getForegroundWindow } from './tracking/processTracker'
import { getSteamGames, getSteamPath } from './tracking/steamIntegration'
import { getTrackingStats, getTrackedGames, addTrackedGame, removeTrackedGame } from './tracking/timeStorage'
import { ApiClient } from './apiClient'
import type { TrackedGame, AppSettings, User, LoginResponse } from '../shared/types'
import type { StoreType } from './storeTypes'
export function setupIpcHandlers(
store: StoreType,
getMainWindow: () => BrowserWindow | null
) {
const apiClient = new ApiClient(store)
// Settings handlers
ipcMain.handle('get-settings', () => {
return store.get('settings')
})
ipcMain.handle('save-settings', async (_event, settings: Partial<AppSettings>) => {
const currentSettings = store.get('settings')
const newSettings = { ...currentSettings, ...settings }
store.set('settings', newSettings)
// Handle auto-launch setting change
if (settings.autoLaunch !== undefined) {
await setupAutoLaunch(settings.autoLaunch)
}
return newSettings
})
// Auth handlers
ipcMain.handle('get-token', () => {
return store.get('token')
})
ipcMain.handle('save-token', (_event, token: string) => {
store.set('token', token)
})
ipcMain.handle('clear-token', () => {
store.set('token', null)
})
// Process tracking handlers
ipcMain.handle('get-running-processes', async () => {
return await getRunningProcesses()
})
ipcMain.handle('get-foreground-window', async () => {
return await getForegroundWindow()
})
ipcMain.handle('get-tracking-stats', () => {
return getTrackingStats(store)
})
// Steam handlers
ipcMain.handle('get-steam-games', async () => {
return await getSteamGames()
})
ipcMain.handle('get-steam-path', () => {
return getSteamPath()
})
// Tracked games handlers
ipcMain.handle('get-tracked-games', () => {
return getTrackedGames(store)
})
ipcMain.handle('add-tracked-game', (_event, game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>) => {
return addTrackedGame(store, game)
})
ipcMain.handle('remove-tracked-game', (_event, gameId: string) => {
removeTrackedGame(store, gameId)
})
// API handlers - all requests go through main process (no CORS issues)
ipcMain.handle('api-login', async (_event, login: string, password: string) => {
console.log('[API] Login attempt for:', login)
try {
const response = await apiClient.post<LoginResponse>('/auth/login', { login, password })
console.log('[API] Login response:', response.status)
// Save token if login successful
if (response.data.access_token) {
store.set('token', response.data.access_token)
}
return { success: true, data: response.data }
} catch (error: unknown) {
console.error('[API] Login error:', error)
const err = error as { status?: number; message?: string; detail?: unknown }
return {
success: false,
error: err.detail || err.message || 'Login failed',
status: err.status
}
}
})
ipcMain.handle('api-get-me', async () => {
try {
const response = await apiClient.get<User>('/auth/me')
return { success: true, data: response.data }
} catch (error: unknown) {
const err = error as { status?: number; message?: string; detail?: unknown }
return {
success: false,
error: err.detail || err.message || 'Failed to get user',
status: err.status
}
}
})
ipcMain.handle('api-2fa-verify', async (_event, sessionId: number, code: string) => {
console.log('[API] 2FA verify attempt')
try {
const response = await apiClient.post<LoginResponse>(`/auth/2fa/verify?session_id=${sessionId}&code=${code}`)
console.log('[API] 2FA verify response:', response.status)
// Save token if verification successful
if (response.data.access_token) {
store.set('token', response.data.access_token)
}
return { success: true, data: response.data }
} catch (error: unknown) {
console.error('[API] 2FA verify error:', error)
const err = error as { status?: number; message?: string; detail?: unknown }
return {
success: false,
error: err.detail || err.message || '2FA verification failed',
status: err.status
}
}
})
ipcMain.handle('api-request', async (_event, method: string, endpoint: string, data?: unknown) => {
try {
let response
switch (method.toUpperCase()) {
case 'GET':
response = await apiClient.get(endpoint)
break
case 'POST':
response = await apiClient.post(endpoint, data)
break
case 'PUT':
response = await apiClient.put(endpoint, data)
break
case 'PATCH':
response = await apiClient.patch(endpoint, data)
break
case 'DELETE':
response = await apiClient.delete(endpoint)
break
default:
throw new Error(`Unknown method: ${method}`)
}
return { success: true, data: response.data }
} catch (error: unknown) {
const err = error as { status?: number; message?: string; detail?: unknown }
return {
success: false,
error: err.detail || err.message || 'Request failed',
status: err.status
}
}
})
}

View File

@@ -0,0 +1,28 @@
import Store from 'electron-store'
import type { AppSettings, TrackedGame } from '../shared/types'
export interface GameTrackingData {
totalTime: number
sessions: Array<{
startTime: number
endTime: number
duration: number
}>
lastPlayed: number
}
export type StoreType = Store<{
settings: AppSettings
token: string | null
trackedGames: Record<string, TrackedGame>
trackingData: Record<string, GameTrackingData>
}>
// Extend Electron App type
declare global {
namespace Electron {
interface App {
isQuitting?: boolean
}
}
}

View File

@@ -0,0 +1,284 @@
import { exec } from 'child_process'
import { promisify } from 'util'
import type { TrackedProcess, TrackingStats, TrackedGame } from '../../shared/types'
import type { StoreType } from '../storeTypes'
import { updateGameTime, getTrackedGames } from './timeStorage'
import { updateTrayMenu } from '../tray'
const execAsync = promisify(exec)
interface ProcessInfo {
ProcessName: string
MainWindowTitle: string
Id: number
}
export async function getRunningProcesses(): Promise<TrackedProcess[]> {
try {
const { stdout } = await execAsync(
'powershell -NoProfile -Command "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Process | Where-Object {$_.MainWindowTitle} | Select-Object ProcessName, MainWindowTitle, Id | ConvertTo-Json -Compress"',
{ encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }
)
if (!stdout.trim()) {
return []
}
let processes: ProcessInfo[]
try {
const parsed = JSON.parse(stdout)
processes = Array.isArray(parsed) ? parsed : [parsed]
} catch {
return []
}
return processes.map((proc) => ({
id: proc.Id.toString(),
name: proc.ProcessName,
displayName: proc.MainWindowTitle || proc.ProcessName,
windowTitle: proc.MainWindowTitle,
isGame: isLikelyGame(proc.ProcessName, proc.MainWindowTitle),
}))
} catch (error) {
console.error('Failed to get running processes:', error)
return []
}
}
export async function getForegroundWindow(): Promise<string | null> {
try {
// Use base64 encoded script to avoid escaping issues
const script = `
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class FGWindow {
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
}
"@
\$hwnd = [FGWindow]::GetForegroundWindow()
\$processId = 0
[void][FGWindow]::GetWindowThreadProcessId(\$hwnd, [ref]\$processId)
\$proc = Get-Process -Id \$processId -ErrorAction SilentlyContinue
if (\$proc) { Write-Output \$proc.ProcessName }
`
// Encode script as base64
const base64Script = Buffer.from(script, 'utf16le').toString('base64')
const { stdout } = await execAsync(
`powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${base64Script}`,
{ encoding: 'utf8', timeout: 5000 }
)
const result = stdout.trim()
return result || null
} catch (error) {
console.error('[getForegroundWindow] Error:', error)
return null
}
}
function isLikelyGame(processName: string, windowTitle: string): boolean {
const gameIndicators = [
'game', 'steam', 'epic', 'uplay', 'origin', 'battle.net',
'unity', 'unreal', 'godot', 'ue4', 'ue5',
]
const lowerName = processName.toLowerCase()
const lowerTitle = (windowTitle || '').toLowerCase()
// Check for common game launchers/engines
for (const indicator of gameIndicators) {
if (lowerName.includes(indicator) || lowerTitle.includes(indicator)) {
return true
}
}
// Exclude common non-game processes
const nonGameProcesses = [
'explorer', 'chrome', 'firefox', 'edge', 'opera', 'brave',
'code', 'idea', 'webstorm', 'pycharm', 'rider',
'discord', 'slack', 'teams', 'zoom', 'telegram',
'spotify', 'vlc', 'foobar', 'winamp',
'notepad', 'word', 'excel', 'powerpoint', 'outlook',
'cmd', 'powershell', 'terminal', 'windowsterminal',
]
for (const nonGame of nonGameProcesses) {
if (lowerName.includes(nonGame)) {
return false
}
}
return true
}
interface GameEvent {
gameName: string
gameId: string
duration?: number
}
export class ProcessTracker {
private intervalId: NodeJS.Timeout | null = null
private currentGame: string | null = null
private currentGameName: string | null = null
private sessionStart: number | null = null
private store: StoreType
private onUpdate: (stats: TrackingStats) => void
private onGameStarted: (event: GameEvent) => void
private onGameStopped: (event: GameEvent) => void
constructor(
store: StoreType,
onUpdate: (stats: TrackingStats) => void,
onGameStarted?: (event: GameEvent) => void,
onGameStopped?: (event: GameEvent) => void
) {
this.store = store
this.onUpdate = onUpdate
this.onGameStarted = onGameStarted || (() => {})
this.onGameStopped = onGameStopped || (() => {})
}
start() {
const settings = this.store.get('settings')
const interval = settings.trackingInterval || 5000
this.intervalId = setInterval(() => this.tick(), interval)
console.log(`Process tracker started with ${interval}ms interval`)
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId)
this.intervalId = null
}
// End current session if any
if (this.currentGame && this.sessionStart) {
const duration = Date.now() - this.sessionStart
updateGameTime(this.store, this.currentGame, duration)
}
this.currentGame = null
this.sessionStart = null
console.log('Process tracker stopped')
}
private async tick() {
const foregroundProcess = await getForegroundWindow()
const trackedGames = getTrackedGames(this.store)
// Debug logging - ALWAYS log
console.log('[Tracker] Foreground:', foregroundProcess || 'NULL', '| Tracked:', trackedGames.length, 'games:', trackedGames.map(g => g.executableName).join(', ') || 'none')
// Find if foreground process matches any tracked game
let matchedGame: TrackedGame | null = null
if (foregroundProcess) {
const lowerForeground = foregroundProcess.toLowerCase().replace('.exe', '')
for (const game of trackedGames) {
const lowerExe = game.executableName.toLowerCase().replace('.exe', '')
// More flexible matching
const matches = lowerForeground === lowerExe ||
lowerForeground.includes(lowerExe) ||
lowerExe.includes(lowerForeground)
if (matches) {
console.log('[Tracker] MATCH:', foregroundProcess, '===', game.executableName)
matchedGame = game
break
}
}
}
// Handle game state changes
if (matchedGame && matchedGame.id !== this.currentGame) {
// New game started
if (this.currentGame && this.sessionStart && this.currentGameName) {
// End previous session
const duration = Date.now() - this.sessionStart
updateGameTime(this.store, this.currentGame, duration)
// Emit game stopped event for previous game
this.onGameStopped({
gameName: this.currentGameName,
gameId: this.currentGame,
duration
})
}
this.currentGame = matchedGame.id
this.currentGameName = matchedGame.name
this.sessionStart = Date.now()
console.log(`Started tracking: ${matchedGame.name}`)
updateTrayMenu(null, true, matchedGame.name)
// Emit game started event
this.onGameStarted({
gameName: matchedGame.name,
gameId: matchedGame.id
})
} else if (!matchedGame && this.currentGame) {
// Game stopped
if (this.sessionStart) {
const duration = Date.now() - this.sessionStart
updateGameTime(this.store, this.currentGame, duration)
// Emit game stopped event
if (this.currentGameName) {
this.onGameStopped({
gameName: this.currentGameName,
gameId: this.currentGame,
duration
})
}
}
console.log(`Stopped tracking: ${this.currentGame}`)
this.currentGame = null
this.currentGameName = null
this.sessionStart = null
updateTrayMenu(null, false)
}
// Emit update
const stats = this.getStats()
this.onUpdate(stats)
}
private getStats(): TrackingStats {
const trackedGames = getTrackedGames(this.store)
const now = Date.now()
const todayStart = new Date().setHours(0, 0, 0, 0)
const weekStart = now - 7 * 24 * 60 * 60 * 1000
const monthStart = now - 30 * 24 * 60 * 60 * 1000
let totalTimeToday = 0
let totalTimeWeek = 0
let totalTimeMonth = 0
// Add current session time if active
if (this.currentGame && this.sessionStart) {
const currentSessionTime = now - this.sessionStart
totalTimeToday += currentSessionTime
totalTimeWeek += currentSessionTime
totalTimeMonth += currentSessionTime
}
// This is a simplified version - full implementation would track sessions with timestamps
for (const game of trackedGames) {
totalTimeMonth += game.totalTime
// For simplicity, assume all recorded time is from this week/today
// A full implementation would store session timestamps
}
return {
totalTimeToday,
totalTimeWeek,
totalTimeMonth,
sessions: [],
currentGame: this.currentGameName,
currentSessionDuration: this.currentGame && this.sessionStart ? now - this.sessionStart : 0,
}
}
}

View File

@@ -0,0 +1,215 @@
import * as fs from 'fs'
import * as path from 'path'
import type { SteamGame } from '../../shared/types'
// Common Steam installation paths on Windows
const STEAM_PATHS = [
'C:\\Program Files (x86)\\Steam',
'C:\\Program Files\\Steam',
'D:\\Steam',
'D:\\SteamLibrary',
'E:\\Steam',
'E:\\SteamLibrary',
]
let cachedSteamPath: string | null = null
export function getSteamPath(): string | null {
if (cachedSteamPath) {
return cachedSteamPath
}
// Try common paths
for (const steamPath of STEAM_PATHS) {
if (fs.existsSync(path.join(steamPath, 'steam.exe')) ||
fs.existsSync(path.join(steamPath, 'steamapps'))) {
cachedSteamPath = steamPath
return steamPath
}
}
// Try to find via registry (would require node-winreg or similar)
// For now, just check common paths
return null
}
export async function getSteamGames(): Promise<SteamGame[]> {
const steamPath = getSteamPath()
if (!steamPath) {
console.log('Steam not found')
return []
}
const games: SteamGame[] = []
const libraryPaths = await getLibraryPaths(steamPath)
for (const libraryPath of libraryPaths) {
const steamAppsPath = path.join(libraryPath, 'steamapps')
if (!fs.existsSync(steamAppsPath)) continue
try {
const files = fs.readdirSync(steamAppsPath)
const manifests = files.filter((f) => f.startsWith('appmanifest_') && f.endsWith('.acf'))
for (const manifest of manifests) {
const game = await parseAppManifest(path.join(steamAppsPath, manifest), libraryPath)
if (game) {
games.push(game)
}
}
} catch (error) {
console.error(`Error reading steam apps from ${steamAppsPath}:`, error)
}
}
return games.sort((a, b) => a.name.localeCompare(b.name))
}
async function getLibraryPaths(steamPath: string): Promise<string[]> {
const paths: string[] = [steamPath]
const libraryFoldersPath = path.join(steamPath, 'steamapps', 'libraryfolders.vdf')
if (!fs.existsSync(libraryFoldersPath)) {
return paths
}
try {
const content = fs.readFileSync(libraryFoldersPath, 'utf8')
const libraryPaths = parseLibraryFolders(content)
paths.push(...libraryPaths.filter((p) => !paths.includes(p)))
} catch (error) {
console.error('Error reading library folders:', error)
}
return paths
}
function parseLibraryFolders(content: string): string[] {
const paths: string[] = []
// Simple VDF parser for library folders
// Format: "path" "C:\\SteamLibrary"
const pathRegex = /"path"\s+"([^"]+)"/g
let match
while ((match = pathRegex.exec(content)) !== null) {
const libPath = match[1].replace(/\\\\/g, '\\')
if (fs.existsSync(libPath)) {
paths.push(libPath)
}
}
return paths
}
async function parseAppManifest(manifestPath: string, libraryPath: string): Promise<SteamGame | null> {
try {
const content = fs.readFileSync(manifestPath, 'utf8')
const appIdMatch = content.match(/"appid"\s+"(\d+)"/)
const nameMatch = content.match(/"name"\s+"([^"]+)"/)
const installDirMatch = content.match(/"installdir"\s+"([^"]+)"/)
if (!appIdMatch || !nameMatch || !installDirMatch) {
return null
}
const appId = appIdMatch[1]
const name = nameMatch[1]
const installDir = installDirMatch[1]
// Filter out tools, servers, etc.
const skipTypes = ['Tool', 'Config', 'DLC', 'Music', 'Video']
const typeMatch = content.match(/"type"\s+"([^"]+)"/)
if (typeMatch && skipTypes.includes(typeMatch[1])) {
return null
}
const fullInstallPath = path.join(libraryPath, 'steamapps', 'common', installDir)
let executable: string | undefined
// Try to find main executable
if (fs.existsSync(fullInstallPath)) {
executable = findMainExecutable(fullInstallPath, name)
}
return {
appId,
name,
installDir: fullInstallPath,
executable,
iconPath: getGameIconPath(steamPath, appId),
}
} catch (error) {
console.error(`Error parsing manifest ${manifestPath}:`, error)
return null
}
}
function findMainExecutable(installPath: string, gameName: string): string | undefined {
try {
const files = fs.readdirSync(installPath)
const exeFiles = files.filter((f) => f.endsWith('.exe'))
if (exeFiles.length === 0) {
// Check subdirectories (one level deep)
for (const dir of files) {
const subPath = path.join(installPath, dir)
if (fs.statSync(subPath).isDirectory()) {
const subFiles = fs.readdirSync(subPath)
const subExe = subFiles.filter((f) => f.endsWith('.exe'))
exeFiles.push(...subExe.map((f) => path.join(dir, f)))
}
}
}
if (exeFiles.length === 0) return undefined
// Try to find exe that matches game name
const lowerName = gameName.toLowerCase().replace(/[^a-z0-9]/g, '')
for (const exe of exeFiles) {
const lowerExe = exe.toLowerCase().replace(/[^a-z0-9]/g, '')
if (lowerExe.includes(lowerName) || lowerName.includes(lowerExe.replace('.exe', ''))) {
return exe
}
}
// Filter out common non-game executables
const skipExes = [
'unins', 'setup', 'install', 'config', 'crash', 'report',
'launcher', 'updater', 'redistributable', 'vcredist', 'directx',
'dxsetup', 'ue4prereqsetup', 'dotnet',
]
const gameExes = exeFiles.filter((exe) => {
const lower = exe.toLowerCase()
return !skipExes.some((skip) => lower.includes(skip))
})
return gameExes[0] || exeFiles[0]
} catch {
return undefined
}
}
function getGameIconPath(steamPath: string | null, appId: string): string | undefined {
if (!steamPath) return undefined
// Steam stores icons in appcache/librarycache
const iconPath = path.join(steamPath, 'appcache', 'librarycache', `${appId}_icon.jpg`)
if (fs.existsSync(iconPath)) {
return iconPath
}
// Try header image
const headerPath = path.join(steamPath, 'appcache', 'librarycache', `${appId}_header.jpg`)
if (fs.existsSync(headerPath)) {
return headerPath
}
return undefined
}
// Re-export for use
const steamPath = getSteamPath()

View File

@@ -0,0 +1,155 @@
import type { TrackedGame, TrackingStats, GameSession } from '../../shared/types'
import type { StoreType, GameTrackingData } from '../storeTypes'
export type { GameTrackingData }
export function getTrackedGames(store: StoreType): TrackedGame[] {
const trackedGames = store.get('trackedGames') || {}
return Object.values(trackedGames)
}
export function addTrackedGame(
store: StoreType,
game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>
): TrackedGame {
const trackedGames = store.get('trackedGames') || {}
const newGame: TrackedGame = {
...game,
totalTime: 0,
lastPlayed: undefined,
}
trackedGames[game.id] = newGame
store.set('trackedGames', trackedGames)
// Initialize tracking data
const trackingData = store.get('trackingData') || {}
trackingData[game.id] = {
totalTime: 0,
sessions: [],
lastPlayed: 0,
}
store.set('trackingData', trackingData)
return newGame
}
export function removeTrackedGame(store: StoreType, gameId: string): void {
const trackedGames = store.get('trackedGames') || {}
delete trackedGames[gameId]
store.set('trackedGames', trackedGames)
const trackingData = store.get('trackingData') || {}
delete trackingData[gameId]
store.set('trackingData', trackingData)
}
export function updateGameTime(store: StoreType, gameId: string, duration: number): void {
// Update tracked games
const trackedGames = store.get('trackedGames') || {}
if (trackedGames[gameId]) {
trackedGames[gameId].totalTime += duration
trackedGames[gameId].lastPlayed = Date.now()
store.set('trackedGames', trackedGames)
}
// Update tracking data with session
const trackingData = store.get('trackingData') || {}
if (!trackingData[gameId]) {
trackingData[gameId] = {
totalTime: 0,
sessions: [],
lastPlayed: 0,
}
}
const now = Date.now()
trackingData[gameId].totalTime += duration
trackingData[gameId].lastPlayed = now
trackingData[gameId].sessions.push({
startTime: now - duration,
endTime: now,
duration,
})
// Keep only last 100 sessions to prevent data bloat
if (trackingData[gameId].sessions.length > 100) {
trackingData[gameId].sessions = trackingData[gameId].sessions.slice(-100)
}
store.set('trackingData', trackingData)
}
export function getTrackingStats(store: StoreType): TrackingStats {
const trackingData = store.get('trackingData') || {}
const now = Date.now()
const todayStart = new Date().setHours(0, 0, 0, 0)
const weekStart = now - 7 * 24 * 60 * 60 * 1000
const monthStart = now - 30 * 24 * 60 * 60 * 1000
let totalTimeToday = 0
let totalTimeWeek = 0
let totalTimeMonth = 0
const recentSessions: GameSession[] = []
for (const [gameId, data] of Object.entries(trackingData)) {
for (const session of data.sessions) {
if (session.endTime >= monthStart) {
totalTimeMonth += session.duration
if (session.endTime >= weekStart) {
totalTimeWeek += session.duration
}
if (session.endTime >= todayStart) {
totalTimeToday += session.duration
}
}
}
// Get last session for each game
if (data.sessions.length > 0) {
const lastSession = data.sessions[data.sessions.length - 1]
const trackedGames = store.get('trackedGames') || {}
const game = trackedGames[gameId]
if (game && lastSession.endTime >= weekStart) {
recentSessions.push({
gameId,
gameName: game.name,
startTime: lastSession.startTime,
endTime: lastSession.endTime,
duration: lastSession.duration,
isActive: false,
})
}
}
}
// Sort by most recent
recentSessions.sort((a, b) => (b.endTime || 0) - (a.endTime || 0))
return {
totalTimeToday,
totalTimeWeek,
totalTimeMonth,
sessions: recentSessions.slice(0, 10),
}
}
export function formatTime(ms: number): string {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
const remainingMinutes = minutes % 60
return `${hours}ч ${remainingMinutes}м`
} else if (minutes > 0) {
return `${minutes}м`
} else {
return `${seconds}с`
}
}

115
desktop/src/main/tray.ts Normal file
View File

@@ -0,0 +1,115 @@
import { Tray, Menu, nativeImage, BrowserWindow, app, NativeImage } from 'electron'
import * as path from 'path'
import type { StoreType } from './storeTypes'
let tray: Tray | null = null
export function setupTray(
mainWindow: BrowserWindow | null,
store: StoreType
) {
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
// In dev: use project resources folder, in prod: use app resources
const iconPath = isDev
? path.join(__dirname, '../../../resources/icon.ico')
: path.join(process.resourcesPath, 'resources/icon.ico')
// Create tray icon
let trayIcon: NativeImage
try {
trayIcon = nativeImage.createFromPath(iconPath)
if (trayIcon.isEmpty()) {
trayIcon = nativeImage.createEmpty()
}
} catch {
trayIcon = nativeImage.createEmpty()
}
// Resize for tray (16x16 on Windows)
if (!trayIcon.isEmpty()) {
trayIcon = trayIcon.resize({ width: 16, height: 16 })
}
tray = new Tray(trayIcon)
tray.setToolTip('Game Marathon Tracker')
const contextMenu = Menu.buildFromTemplate([
{
label: 'Открыть',
click: () => {
mainWindow?.show()
mainWindow?.focus()
},
},
{ type: 'separator' },
{
label: 'Статус: Отслеживание',
enabled: false,
},
{ type: 'separator' },
{
label: 'Выход',
click: () => {
app.isQuitting = true
app.quit()
},
},
])
tray.setContextMenu(contextMenu)
// Double-click to show window
tray.on('double-click', () => {
mainWindow?.show()
mainWindow?.focus()
})
return tray
}
export function updateTrayMenu(
mainWindow: BrowserWindow | null,
isTracking: boolean,
currentGame?: string
) {
if (!tray) return
const statusLabel = isTracking
? `Отслеживание: ${currentGame || 'Активно'}`
: 'Отслеживание: Неактивно'
const contextMenu = Menu.buildFromTemplate([
{
label: 'Открыть',
click: () => {
mainWindow?.show()
mainWindow?.focus()
},
},
{ type: 'separator' },
{
label: statusLabel,
enabled: false,
},
{ type: 'separator' },
{
label: 'Выход',
click: () => {
app.isQuitting = true
app.quit()
},
},
])
tray.setContextMenu(contextMenu)
}
export function destroyTray() {
if (tray) {
tray.destroy()
tray = null
}
}
export { tray }

184
desktop/src/main/updater.ts Normal file
View File

@@ -0,0 +1,184 @@
import { autoUpdater } from 'electron-updater'
import { BrowserWindow, ipcMain, app } from 'electron'
import * as path from 'path'
let splashWindow: BrowserWindow | null = null
export function createSplashWindow(): BrowserWindow {
splashWindow = new BrowserWindow({
width: 350,
height: 250,
frame: false,
transparent: false,
resizable: false,
center: true,
backgroundColor: '#0d0e14',
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
})
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
if (isDev) {
// In dev mode: __dirname is dist/main/main/, need to go up 3 levels to project root
splashWindow.loadFile(path.join(__dirname, '../../../src/renderer/splash.html'))
} else {
// In production: __dirname is dist/main/main/, so go up twice to dist/renderer/
splashWindow.loadFile(path.join(__dirname, '../../renderer/splash.html'))
}
return splashWindow
}
export function closeSplashWindow() {
if (splashWindow) {
splashWindow.close()
splashWindow = null
}
}
function sendStatusToSplash(status: string) {
if (splashWindow) {
splashWindow.webContents.send('update-status', status)
}
}
function sendProgressToSplash(percent: number) {
if (splashWindow) {
splashWindow.webContents.send('update-progress', percent)
}
}
export function setupAutoUpdater(onComplete: () => void) {
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
let hasCompleted = false
const safeComplete = () => {
if (hasCompleted) return
hasCompleted = true
closeSplashWindow()
onComplete()
}
// In development, skip update check
if (isDev) {
console.log('[Updater] Skipping update check in development mode')
sendStatusToSplash('Режим разработки')
setTimeout(safeComplete, 1500)
return
}
// Configure auto-updater
autoUpdater.autoDownload = true
autoUpdater.autoInstallOnAppQuit = true
// Check for updates (use 'once' to prevent handlers from triggering on manual update checks)
autoUpdater.once('checking-for-update', () => {
console.log('[Updater] Checking for updates...')
sendStatusToSplash('Проверка обновлений...')
})
autoUpdater.once('update-available', (info) => {
console.log('[Updater] Update available:', info.version)
sendStatusToSplash(`Найдено обновление v${info.version}`)
})
autoUpdater.once('update-not-available', () => {
console.log('[Updater] No updates available')
sendStatusToSplash('Актуальная версия')
setTimeout(safeComplete, 1000)
})
autoUpdater.on('download-progress', (progress) => {
const percent = Math.round(progress.percent)
console.log(`[Updater] Download progress: ${percent}%`)
sendStatusToSplash(`Загрузка обновления... ${percent}%`)
sendProgressToSplash(percent)
})
autoUpdater.once('update-downloaded', (info) => {
console.log('[Updater] Update downloaded:', info.version)
sendStatusToSplash('Установка обновления...')
// Install and restart
setTimeout(() => {
autoUpdater.quitAndInstall(false, true)
}, 1500)
})
autoUpdater.once('error', (error) => {
console.error('[Updater] Error:', error.message)
console.error('[Updater] Error stack:', error.stack)
sendStatusToSplash('Запуск...')
setTimeout(safeComplete, 1500)
})
// Start checking
autoUpdater.checkForUpdates().catch((error) => {
console.error('[Updater] Failed to check for updates:', error)
sendStatusToSplash('Запуск...')
setTimeout(safeComplete, 1500)
})
}
// Manual check for updates (from settings)
export function checkForUpdatesManual(): Promise<{ available: boolean; version?: string; error?: string }> {
return new Promise((resolve) => {
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
if (isDev) {
resolve({ available: false, error: 'В режиме разработки обновления недоступны' })
return
}
const onUpdateAvailable = (info: { version: string }) => {
cleanup()
resolve({ available: true, version: info.version })
}
const onUpdateNotAvailable = () => {
cleanup()
resolve({ available: false })
}
const onError = (error: Error) => {
cleanup()
resolve({ available: false, error: error.message })
}
const cleanup = () => {
autoUpdater.off('update-available', onUpdateAvailable)
autoUpdater.off('update-not-available', onUpdateNotAvailable)
autoUpdater.off('error', onError)
}
autoUpdater.on('update-available', onUpdateAvailable)
autoUpdater.on('update-not-available', onUpdateNotAvailable)
autoUpdater.on('error', onError)
autoUpdater.checkForUpdates().catch((error) => {
cleanup()
resolve({ available: false, error: error.message })
})
})
}
// Setup IPC handlers for updates
export function setupUpdateIpcHandlers() {
ipcMain.handle('get-app-version', () => {
return app.getVersion()
})
ipcMain.handle('check-for-updates', async () => {
return await checkForUpdatesManual()
})
ipcMain.handle('download-update', () => {
autoUpdater.downloadUpdate()
})
ipcMain.handle('install-update', () => {
autoUpdater.quitAndInstall(false, true)
})
}

View File

@@ -0,0 +1,97 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { AppSettings, TrackedProcess, SteamGame, TrackedGame, TrackingStats, User, LoginResponse } from '../shared/types'
interface ApiResult<T> {
success: boolean
data?: T
error?: string
status?: number
}
// Expose protected methods that allow the renderer process to use
// ipcRenderer without exposing the entire object
const electronAPI = {
// Settings
getSettings: (): Promise<AppSettings> => ipcRenderer.invoke('get-settings'),
saveSettings: (settings: Partial<AppSettings>): Promise<void> =>
ipcRenderer.invoke('save-settings', settings),
// Auth (local storage)
getToken: (): Promise<string | null> => ipcRenderer.invoke('get-token'),
saveToken: (token: string): Promise<void> => ipcRenderer.invoke('save-token', token),
clearToken: (): Promise<void> => ipcRenderer.invoke('clear-token'),
// API calls (through main process - no CORS)
apiLogin: (login: string, password: string): Promise<ApiResult<LoginResponse>> =>
ipcRenderer.invoke('api-login', login, password),
api2faVerify: (sessionId: number, code: string): Promise<ApiResult<LoginResponse>> =>
ipcRenderer.invoke('api-2fa-verify', sessionId, code),
apiGetMe: (): Promise<ApiResult<User>> =>
ipcRenderer.invoke('api-get-me'),
apiRequest: <T>(method: string, endpoint: string, data?: unknown): Promise<ApiResult<T>> =>
ipcRenderer.invoke('api-request', method, endpoint, data),
// Process tracking
getRunningProcesses: (): Promise<TrackedProcess[]> =>
ipcRenderer.invoke('get-running-processes'),
getForegroundWindow: (): Promise<string | null> =>
ipcRenderer.invoke('get-foreground-window'),
getTrackingStats: (): Promise<TrackingStats> =>
ipcRenderer.invoke('get-tracking-stats'),
// Steam
getSteamGames: (): Promise<SteamGame[]> => ipcRenderer.invoke('get-steam-games'),
getSteamPath: (): Promise<string | null> => ipcRenderer.invoke('get-steam-path'),
// Tracked games
getTrackedGames: (): Promise<TrackedGame[]> => ipcRenderer.invoke('get-tracked-games'),
addTrackedGame: (game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>): Promise<TrackedGame> =>
ipcRenderer.invoke('add-tracked-game', game),
removeTrackedGame: (gameId: string): Promise<void> =>
ipcRenderer.invoke('remove-tracked-game', gameId),
// Window controls
minimizeToTray: (): void => ipcRenderer.send('minimize-to-tray'),
closeWindow: (): void => ipcRenderer.send('close-window'),
quitApp: (): void => ipcRenderer.send('quit-app'),
// Monitoring control
startMonitoring: (): Promise<boolean> => ipcRenderer.invoke('start-monitoring'),
stopMonitoring: (): Promise<boolean> => ipcRenderer.invoke('stop-monitoring'),
getMonitoringStatus: (): Promise<boolean> => ipcRenderer.invoke('get-monitoring-status'),
// Updates
getAppVersion: (): Promise<string> => ipcRenderer.invoke('get-app-version'),
checkForUpdates: (): Promise<{ available: boolean; version?: string; error?: string }> =>
ipcRenderer.invoke('check-for-updates'),
installUpdate: (): Promise<void> => ipcRenderer.invoke('install-update'),
// Events
onTrackingUpdate: (callback: (stats: TrackingStats) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, stats: TrackingStats) => callback(stats)
ipcRenderer.on('tracking-update', subscription)
return () => ipcRenderer.removeListener('tracking-update', subscription)
},
onGameStarted: (callback: (gameName: string, gameId: string) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, gameName: string, gameId: string) => callback(gameName, gameId)
ipcRenderer.on('game-started', subscription)
return () => ipcRenderer.removeListener('game-started', subscription)
},
onGameStopped: (callback: (gameName: string, duration: number) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, gameName: string, duration: number) =>
callback(gameName, duration)
ipcRenderer.on('game-stopped', subscription)
return () => ipcRenderer.removeListener('game-stopped', subscription)
},
}
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
// Type declaration for renderer process
declare global {
interface Window {
electronAPI: typeof electronAPI
}
}

View File

@@ -0,0 +1,65 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useEffect } from 'react'
import { useAuthStore } from './store/auth'
import { Layout } from './components/Layout'
import { LoginPage } from './pages/LoginPage'
import { DashboardPage } from './pages/DashboardPage'
import { SettingsPage } from './pages/SettingsPage'
import { GamesPage } from './pages/GamesPage'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuthStore()
if (isLoading) {
return (
<div className="min-h-screen bg-dark-900 flex items-center justify-center">
<div className="animate-spin w-8 h-8 border-2 border-neon-500 border-t-transparent rounded-full" />
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <Layout>{children}</Layout>
}
export default function App() {
const { syncUser } = useAuthStore()
useEffect(() => {
syncUser()
}, [syncUser])
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/games"
element={
<ProtectedRoute>
<GamesPage />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<SettingsPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

View File

@@ -0,0 +1,70 @@
import { ReactNode } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { Gamepad2, Settings, LayoutDashboard, X, Minus } from 'lucide-react'
import { clsx } from 'clsx'
interface LayoutProps {
children: ReactNode
}
export function Layout({ children }: LayoutProps) {
const location = useLocation()
const navItems = [
{ path: '/', icon: LayoutDashboard, label: 'Главная' },
{ path: '/games', icon: Gamepad2, label: 'Игры' },
{ path: '/settings', icon: Settings, label: 'Настройки' },
]
return (
<div className="h-screen bg-dark-900 flex flex-col overflow-hidden">
{/* Custom title bar */}
<div className="titlebar h-8 bg-dark-950 flex items-center justify-between px-2 border-b border-dark-700">
<div className="flex items-center gap-2">
<Gamepad2 className="w-4 h-4 text-neon-500" />
<span className="text-xs font-medium text-gray-400">Game Marathon Tracker</span>
</div>
<div className="flex items-center">
<button
onClick={() => window.electronAPI.minimizeToTray()}
className="w-8 h-8 flex items-center justify-center hover:bg-dark-700 transition-colors"
>
<Minus className="w-4 h-4 text-gray-400" />
</button>
<button
onClick={() => window.electronAPI.closeWindow()}
className="w-8 h-8 flex items-center justify-center hover:bg-red-600 transition-colors"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
</div>
{/* Main content */}
<div className="flex-1 overflow-auto p-4">{children}</div>
{/* Bottom navigation */}
<nav className="bg-dark-800 border-t border-dark-700 px-2 py-2">
<div className="flex justify-around">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
clsx(
'flex flex-col items-center gap-1 px-4 py-2 rounded-lg transition-all',
isActive
? 'text-neon-500 bg-neon-500/10'
: 'text-gray-400 hover:text-gray-300 hover:bg-dark-700'
)
}
>
<item.icon className="w-5 h-5" />
<span className="text-xs">{item.label}</span>
</NavLink>
))}
</div>
</nav>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { type ReactNode, type HTMLAttributes } from 'react'
import { clsx } from 'clsx'
interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode
variant?: 'default' | 'dark' | 'neon'
hover?: boolean
glow?: boolean
className?: string
}
export function GlassCard({
children,
variant = 'default',
hover = false,
glow = false,
className,
...props
}: GlassCardProps) {
const variantClasses = {
default: 'glass',
dark: 'glass-dark',
neon: 'glass-neon',
}
return (
<div
className={clsx(
'rounded-xl p-4',
variantClasses[variant],
hover && 'card-hover cursor-pointer',
glow && 'neon-glow-pulse',
className
)}
{...props}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,48 @@
import { forwardRef, type InputHTMLAttributes } from 'react'
import { clsx } from 'clsx'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
icon?: React.ReactNode
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, icon, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-gray-300 mb-1.5">
{label}
</label>
)}
<div className="relative">
{icon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
{icon}
</div>
)}
<input
ref={ref}
className={clsx(
'w-full bg-dark-800 border border-dark-600 rounded-lg px-4 py-2.5',
'text-white placeholder-gray-500',
'transition-all duration-200',
'focus:border-neon-500/50 focus:ring-2 focus:ring-neon-500/10',
'disabled:opacity-50 disabled:cursor-not-allowed',
icon && 'pl-10',
error && 'border-red-500 focus:border-red-500 focus:ring-red-500/10',
className
)}
{...props}
/>
</div>
{error && (
<p className="mt-1.5 text-sm text-red-400">{error}</p>
)}
</div>
)
}
)
Input.displayName = 'Input'

View File

@@ -0,0 +1,118 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
import { clsx } from 'clsx'
import { Loader2 } from 'lucide-react'
interface NeonButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
color?: 'neon' | 'purple' | 'pink'
isLoading?: boolean
icon?: ReactNode
iconPosition?: 'left' | 'right'
glow?: boolean
}
export const NeonButton = forwardRef<HTMLButtonElement, NeonButtonProps>(
(
{
className,
variant = 'primary',
size = 'md',
color = 'neon',
isLoading,
icon,
iconPosition = 'left',
glow = true,
children,
disabled,
...props
},
ref
) => {
const colorMap = {
neon: {
primary: 'bg-neon-500 hover:bg-neon-400 text-dark-900',
secondary: 'bg-dark-600 hover:bg-dark-500 text-neon-400 border border-neon-500/30',
outline: 'bg-transparent border-2 border-neon-500 text-neon-500 hover:bg-neon-500 hover:text-dark-900',
ghost: 'bg-transparent hover:bg-neon-500/10 text-neon-400',
danger: 'bg-red-600 hover:bg-red-700 text-white',
glow: '0 0 12px rgba(34, 211, 238, 0.4)',
glowHover: '0 0 18px rgba(34, 211, 238, 0.55)',
},
purple: {
primary: 'bg-accent-500 hover:bg-accent-400 text-white',
secondary: 'bg-dark-600 hover:bg-dark-500 text-accent-400 border border-accent-500/30',
outline: 'bg-transparent border-2 border-accent-500 text-accent-500 hover:bg-accent-500 hover:text-white',
ghost: 'bg-transparent hover:bg-accent-500/10 text-accent-400',
danger: 'bg-red-600 hover:bg-red-700 text-white',
glow: '0 0 12px rgba(139, 92, 246, 0.4)',
glowHover: '0 0 18px rgba(139, 92, 246, 0.55)',
},
pink: {
primary: 'bg-pink-500 hover:bg-pink-400 text-white',
secondary: 'bg-dark-600 hover:bg-dark-500 text-pink-400 border border-pink-500/30',
outline: 'bg-transparent border-2 border-pink-500 text-pink-500 hover:bg-pink-500 hover:text-white',
ghost: 'bg-transparent hover:bg-pink-500/10 text-pink-400',
danger: 'bg-red-600 hover:bg-red-700 text-white',
glow: '0 0 12px rgba(244, 114, 182, 0.4)',
glowHover: '0 0 18px rgba(244, 114, 182, 0.55)',
},
}
const iconSizes = {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
}
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2.5 text-base gap-2',
lg: 'px-6 py-3 text-lg gap-2.5',
}
const colors = colorMap[color]
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={clsx(
'inline-flex items-center justify-center font-semibold rounded-lg',
'transition-all duration-300 ease-out',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-dark-900',
color === 'neon' && 'focus:ring-neon-500',
color === 'purple' && 'focus:ring-accent-500',
color === 'pink' && 'focus:ring-pink-500',
colors[variant],
sizeClasses[size],
className
)}
style={{
boxShadow: glow && !disabled && variant !== 'ghost' ? colors.glow : undefined,
}}
onMouseEnter={(e) => {
if (glow && !disabled && variant !== 'ghost') {
e.currentTarget.style.boxShadow = colors.glowHover
}
props.onMouseEnter?.(e)
}}
onMouseLeave={(e) => {
if (glow && !disabled && variant !== 'ghost') {
e.currentTarget.style.boxShadow = colors.glow
}
props.onMouseLeave?.(e)
}}
{...props}
>
{isLoading && <Loader2 className={clsx(iconSizes[size], 'animate-spin')} />}
{!isLoading && icon && iconPosition === 'left' && icon}
{children}
{!isLoading && icon && iconPosition === 'right' && icon}
</button>
)
}
)
NeonButton.displayName = 'NeonButton'

View File

@@ -0,0 +1,134 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #14161e;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #252732;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #2e313d;
}
/* Glass effect */
.glass {
background: rgba(20, 22, 30, 0.8);
backdrop-filter: blur(12px);
border: 1px solid rgba(34, 211, 238, 0.1);
}
.glass-dark {
background: rgba(13, 14, 20, 0.9);
backdrop-filter: blur(16px);
border: 1px solid rgba(34, 211, 238, 0.08);
}
.glass-neon {
background: rgba(20, 22, 30, 0.85);
backdrop-filter: blur(12px);
border: 1px solid rgba(34, 211, 238, 0.2);
box-shadow: 0 0 20px rgba(34, 211, 238, 0.08);
}
/* Neon glow effect */
.neon-glow {
box-shadow: 0 0 8px rgba(34, 211, 238, 0.4), 0 0 16px rgba(34, 211, 238, 0.2);
}
.neon-glow-purple {
box-shadow: 0 0 8px rgba(139, 92, 246, 0.4), 0 0 16px rgba(139, 92, 246, 0.2);
}
.neon-glow-pulse {
animation: glow-pulse 2s ease-in-out infinite;
}
/* Gradient border */
.gradient-border {
position: relative;
background: #14161e;
}
.gradient-border::before {
content: '';
position: absolute;
inset: -1px;
padding: 1px;
border-radius: inherit;
background: linear-gradient(135deg, #22d3ee, #8b5cf6, #f472b6);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0.5;
}
/* Card hover effect */
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-2px);
border-color: rgba(34, 211, 238, 0.3);
box-shadow: 0 4px 20px rgba(34, 211, 238, 0.1);
}
/* Title bar drag region */
.titlebar {
-webkit-app-region: drag;
}
.titlebar button {
-webkit-app-region: no-drag;
}
/* Live indicator */
.live-indicator {
width: 8px;
height: 8px;
background: #22c55e;
border-radius: 50%;
animation: pulse-live 2s infinite;
}
@keyframes pulse-live {
0% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
}
70% {
box-shadow: 0 0 0 6px rgba(34, 197, 94, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
}
}
/* Input focus styles */
input:focus {
outline: none;
border-color: rgba(34, 211, 238, 0.5);
box-shadow: 0 0 0 2px rgba(34, 211, 238, 0.1);
}
/* Selection */
::selection {
background: rgba(34, 211, 238, 0.3);
}
/* Transition utilities */
.transition-all-300 {
transition: all 0.3s ease;
}

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' http://localhost:* https://*; img-src 'self' data: https:">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Orbitron:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<title>Game Marathon Tracker</title>
</head>
<body class="bg-dark-900 text-white antialiased">
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<HashRouter>
<App />
</HashRouter>
</React.StrictMode>
)

View File

@@ -0,0 +1,491 @@
import { useEffect, useRef, useCallback, useState } from 'react'
import { Link } from 'react-router-dom'
import { Clock, Gamepad2, Plus, Trophy, Target, Loader2, ChevronDown, Timer, Play, Square } from 'lucide-react'
import { useTrackingStore } from '../store/tracking'
import { useAuthStore } from '../store/auth'
import { useMarathonStore } from '../store/marathon'
import { GlassCard } from '../components/ui/GlassCard'
import { NeonButton } from '../components/ui/NeonButton'
function formatTime(ms: number): string {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
const remainingMinutes = minutes % 60
return `${hours}ч ${remainingMinutes}м`
} else if (minutes > 0) {
return `${minutes}м`
} else {
return `${seconds}с`
}
}
function formatMinutes(minutes: number): string {
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
if (hours > 0) {
return `${hours}ч ${mins}м`
}
return `${mins}м`
}
function getDifficultyColor(difficulty: string): string {
switch (difficulty) {
case 'easy': return 'text-green-400'
case 'medium': return 'text-yellow-400'
case 'hard': return 'text-red-400'
default: return 'text-gray-400'
}
}
function getDifficultyLabel(difficulty: string): string {
switch (difficulty) {
case 'easy': return 'Легкий'
case 'medium': return 'Средний'
case 'hard': return 'Сложный'
default: return difficulty
}
}
export function DashboardPage() {
const { user } = useAuthStore()
const { trackedGames, stats, currentGame, loadTrackedGames, updateStats } = useTrackingStore()
const {
marathons,
selectedMarathonId,
currentAssignment,
isLoading,
loadMarathons,
selectMarathon,
syncTime
} = useMarathonStore()
// Monitoring state
const [isMonitoring, setIsMonitoring] = useState(false)
const [localSessionSeconds, setLocalSessionSeconds] = useState(0)
// Refs for time tracking sync
const syncIntervalRef = useRef<NodeJS.Timeout | null>(null)
const lastSyncedMinutesRef = useRef<number>(0)
const sessionStartRef = useRef<number | null>(null)
// Check if we should track time: any tracked game is running + active assignment exists
const isTrackingAssignment = !!(currentGame && currentAssignment && currentAssignment.status === 'active')
// Track base minutes at session start to avoid re-adding on each sync
const baseMinutesRef = useRef<number>(0)
// Sync time to server - use refs to avoid dependency issues
const doSyncTime = useCallback(async () => {
const assignment = useMarathonStore.getState().currentAssignment
if (!assignment || assignment.status !== 'active' || !sessionStartRef.current) {
return
}
// Calculate session duration only
const sessionMinutes = Math.floor((Date.now() - sessionStartRef.current) / 60000)
const totalMinutes = baseMinutesRef.current + sessionMinutes
if (totalMinutes !== lastSyncedMinutesRef.current && totalMinutes > 0) {
console.log(`[Sync] Syncing ${totalMinutes} minutes for assignment ${assignment.id} (base: ${baseMinutesRef.current}, session: ${sessionMinutes})`)
await syncTime(totalMinutes)
lastSyncedMinutesRef.current = totalMinutes
}
}, [syncTime])
useEffect(() => {
loadTrackedGames()
loadMarathons()
// Load monitoring status
window.electronAPI.getMonitoringStatus().then(setIsMonitoring)
// Subscribe to tracking updates
const unsubscribe = window.electronAPI.onTrackingUpdate((newStats) => {
updateStats(newStats)
})
// Subscribe to game started event
const unsubGameStarted = window.electronAPI.onGameStarted((gameName, _gameId) => {
console.log(`[Game] Started: ${gameName}`)
sessionStartRef.current = Date.now()
setLocalSessionSeconds(0)
})
// Subscribe to game stopped event
const unsubGameStopped = window.electronAPI.onGameStopped((gameName, _duration) => {
console.log(`[Game] Stopped: ${gameName}`)
sessionStartRef.current = null
setLocalSessionSeconds(0)
})
// Get initial stats
window.electronAPI.getTrackingStats().then(updateStats)
return () => {
unsubscribe()
unsubGameStarted()
unsubGameStopped()
}
}, [loadTrackedGames, loadMarathons, updateStats])
// Setup sync interval and local timer when tracking
useEffect(() => {
let localTimerInterval: NodeJS.Timeout | null = null
if (isTrackingAssignment && currentAssignment) {
// Start session if not already started
if (!sessionStartRef.current) {
sessionStartRef.current = Date.now()
// Store base minutes at session start
baseMinutesRef.current = currentAssignment.tracked_time_minutes || 0
lastSyncedMinutesRef.current = baseMinutesRef.current
console.log(`[Sync] Session started, base minutes: ${baseMinutesRef.current}`)
}
// Setup periodic sync every 60 seconds (don't sync immediately to avoid loops)
syncIntervalRef.current = setInterval(() => {
doSyncTime()
}, 60000)
// Update local timer every second for UI
localTimerInterval = setInterval(() => {
if (sessionStartRef.current) {
setLocalSessionSeconds(Math.floor((Date.now() - sessionStartRef.current) / 1000))
}
}, 1000)
} else {
// Do final sync when game stops
if (sessionStartRef.current) {
doSyncTime()
}
if (syncIntervalRef.current) {
clearInterval(syncIntervalRef.current)
syncIntervalRef.current = null
}
sessionStartRef.current = null
baseMinutesRef.current = 0
setLocalSessionSeconds(0)
}
return () => {
if (syncIntervalRef.current) {
clearInterval(syncIntervalRef.current)
syncIntervalRef.current = null
}
if (localTimerInterval) {
clearInterval(localTimerInterval)
}
}
// Note: doSyncTime is intentionally excluded to avoid infinite loops
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isTrackingAssignment])
// Toggle monitoring
const toggleMonitoring = async () => {
if (isMonitoring) {
await window.electronAPI.stopMonitoring()
setIsMonitoring(false)
} else {
await window.electronAPI.startMonitoring()
setIsMonitoring(true)
}
}
const todayTime = stats?.totalTimeToday || 0
const weekTime = stats?.totalTimeWeek || 0
const selectedMarathon = marathons.find(m => m.id === selectedMarathonId)
const renderCurrentChallenge = () => {
if (isLoading) {
return (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-6 h-6 text-neon-500 animate-spin" />
</div>
)
}
if (marathons.length === 0) {
return (
<p className="text-gray-400 text-sm">
Нет активных марафонов. Присоединитесь к марафону на сайте.
</p>
)
}
if (!currentAssignment) {
return (
<p className="text-gray-400 text-sm">
Нет активного задания. Крутите колесо на сайте!
</p>
)
}
const assignment = currentAssignment
// Playthrough assignment
if (assignment.is_playthrough && assignment.playthrough_info) {
// When actively tracking: use baseMinutesRef (set at session start) + current session
// Otherwise: use tracked_time_minutes from assignment
const baseMinutes = isTrackingAssignment ? baseMinutesRef.current : assignment.tracked_time_minutes
const sessionSeconds = isTrackingAssignment ? localSessionSeconds : 0
const totalSeconds = (baseMinutes * 60) + sessionSeconds
const totalMinutes = Math.floor(totalSeconds / 60)
const trackedHours = totalMinutes / 60
const estimatedPoints = Math.floor(trackedHours * 30)
// Format with seconds when actively tracking
const formatLiveTime = () => {
if (isTrackingAssignment && sessionSeconds > 0) {
const hours = Math.floor(totalSeconds / 3600)
const mins = Math.floor((totalSeconds % 3600) / 60)
const secs = totalSeconds % 60
if (hours > 0) {
return `${hours}ч ${mins}м ${secs}с`
}
return `${mins}м ${secs}с`
}
return formatMinutes(totalMinutes)
}
return (
<div>
<div className="flex items-start gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-white">
Прохождение: {assignment.game.title}
</h3>
{isTrackingAssignment && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-green-500/20 border border-green-500/30 rounded-full text-xs text-green-400">
<div className="live-indicator" />
Идёт запись
</span>
)}
</div>
{assignment.playthrough_info.description && (
<p className="text-sm text-gray-400 mb-2 line-clamp-2">
{assignment.playthrough_info.description}
</p>
)}
<div className="flex items-center gap-3 text-xs flex-wrap">
{totalSeconds > 0 || isTrackingAssignment ? (
<>
<span className="flex items-center gap-1 text-neon-400">
<Timer className="w-3 h-3" />
{formatLiveTime()}
</span>
<span className="text-neon-400 font-medium">
~{estimatedPoints} очков
</span>
</>
) : (
<span className="text-gray-500">
Базово: {assignment.playthrough_info.points} очков
</span>
)}
</div>
</div>
</div>
</div>
)
}
// Challenge assignment
if (assignment.challenge) {
const challenge = assignment.challenge
return (
<div>
<div className="flex items-start gap-3">
<div className="flex-1">
<h3 className="font-medium text-white mb-1">{challenge.title}</h3>
<p className="text-xs text-gray-500 mb-1">{challenge.game.title}</p>
<p className="text-sm text-gray-400 mb-2 line-clamp-2">
{challenge.description}
</p>
<div className="flex items-center gap-3 text-xs">
<span className={getDifficultyColor(challenge.difficulty)}>
[{getDifficultyLabel(challenge.difficulty)}]
</span>
<span className="text-neon-400 font-medium">
+{challenge.points} очков
</span>
{challenge.estimated_time && (
<span className="text-gray-500">
~{challenge.estimated_time} мин
</span>
)}
</div>
</div>
</div>
</div>
)
}
return (
<p className="text-gray-400 text-sm">
Задание загружается...
</p>
)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-display font-bold text-white">
Привет, {user?.nickname || 'Игрок'}!
</h1>
<p className="text-sm text-gray-400">
{isMonitoring ? (currentGame ? `Играет: ${currentGame}` : 'Мониторинг активен') : 'Мониторинг выключен'}
</p>
</div>
<div className="flex items-center gap-2">
{currentGame && isMonitoring && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-500/10 border border-green-500/30 rounded-full">
<div className="live-indicator" />
<span className="text-xs text-green-400 font-medium truncate max-w-[100px]">{currentGame}</span>
</div>
)}
<button
onClick={toggleMonitoring}
className={`p-2 rounded-lg transition-colors ${
isMonitoring
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
: 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
}`}
title={isMonitoring ? 'Остановить мониторинг' : 'Начать мониторинг'}
>
{isMonitoring ? <Square className="w-5 h-5" /> : <Play className="w-5 h-5" />}
</button>
</div>
</div>
{/* Stats cards */}
<div className="grid grid-cols-2 gap-3">
<GlassCard variant="neon" className="p-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-neon-500/10 flex items-center justify-center">
<Clock className="w-5 h-5 text-neon-500" />
</div>
<div>
<p className="text-xs text-gray-400">Сегодня</p>
<p className="text-lg font-bold text-white">{formatTime(todayTime)}</p>
</div>
</div>
</GlassCard>
<GlassCard variant="default" className="p-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-500/10 flex items-center justify-center">
<Trophy className="w-5 h-5 text-accent-500" />
</div>
<div>
<p className="text-xs text-gray-400">За неделю</p>
<p className="text-lg font-bold text-white">{formatTime(weekTime)}</p>
</div>
</div>
</GlassCard>
</div>
{/* Current challenge */}
<GlassCard variant="dark" className="border border-neon-500/20">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<Target className="w-5 h-5 text-neon-500" />
<h2 className="font-semibold text-white">Текущий челлендж</h2>
</div>
{/* Marathon selector */}
{marathons.length > 1 && (
<div className="relative">
<select
value={selectedMarathonId || ''}
onChange={(e) => selectMarathon(Number(e.target.value))}
className="appearance-none bg-dark-800 border border-dark-600 rounded-lg px-3 py-1.5 pr-8 text-xs text-gray-300 focus:outline-none focus:border-neon-500 cursor-pointer"
>
{marathons.map(m => (
<option key={m.id} value={m.id}>
{m.title.length > 30 ? m.title.substring(0, 30) + '...' : m.title}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 pointer-events-none" />
</div>
)}
</div>
{/* Marathon title for single marathon */}
{marathons.length === 1 && selectedMarathon && (
<p className="text-xs text-gray-500 mb-2">{selectedMarathon.title}</p>
)}
{renderCurrentChallenge()}
</GlassCard>
{/* Tracked games */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-white flex items-center gap-2">
<Gamepad2 className="w-5 h-5 text-neon-500" />
Отслеживаемые игры
</h2>
<Link to="/games">
<NeonButton variant="ghost" size="sm" icon={<Plus className="w-4 h-4" />}>
Добавить
</NeonButton>
</Link>
</div>
{trackedGames.length === 0 ? (
<GlassCard variant="dark" className="text-center py-8">
<Gamepad2 className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400 text-sm mb-4">
Нет отслеживаемых игр
</p>
<Link to="/games">
<NeonButton variant="secondary" size="sm">
Добавить игру
</NeonButton>
</Link>
</GlassCard>
) : (
<div className="grid grid-cols-2 gap-2">
{trackedGames.slice(0, 4).map((game) => (
<GlassCard
key={game.id}
variant="default"
hover
className="p-3"
>
<div className="flex items-center gap-2 mb-2">
{currentGame === game.name && <div className="live-indicator" />}
<p className="text-sm font-medium text-white truncate flex-1">
{game.name}
</p>
</div>
<p className="text-xs text-gray-400">
{formatTime(game.totalTime)}
</p>
</GlassCard>
))}
</div>
)}
{trackedGames.length > 4 && (
<Link to="/games" className="block mt-2">
<NeonButton variant="ghost" size="sm" className="w-full">
Показать все ({trackedGames.length})
</NeonButton>
</Link>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,298 @@
import { useEffect, useState } from 'react'
import { Gamepad2, Plus, Trash2, Search, FolderOpen, Cpu, RefreshCw, Loader2 } from 'lucide-react'
import { useTrackingStore } from '../store/tracking'
import { GlassCard } from '../components/ui/GlassCard'
import { NeonButton } from '../components/ui/NeonButton'
import { Input } from '../components/ui/Input'
import type { TrackedProcess } from '@shared/types'
function formatTime(ms: number): string {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
const remainingMinutes = minutes % 60
return `${hours}ч ${remainingMinutes}м`
} else if (minutes > 0) {
return `${minutes}м`
} else {
return `${seconds}с`
}
}
// System processes to filter out
const SYSTEM_PROCESSES = new Set([
'svchost', 'csrss', 'wininit', 'services', 'lsass', 'smss', 'winlogon',
'dwm', 'explorer', 'taskhost', 'conhost', 'spoolsv', 'searchhost',
'runtimebroker', 'sihost', 'fontdrvhost', 'ctfmon', 'dllhost',
'securityhealthservice', 'searchindexer', 'audiodg', 'wudfhost',
'system', 'registry', 'idle', 'memory compression', 'ntoskrnl',
'shellexperiencehost', 'startmenuexperiencehost', 'applicationframehost',
'systemsettings', 'textinputhost', 'searchui', 'cortana', 'lockapp',
'windowsinternal', 'taskhostw', 'wmiprvse', 'msiexec', 'trustedinstaller',
'tiworker', 'smartscreen', 'securityhealthsystray', 'sgrmbroker',
'gamebarpresencewriter', 'gamebar', 'gamebarftserver',
'microsoftedge', 'msedge', 'chrome', 'firefox', 'opera', 'brave',
'discord', 'slack', 'teams', 'zoom', 'skype',
'powershell', 'cmd', 'windowsterminal', 'code', 'devenv',
'node', 'npm', 'electron', 'vite'
])
export function GamesPage() {
const { trackedGames, currentGame, loadTrackedGames, addGame, removeGame } = useTrackingStore()
const [showAddModal, setShowAddModal] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [addMode, setAddMode] = useState<'process' | 'manual'>('process')
const [manualGame, setManualGame] = useState({ name: '', executableName: '' })
const [processes, setProcesses] = useState<TrackedProcess[]>([])
const [isLoadingProcesses, setIsLoadingProcesses] = useState(false)
useEffect(() => {
loadTrackedGames()
}, [loadTrackedGames])
const loadProcesses = async () => {
setIsLoadingProcesses(true)
try {
const procs = await window.electronAPI.getRunningProcesses()
// Filter out system processes and already tracked games
const filtered = procs.filter(p => {
const name = p.name.toLowerCase().replace('.exe', '')
return !SYSTEM_PROCESSES.has(name) &&
!trackedGames.some(tg =>
tg.executableName.toLowerCase().replace('.exe', '') === name
)
})
setProcesses(filtered)
} catch (error) {
console.error('Failed to load processes:', error)
} finally {
setIsLoadingProcesses(false)
}
}
useEffect(() => {
if (showAddModal && addMode === 'process') {
loadProcesses()
}
}, [showAddModal, addMode])
const filteredProcesses = processes.filter(
(proc) =>
proc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(proc.windowTitle && proc.windowTitle.toLowerCase().includes(searchQuery.toLowerCase()))
)
const handleAddProcess = async (process: TrackedProcess) => {
const name = process.windowTitle || process.displayName || process.name.replace('.exe', '')
await addGame({
id: `proc_${Date.now()}`,
name: name,
executableName: process.name,
executablePath: process.executablePath,
})
setShowAddModal(false)
setSearchQuery('')
}
const handleAddManualGame = async () => {
if (!manualGame.name || !manualGame.executableName) return
await addGame({
id: `manual_${Date.now()}`,
name: manualGame.name,
executableName: manualGame.executableName,
})
setShowAddModal(false)
setManualGame({ name: '', executableName: '' })
}
const handleRemoveGame = async (gameId: string) => {
if (confirm('Удалить игру из отслеживания?')) {
await removeGame(gameId)
}
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-display font-bold text-white flex items-center gap-2">
<Gamepad2 className="w-6 h-6 text-neon-500" />
Игры
</h1>
<NeonButton
size="sm"
icon={<Plus className="w-4 h-4" />}
onClick={() => setShowAddModal(true)}
>
Добавить
</NeonButton>
</div>
{/* Games list */}
{trackedGames.length === 0 ? (
<GlassCard variant="dark" className="text-center py-12">
<Gamepad2 className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">Нет игр</h3>
<p className="text-gray-400 text-sm mb-4">
Добавьте игры для отслеживания времени
</p>
<NeonButton onClick={() => setShowAddModal(true)}>
Добавить игру
</NeonButton>
</GlassCard>
) : (
<div className="space-y-2">
{trackedGames.map((game) => (
<GlassCard
key={game.id}
variant="default"
className="flex items-center justify-between"
>
<div className="flex items-center gap-3 min-w-0">
{currentGame === game.name && <div className="live-indicator flex-shrink-0" />}
<div className="min-w-0">
<p className="font-medium text-white truncate">{game.name}</p>
<p className="text-xs text-gray-400">
{formatTime(game.totalTime)} наиграно
{game.steamAppId && ' • Steam'}
</p>
</div>
</div>
<button
onClick={() => handleRemoveGame(game.id)}
className="p-2 text-gray-400 hover:text-red-400 transition-colors flex-shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
</GlassCard>
))}
</div>
)}
{/* Add game modal */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/80 backdrop-blur-sm">
<GlassCard variant="dark" className="w-full max-w-sm mx-4 max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Добавить игру</h2>
<button
onClick={() => setShowAddModal(false)}
className="p-1 text-gray-400 hover:text-white"
>
</button>
</div>
{/* Mode tabs */}
<div className="flex gap-1 mb-4">
<button
onClick={() => setAddMode('process')}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-colors ${
addMode === 'process'
? 'bg-neon-500/20 text-neon-400 border border-neon-500/30'
: 'bg-dark-700 text-gray-400 border border-dark-600'
}`}
>
<Cpu className="w-3.5 h-3.5" />
Процессы
</button>
<button
onClick={() => setAddMode('manual')}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-colors ${
addMode === 'manual'
? 'bg-neon-500/20 text-neon-400 border border-neon-500/30'
: 'bg-dark-700 text-gray-400 border border-dark-600'
}`}
>
<FolderOpen className="w-3.5 h-3.5" />
Вручную
</button>
</div>
{addMode === 'process' && (
<>
<div className="flex gap-2">
<div className="flex-1">
<Input
placeholder="Поиск процесса..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
icon={<Search className="w-4 h-4" />}
/>
</div>
<button
onClick={loadProcesses}
disabled={isLoadingProcesses}
className="p-2.5 bg-dark-700 border border-dark-600 rounded-lg text-gray-400 hover:text-white hover:border-neon-500/50 transition-colors disabled:opacity-50"
title="Обновить список"
>
<RefreshCw className={`w-4 h-4 ${isLoadingProcesses ? 'animate-spin' : ''}`} />
</button>
</div>
<p className="text-xs text-gray-500 mt-2">
Запустите игру и нажмите обновить
</p>
<div className="mt-3 space-y-2 overflow-y-auto max-h-52">
{isLoadingProcesses ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 text-neon-500 animate-spin" />
</div>
) : filteredProcesses.length === 0 ? (
<p className="text-center text-gray-400 text-sm py-4">
{processes.length === 0 ? 'Нет подходящих процессов' : 'Ничего не найдено'}
</p>
) : (
filteredProcesses.slice(0, 20).map((proc) => (
<button
key={proc.id}
onClick={() => handleAddProcess(proc)}
className="w-full flex items-start gap-3 p-2 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors text-left"
>
<Cpu className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<p className="text-sm text-white truncate">
{proc.windowTitle || proc.displayName || proc.name}
</p>
<p className="text-xs text-gray-500 truncate">
{proc.name}
</p>
</div>
</button>
))
)}
</div>
</>
)}
{addMode === 'manual' && (
<div className="space-y-4">
<Input
label="Название игры"
placeholder="Например: Elden Ring"
value={manualGame.name}
onChange={(e) => setManualGame({ ...manualGame, name: e.target.value })}
/>
<Input
label="Имя процесса (exe)"
placeholder="Например: eldenring.exe"
value={manualGame.executableName}
onChange={(e) => setManualGame({ ...manualGame, executableName: e.target.value })}
/>
<NeonButton
className="w-full"
onClick={handleAddManualGame}
disabled={!manualGame.name || !manualGame.executableName}
>
Добавить
</NeonButton>
</div>
)}
</GlassCard>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,183 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Gamepad2, User, Lock, X, Minus, Shield, ArrowLeft } from 'lucide-react'
import { useAuthStore } from '../store/auth'
import { NeonButton } from '../components/ui/NeonButton'
import { Input } from '../components/ui/Input'
export function LoginPage() {
const navigate = useNavigate()
const { login, verify2fa, isLoading, error, clearError, requires2fa, reset2fa } = useAuthStore()
const [formData, setFormData] = useState({
login: '',
password: '',
})
const [twoFactorCode, setTwoFactorCode] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const success = await login(formData.login, formData.password)
if (success) {
navigate('/')
}
}
const handle2faSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const success = await verify2fa(twoFactorCode)
if (success) {
navigate('/')
}
}
const handleBack = () => {
reset2fa()
setTwoFactorCode('')
}
return (
<div className="min-h-screen bg-dark-900 flex flex-col">
{/* Custom title bar */}
<div className="titlebar h-8 bg-dark-950 flex items-center justify-between px-2 border-b border-dark-700">
<div className="flex items-center gap-2">
<Gamepad2 className="w-4 h-4 text-neon-500" />
<span className="text-xs font-medium text-gray-400">Game Marathon Tracker</span>
</div>
<div className="flex items-center">
<button
onClick={() => window.electronAPI.minimizeToTray()}
className="w-8 h-8 flex items-center justify-center hover:bg-dark-700 transition-colors"
>
<Minus className="w-4 h-4 text-gray-400" />
</button>
<button
onClick={() => window.electronAPI.quitApp()}
className="w-8 h-8 flex items-center justify-center hover:bg-red-600 transition-colors"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
</div>
{/* Login form */}
<div className="flex-1 flex items-center justify-center p-6">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-neon-500/10 border border-neon-500/30 mb-4">
{requires2fa ? (
<Shield className="w-8 h-8 text-neon-500" />
) : (
<Gamepad2 className="w-8 h-8 text-neon-500" />
)}
</div>
<h1 className="text-2xl font-display font-bold text-white mb-2">
{requires2fa ? 'Подтверждение' : 'Game Marathon'}
</h1>
<p className="text-gray-400 text-sm">
{requires2fa
? 'Введите код из Telegram'
: 'Войдите в свой аккаунт'}
</p>
</div>
{requires2fa ? (
/* 2FA Form */
<form onSubmit={handle2faSubmit} className="space-y-4">
<Input
label="Код подтверждения"
type="text"
value={twoFactorCode}
onChange={(e) => {
setTwoFactorCode(e.target.value.replace(/\D/g, '').slice(0, 6))
clearError()
}}
icon={<Shield className="w-5 h-5" />}
placeholder="000000"
maxLength={6}
required
autoFocus
/>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<NeonButton
type="submit"
className="w-full"
size="lg"
isLoading={isLoading}
disabled={twoFactorCode.length !== 6}
>
Подтвердить
</NeonButton>
<button
type="button"
onClick={handleBack}
className="w-full flex items-center justify-center gap-2 text-gray-400 hover:text-white transition-colors py-2"
>
<ArrowLeft className="w-4 h-4" />
Назад
</button>
</form>
) : (
/* Login Form */
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Логин"
type="text"
value={formData.login}
onChange={(e) => {
setFormData({ ...formData, login: e.target.value })
clearError()
}}
icon={<User className="w-5 h-5" />}
placeholder="Введите логин"
required
/>
<Input
label="Пароль"
type="password"
value={formData.password}
onChange={(e) => {
setFormData({ ...formData, password: e.target.value })
clearError()
}}
icon={<Lock className="w-5 h-5" />}
placeholder="Введите пароль"
required
/>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<NeonButton
type="submit"
className="w-full"
size="lg"
isLoading={isLoading}
>
Войти
</NeonButton>
</form>
)}
{/* Footer */}
{!requires2fa && (
<p className="text-center text-gray-500 text-xs mt-6">
Нет аккаунта? Зарегистрируйтесь на сайте
</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,267 @@
import { useEffect, useState } from 'react'
import { Settings, Power, Monitor, Clock, Globe, LogOut, Download, RefreshCw, Check, AlertCircle } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../store/auth'
import { GlassCard } from '../components/ui/GlassCard'
import { NeonButton } from '../components/ui/NeonButton'
import { Input } from '../components/ui/Input'
import type { AppSettings } from '@shared/types'
export function SettingsPage() {
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const [settings, setSettings] = useState<AppSettings | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [appVersion, setAppVersion] = useState('')
const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'available' | 'not-available' | 'error'>('idle')
const [updateVersion, setUpdateVersion] = useState('')
const [updateError, setUpdateError] = useState('')
useEffect(() => {
window.electronAPI.getSettings().then(setSettings)
window.electronAPI.getAppVersion().then(setAppVersion)
}, [])
const handleCheckForUpdates = async () => {
setUpdateStatus('checking')
setUpdateError('')
try {
const result = await window.electronAPI.checkForUpdates()
if (result.error) {
setUpdateStatus('error')
setUpdateError(result.error)
} else if (result.available) {
setUpdateStatus('available')
setUpdateVersion(result.version || '')
} else {
setUpdateStatus('not-available')
}
} catch (err) {
setUpdateStatus('error')
setUpdateError('Ошибка проверки')
}
}
const handleInstallUpdate = () => {
window.electronAPI.installUpdate()
}
const handleToggle = async (key: keyof AppSettings, value: boolean) => {
if (!settings) return
setIsSaving(true)
try {
await window.electronAPI.saveSettings({ [key]: value })
setSettings({ ...settings, [key]: value })
} finally {
setIsSaving(false)
}
}
const handleApiUrlChange = async (url: string) => {
if (!settings) return
setSettings({ ...settings, apiUrl: url })
}
const handleApiUrlSave = async () => {
if (!settings) return
setIsSaving(true)
try {
await window.electronAPI.saveSettings({ apiUrl: settings.apiUrl })
} finally {
setIsSaving(false)
}
}
const handleLogout = async () => {
await logout()
navigate('/login')
}
if (!settings) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-2 border-neon-500 border-t-transparent rounded-full" />
</div>
)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-2">
<Settings className="w-6 h-6 text-neon-500" />
<h1 className="text-xl font-display font-bold text-white">Настройки</h1>
</div>
{/* User info */}
<GlassCard variant="neon" className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-neon-500/20 flex items-center justify-center">
<span className="text-lg font-bold text-neon-400">
{user?.nickname?.charAt(0).toUpperCase() || 'U'}
</span>
</div>
<div>
<p className="font-medium text-white">{user?.nickname}</p>
<p className="text-xs text-gray-400">@{user?.login}</p>
</div>
</div>
<NeonButton variant="ghost" size="sm" icon={<LogOut className="w-4 h-4" />} onClick={handleLogout}>
Выйти
</NeonButton>
</GlassCard>
{/* Settings */}
<div className="space-y-2">
{/* Auto-launch */}
<GlassCard variant="dark" className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-500/10 flex items-center justify-center">
<Power className="w-5 h-5 text-accent-500" />
</div>
<div>
<p className="font-medium text-white">Автозапуск</p>
<p className="text-xs text-gray-400">Запускать при старте Windows</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.autoLaunch}
onChange={(e) => handleToggle('autoLaunch', e.target.checked)}
className="sr-only peer"
disabled={isSaving}
/>
<div className="w-11 h-6 bg-dark-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-neon-500"></div>
</label>
</GlassCard>
{/* Minimize to tray */}
<GlassCard variant="dark" className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-neon-500/10 flex items-center justify-center">
<Monitor className="w-5 h-5 text-neon-500" />
</div>
<div>
<p className="font-medium text-white">Сворачивать в трей</p>
<p className="text-xs text-gray-400">При закрытии скрывать в трей</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.minimizeToTray}
onChange={(e) => handleToggle('minimizeToTray', e.target.checked)}
className="sr-only peer"
disabled={isSaving}
/>
<div className="w-11 h-6 bg-dark-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-neon-500"></div>
</label>
</GlassCard>
{/* Tracking interval */}
<GlassCard variant="dark">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-lg bg-pink-500/10 flex items-center justify-center">
<Clock className="w-5 h-5 text-pink-500" />
</div>
<div>
<p className="font-medium text-white">Интервал проверки</p>
<p className="text-xs text-gray-400">Как часто проверять процессы</p>
</div>
</div>
<select
value={settings.trackingInterval}
onChange={(e) => {
const value = Number(e.target.value)
setSettings({ ...settings, trackingInterval: value })
window.electronAPI.saveSettings({ trackingInterval: value })
}}
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-4 py-2 text-white"
>
<option value={3000}>3 секунды</option>
<option value={5000}>5 секунд</option>
<option value={10000}>10 секунд</option>
<option value={30000}>30 секунд</option>
</select>
</GlassCard>
{/* Updates */}
<GlassCard variant="dark">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
<Download className="w-5 h-5 text-green-500" />
</div>
<div>
<p className="font-medium text-white">Обновления</p>
<p className="text-xs text-gray-400">
{updateStatus === 'checking' && 'Проверка...'}
{updateStatus === 'available' && `Доступна v${updateVersion}`}
{updateStatus === 'not-available' && 'Актуальная версия'}
{updateStatus === 'error' && (updateError || 'Ошибка')}
{updateStatus === 'idle' && `Текущая версия: v${appVersion}`}
</p>
</div>
</div>
{updateStatus === 'available' ? (
<NeonButton size="sm" onClick={handleInstallUpdate}>
Установить
</NeonButton>
) : (
<button
onClick={handleCheckForUpdates}
disabled={updateStatus === 'checking'}
className="p-2 rounded-lg bg-dark-700 text-gray-400 hover:text-white hover:bg-dark-600 transition-colors disabled:opacity-50"
>
{updateStatus === 'checking' ? (
<RefreshCw className="w-5 h-5 animate-spin" />
) : updateStatus === 'not-available' ? (
<Check className="w-5 h-5 text-green-500" />
) : updateStatus === 'error' ? (
<AlertCircle className="w-5 h-5 text-red-500" />
) : (
<RefreshCw className="w-5 h-5" />
)}
</button>
)}
</div>
</GlassCard>
{/* API URL (for developers) */}
<GlassCard variant="dark">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-lg bg-gray-500/10 flex items-center justify-center">
<Globe className="w-5 h-5 text-gray-400" />
</div>
<div>
<p className="font-medium text-white">API URL</p>
<p className="text-xs text-gray-400">Для разработки</p>
</div>
</div>
<div className="flex gap-2">
<Input
value={settings.apiUrl}
onChange={(e) => handleApiUrlChange(e.target.value)}
placeholder="http://localhost:8000/api/v1"
className="flex-1"
/>
<NeonButton
variant="secondary"
size="sm"
onClick={handleApiUrlSave}
disabled={isSaving}
>
Сохранить
</NeonButton>
</div>
</GlassCard>
</div>
{/* Version */}
<p className="text-center text-gray-500 text-xs pt-4">
Game Marathon Tracker v{appVersion || '...'}
</p>
</div>
)
}

View File

@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Game Marathon Tracker</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0d0e14;
color: white;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
-webkit-app-region: drag;
}
.logo-img {
width: 120px;
height: 120px;
margin-bottom: 20px;
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); filter: drop-shadow(0 0 10px rgba(34, 211, 238, 0.5)); }
50% { transform: scale(1.05); filter: drop-shadow(0 0 20px rgba(139, 92, 246, 0.7)); }
}
.status {
font-size: 13px;
color: #9ca3af;
text-align: center;
min-height: 20px;
margin-top: 10px;
}
.progress-container {
width: 200px;
height: 4px;
background: rgba(34, 211, 238, 0.1);
border-radius: 2px;
margin-top: 15px;
overflow: hidden;
display: none;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #22d3ee, #8b5cf6);
border-radius: 2px;
width: 0%;
transition: width 0.3s ease;
}
.version {
position: absolute;
bottom: 15px;
font-size: 11px;
color: #6b7280;
}
</style>
</head>
<body>
<img src="logo.jpg" alt="Logo" class="logo-img">
<div class="status" id="status">Проверка обновлений...</div>
<div class="progress-container" id="progressContainer">
<div class="progress-bar" id="progressBar"></div>
</div>
<div class="version" id="version"></div>
<script>
const { ipcRenderer } = require('electron');
// Get current version
ipcRenderer.invoke('get-app-version').then(version => {
document.getElementById('version').textContent = `v${version}`;
});
// Listen for status updates
ipcRenderer.on('update-status', (event, status) => {
document.getElementById('status').textContent = status;
});
// Listen for download progress
ipcRenderer.on('update-progress', (event, percent) => {
const container = document.getElementById('progressContainer');
const bar = document.getElementById('progressBar');
container.style.display = 'block';
bar.style.width = `${percent}%`;
});
</script>
</body>
</html>

View File

@@ -0,0 +1,149 @@
import { create } from 'zustand'
import type { User } from '@shared/types'
interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
isLoading: boolean
error: string | null
// 2FA state
requires2fa: boolean
twoFactorSessionId: number | null
login: (login: string, password: string) => Promise<boolean>
verify2fa: (code: string) => Promise<boolean>
logout: () => Promise<void>
syncUser: () => Promise<void>
clearError: () => void
reset2fa: () => void
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
token: null,
isAuthenticated: false,
isLoading: true,
error: null,
requires2fa: false,
twoFactorSessionId: null,
login: async (login: string, password: string) => {
set({ isLoading: true, error: null, requires2fa: false, twoFactorSessionId: null })
const result = await window.electronAPI.apiLogin(login, password)
if (!result.success) {
set({
isLoading: false,
error: typeof result.error === 'string' ? result.error : 'Ошибка авторизации',
})
return false
}
const response = result.data!
if (response.requires_2fa && response.two_factor_session_id) {
set({
isLoading: false,
requires2fa: true,
twoFactorSessionId: response.two_factor_session_id,
})
return false // Not fully logged in yet
}
if (response.access_token && response.user) {
set({
user: response.user,
token: response.access_token,
isAuthenticated: true,
isLoading: false,
})
return true
}
set({ isLoading: false })
return false
},
verify2fa: async (code: string) => {
const sessionId = get().twoFactorSessionId
if (!sessionId) {
set({ error: 'Нет активной сессии 2FA' })
return false
}
set({ isLoading: true, error: null })
const result = await window.electronAPI.api2faVerify(sessionId, code)
if (!result.success) {
set({
isLoading: false,
error: typeof result.error === 'string' ? result.error : 'Неверный код',
})
return false
}
const response = result.data!
if (response.access_token && response.user) {
set({
user: response.user,
token: response.access_token,
isAuthenticated: true,
isLoading: false,
requires2fa: false,
twoFactorSessionId: null,
})
return true
}
set({ isLoading: false })
return false
},
reset2fa: () => set({ requires2fa: false, twoFactorSessionId: null, error: null }),
logout: async () => {
await window.electronAPI.clearToken()
set({
user: null,
token: null,
isAuthenticated: false,
error: null,
})
},
syncUser: async () => {
set({ isLoading: true })
const token = await window.electronAPI.getToken()
if (!token) {
set({ isLoading: false, isAuthenticated: false })
return
}
const result = await window.electronAPI.apiGetMe()
if (!result.success) {
await window.electronAPI.clearToken()
set({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
})
return
}
set({
user: result.data!,
token,
isAuthenticated: true,
isLoading: false,
})
},
clearError: () => set({ error: null }),
}))

View File

@@ -0,0 +1,123 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { MarathonResponse, AssignmentResponse } from '@shared/types'
interface MarathonState {
marathons: MarathonResponse[]
selectedMarathonId: number | null
currentAssignment: AssignmentResponse | null
isLoading: boolean
error: string | null
loadMarathons: () => Promise<void>
selectMarathon: (marathonId: number) => Promise<void>
loadCurrentAssignment: () => Promise<void>
syncTime: (minutes: number) => Promise<void>
reset: () => void
}
export const useMarathonStore = create<MarathonState>()(
persist(
(set, get) => ({
marathons: [],
selectedMarathonId: null,
currentAssignment: null,
isLoading: false,
error: null,
loadMarathons: async () => {
set({ isLoading: true, error: null })
const result = await window.electronAPI.apiRequest<MarathonResponse[]>('GET', '/marathons')
if (!result.success) {
set({ isLoading: false, error: result.error || 'Failed to load marathons' })
return
}
const marathons = result.data || []
const activeMarathons = marathons.filter(m => m.status === 'active')
set({ marathons: activeMarathons, isLoading: false })
// If we have a selected marathon, verify it's still valid
const { selectedMarathonId } = get()
if (selectedMarathonId) {
const stillExists = activeMarathons.some(m => m.id === selectedMarathonId)
if (!stillExists && activeMarathons.length > 0) {
// Select first available marathon
await get().selectMarathon(activeMarathons[0].id)
} else if (stillExists) {
// Reload assignment for current selection
await get().loadCurrentAssignment()
}
} else if (activeMarathons.length > 0) {
// No selection, select first marathon
await get().selectMarathon(activeMarathons[0].id)
}
},
selectMarathon: async (marathonId: number) => {
set({ selectedMarathonId: marathonId, currentAssignment: null })
await get().loadCurrentAssignment()
},
loadCurrentAssignment: async () => {
const { selectedMarathonId } = get()
if (!selectedMarathonId) {
set({ currentAssignment: null })
return
}
const result = await window.electronAPI.apiRequest<AssignmentResponse | null>(
'GET',
`/marathons/${selectedMarathonId}/current-assignment`
)
if (result.success) {
set({ currentAssignment: result.data ?? null })
} else {
// User might not be participant of this marathon
set({ currentAssignment: null, error: result.error })
}
},
syncTime: async (minutes: number) => {
const { currentAssignment } = get()
if (!currentAssignment || currentAssignment.status !== 'active') {
return
}
const result = await window.electronAPI.apiRequest(
'PATCH',
`/assignments/${currentAssignment.id}/track-time`,
{ minutes }
)
if (result.success) {
// Update local assignment with new tracked time
set({
currentAssignment: {
...currentAssignment,
tracked_time_minutes: minutes
}
})
}
},
reset: () => {
set({
marathons: [],
selectedMarathonId: null,
currentAssignment: null,
isLoading: false,
error: null
})
}
}),
{
name: 'marathon-storage',
partialize: (state) => ({ selectedMarathonId: state.selectedMarathonId })
}
)
)

View File

@@ -0,0 +1,81 @@
import { create } from 'zustand'
import type { TrackedGame, TrackingStats, SteamGame } from '@shared/types'
interface TrackingState {
trackedGames: TrackedGame[]
steamGames: SteamGame[]
stats: TrackingStats | null
currentGame: string | null
isLoading: boolean
loadTrackedGames: () => Promise<void>
loadSteamGames: () => Promise<void>
addGame: (game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>) => Promise<void>
removeGame: (gameId: string) => Promise<void>
updateStats: (stats: TrackingStats) => void
setCurrentGame: (gameName: string | null) => void
}
export const useTrackingStore = create<TrackingState>((set) => ({
trackedGames: [],
steamGames: [],
stats: null,
currentGame: null,
isLoading: false,
loadTrackedGames: async () => {
set({ isLoading: true })
try {
const games = await window.electronAPI.getTrackedGames()
set({ trackedGames: games, isLoading: false })
} catch (error) {
console.error('Failed to load tracked games:', error)
set({ isLoading: false })
}
},
loadSteamGames: async () => {
try {
const games = await window.electronAPI.getSteamGames()
set({ steamGames: games })
} catch (error) {
console.error('Failed to load Steam games:', error)
}
},
addGame: async (game) => {
try {
const newGame = await window.electronAPI.addTrackedGame(game)
set((state) => ({
trackedGames: [...state.trackedGames, newGame],
}))
} catch (error) {
console.error('Failed to add game:', error)
throw error
}
},
removeGame: async (gameId) => {
try {
await window.electronAPI.removeTrackedGame(gameId)
set((state) => ({
trackedGames: state.trackedGames.filter((g) => g.id !== gameId),
}))
} catch (error) {
console.error('Failed to remove game:', error)
throw error
}
},
updateStats: (stats) => {
if (stats.currentGame) {
console.log('[Tracking] Current game:', stats.currentGame, 'Session:', Math.floor((stats.currentSessionDuration || 0) / 1000), 's')
}
set({
stats,
currentGame: stats.currentGame || null
})
},
setCurrentGame: (gameName) => set({ currentGame: gameName }),
}))

54
desktop/src/renderer/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,54 @@
/// <reference types="vite/client" />
import type { AppSettings, TrackedProcess, SteamGame, TrackedGame, TrackingStats, User, LoginResponse } from '@shared/types'
interface ApiResult<T> {
success: boolean
data?: T
error?: string
status?: number
}
declare global {
interface Window {
electronAPI: {
// Settings
getSettings: () => Promise<AppSettings>
saveSettings: (settings: Partial<AppSettings>) => Promise<void>
// Auth (local storage)
getToken: () => Promise<string | null>
saveToken: (token: string) => Promise<void>
clearToken: () => Promise<void>
// API calls (through main process - no CORS)
apiLogin: (login: string, password: string) => Promise<ApiResult<LoginResponse>>
api2faVerify: (sessionId: number, code: string) => Promise<ApiResult<LoginResponse>>
apiGetMe: () => Promise<ApiResult<User>>
apiRequest: <T>(method: string, endpoint: string, data?: unknown) => Promise<ApiResult<T>>
// Process tracking
getRunningProcesses: () => Promise<TrackedProcess[]>
getForegroundWindow: () => Promise<string | null>
getTrackingStats: () => Promise<TrackingStats>
// Steam
getSteamGames: () => Promise<SteamGame[]>
getSteamPath: () => Promise<string | null>
// Tracked games
getTrackedGames: () => Promise<TrackedGame[]>
addTrackedGame: (game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>) => Promise<TrackedGame>
removeTrackedGame: (gameId: string) => Promise<void>
// Window controls
minimizeToTray: () => void
quitApp: () => void
// Events
onTrackingUpdate: (callback: (stats: TrackingStats) => void) => () => void
onGameStarted: (callback: (gameName: string) => void) => () => void
onGameStopped: (callback: (gameName: string, duration: number) => void) => () => void
}
}
}

226
desktop/src/shared/types.ts Normal file
View File

@@ -0,0 +1,226 @@
// Shared types between main and renderer processes
export interface User {
id: number
login: string
nickname: string
avatar_url?: string
role: 'USER' | 'ADMIN'
}
export interface TokenResponse {
access_token: string
token_type: string
user: User
}
export interface LoginResponse {
access_token?: string
token_type?: string
user?: User
requires_2fa?: boolean
two_factor_session_id?: number
}
// API Response types
export interface MarathonResponse {
id: number
title: string
description?: string
status: 'preparing' | 'active' | 'finished'
start_date?: string
end_date?: string
cover_url?: string
is_public: boolean
participation?: {
is_organizer: boolean
points: number
completed_count: number
dropped_count: number
}
}
export interface GameShort {
id: number
title: string
cover_url?: string
download_url?: string
game_type?: 'challenges' | 'playthrough'
}
export interface ChallengeResponse {
id: number
title: string
description: string
type: string
difficulty: 'easy' | 'medium' | 'hard'
points: number
estimated_time?: number
proof_type: string
proof_hint?: string
game: GameShort
is_generated: boolean
}
export interface PlaythroughInfo {
description?: string
points: number
proof_type: string
proof_hint?: string
}
export interface AssignmentResponse {
id: number
challenge?: ChallengeResponse
game: GameShort
is_playthrough: boolean
playthrough_info?: PlaythroughInfo
status: 'active' | 'completed' | 'dropped' | 'returned'
proof_url?: string
proof_comment?: string
points_earned: number
tracked_time_minutes: number
started_at: string
completed_at?: string
can_drop: boolean
drop_penalty: number
event_type?: string
}
export interface Marathon {
id: number
title: string
description?: string
status: 'lobby' | 'active' | 'finished'
start_date: string
end_date?: string
cover_url?: string
}
export interface Game {
id: number
name: string
genre?: string
cover_url?: string
marathon_id: number
}
export interface Challenge {
id: number
game_id: number
title: string
description: string
type: string
difficulty: 'easy' | 'medium' | 'hard'
points: number
estimated_time: number
proof_type: string
proof_hint?: string
}
export interface Assignment {
id: number
participant_id: number
game_id: number
challenge_id?: number
game: Game
challenge?: Challenge
status: 'active' | 'completed' | 'dropped'
started_at: string
completed_at?: string
time_spent_minutes?: number
}
export interface CurrentChallenge {
marathon: Marathon
assignment?: Assignment
}
// Process tracking types
export interface TrackedProcess {
id: string
name: string
displayName: string
executablePath?: string
windowTitle?: string
isGame: boolean
steamAppId?: string
}
export interface GameSession {
gameId: string
gameName: string
startTime: number
endTime?: number
duration: number
isActive: boolean
}
export interface TrackingStats {
totalTimeToday: number
totalTimeWeek: number
totalTimeMonth: number
sessions: GameSession[]
currentGame?: string | null
currentSessionDuration?: number
}
export interface SteamGame {
appId: string
name: string
installDir: string
executable?: string
iconPath?: string
}
export interface TrackedGame {
id: string
name: string
executableName: string
executablePath?: string
steamAppId?: string
iconPath?: string
totalTime: number
lastPlayed?: number
}
export interface AppSettings {
autoLaunch: boolean
minimizeToTray: boolean
trackingInterval: number
apiUrl: string
theme: 'dark'
}
// IPC Channel types
export interface IpcChannels {
// Settings
'get-settings': () => AppSettings
'save-settings': (settings: Partial<AppSettings>) => void
// Auth
'get-token': () => string | null
'save-token': (token: string) => void
'clear-token': () => void
// Process tracking
'get-running-processes': () => TrackedProcess[]
'get-foreground-window': () => string | null
'start-tracking': (processName: string) => void
'stop-tracking': () => void
'get-tracking-stats': () => TrackingStats
// Steam
'get-steam-games': () => SteamGame[]
'get-steam-path': () => string | null
// Tracked games
'get-tracked-games': () => TrackedGame[]
'add-tracked-game': (game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>) => TrackedGame
'remove-tracked-game': (gameId: string) => void
'update-game-time': (gameId: string, time: number) => void
// Window
'minimize-to-tray': () => void
'quit-app': () => void
}

147
desktop/tailwind.config.js Normal file
View File

@@ -0,0 +1,147 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/renderer/index.html",
"./src/renderer/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
dark: {
950: '#08090d',
900: '#0d0e14',
800: '#14161e',
700: '#1c1e28',
600: '#252732',
500: '#2e313d',
},
neon: {
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#67e8f9',
500: '#22d3ee',
600: '#06b6d4',
700: '#0891b2',
800: '#155e75',
900: '#164e63',
},
accent: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
},
pink: {
400: '#f472b6',
500: '#ec4899',
600: '#db2777',
},
primary: {
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#67e8f9',
500: '#22d3ee',
600: '#06b6d4',
700: '#0891b2',
800: '#155e75',
900: '#164e63',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
display: ['Orbitron', 'sans-serif'],
},
animation: {
'spin-slow': 'spin 3s linear infinite',
'fade-in': 'fade-in 0.3s ease-out forwards',
'slide-up': 'slide-up 0.3s ease-out forwards',
'glow-pulse': 'glow-pulse 2s ease-in-out infinite',
'float': 'float 6s ease-in-out infinite',
'shimmer': 'shimmer 2s linear infinite',
'slide-in-up': 'slide-in-up 0.4s ease-out forwards',
'scale-in': 'scale-in 0.2s ease-out forwards',
'bounce-in': 'bounce-in 0.5s ease-out forwards',
'pulse-neon': 'pulse-neon 2s ease-in-out infinite',
},
keyframes: {
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'slide-up': {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'glow-pulse': {
'0%, 100%': {
boxShadow: '0 0 6px rgba(34, 211, 238, 0.4), 0 0 12px rgba(34, 211, 238, 0.2)'
},
'50%': {
boxShadow: '0 0 10px rgba(34, 211, 238, 0.5), 0 0 20px rgba(34, 211, 238, 0.3)'
},
},
'float': {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-10px)' },
},
'shimmer': {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
},
'slide-in-up': {
'0%': { opacity: '0', transform: 'translateY(30px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'scale-in': {
'0%': { opacity: '0', transform: 'scale(0.9)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
'bounce-in': {
'0%': { opacity: '0', transform: 'scale(0.3)' },
'50%': { transform: 'scale(1.05)' },
'70%': { transform: 'scale(0.9)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
'pulse-neon': {
'0%, 100%': {
textShadow: '0 0 6px rgba(34, 211, 238, 0.5), 0 0 12px rgba(34, 211, 238, 0.25)'
},
'50%': {
textShadow: '0 0 10px rgba(34, 211, 238, 0.6), 0 0 18px rgba(34, 211, 238, 0.35)'
},
},
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'neon-glow': 'linear-gradient(90deg, #22d3ee, #8b5cf6, #22d3ee)',
'cyber-grid': `
linear-gradient(rgba(34, 211, 238, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(34, 211, 238, 0.02) 1px, transparent 1px)
`,
},
backgroundSize: {
'grid': '50px 50px',
},
boxShadow: {
'neon': '0 0 8px rgba(34, 211, 238, 0.4), 0 0 16px rgba(34, 211, 238, 0.2)',
'neon-lg': '0 0 12px rgba(34, 211, 238, 0.5), 0 0 24px rgba(34, 211, 238, 0.3)',
'neon-purple': '0 0 8px rgba(139, 92, 246, 0.4), 0 0 16px rgba(139, 92, 246, 0.2)',
'neon-pink': '0 0 8px rgba(244, 114, 182, 0.4), 0 0 16px rgba(244, 114, 182, 0.2)',
'inner-glow': 'inset 0 0 20px rgba(34, 211, 238, 0.06)',
'glass': '0 8px 32px 0 rgba(0, 0, 0, 0.37)',
},
},
},
plugins: [],
}

26
desktop/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/renderer/*"],
"@shared/*": ["src/shared/*"]
}
},
"include": ["src/renderer/**/*", "src/shared/**/*"],
"references": [{ "path": "./tsconfig.main.json" }]
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"lib": ["ES2020"],
"outDir": "dist/main",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"declarationMap": false,
"sourceMap": true
},
"include": ["src/main/**/*", "src/shared/**/*", "src/preload/**/*"]
}

22
desktop/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
root: 'src/renderer',
base: './',
build: {
outDir: '../../dist/renderer',
emptyOutDir: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/renderer'),
'@shared': path.resolve(__dirname, 'src/shared'),
},
},
server: {
port: 5173,
},
})

669
docs/tz-obs-widget.md Normal file
View File

@@ -0,0 +1,669 @@
# ТЗ: 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 — модель и токены ✅
- [x] Создать модель `WidgetToken`
- [x] Миграция для таблицы `widget_tokens`
- [x] API создания токена (`POST /widgets/marathons/{id}/token`)
- [x] API отзыва токена (`DELETE /widgets/tokens/{id}`)
- [x] API регенерации токена (`POST /widgets/tokens/{id}/regenerate`)
- [x] Валидация токена
### Этап 2: Backend — API виджетов ✅
- [x] Эндпоинт `/widgets/data/leaderboard`
- [x] Эндпоинт `/widgets/data/current`
- [x] Эндпоинт `/widgets/data/progress`
- [x] Схемы ответов
- [ ] Rate limiting
### Этап 3: Frontend — страницы виджетов ✅
- [x] Роутинг `/widget/*`
- [x] Компонент `LeaderboardWidget`
- [x] Компонент `CurrentWidget`
- [x] Компонент `ProgressWidget`
- [x] Polling обновлений (30 сек)
### Этап 4: Frontend — темы и стили ✅
- [x] Базовые стили виджетов
- [x] Тема Dark
- [x] Тема Light
- [x] Тема Neon
- [x] Поддержка прозрачного фона
- [x] Параметры кастомизации через URL (theme, count, avatars, transparent)
### Этап 5: Frontend — страница настроек ✅
- [x] Модальное окно настройки виджетов (WidgetSettingsModal)
- [x] Форма настроек (тема, количество, аватарки, прозрачность)
- [x] Копирование URL
- [x] Превью виджетов (iframe)
- [x] Инструкция по добавлению в OBS
### Этап 6: Тестирование
- [ ] Проверка в OBS Browser Source
- [ ] Тестирование тем
- [ ] Проверка обновления данных
- [ ] Тестирование на разных разрешениях
- [ ] Проверка производительности (polling)
### Не реализовано (опционально)
- [x] Комбинированный виджет
- [ ] Rate limiting для API виджетов
---
## Примеры виджетов
### Лидерборд (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
- **Виджет событий** — показ активных событий марафона
- **Виджет колеса** — мини-версия колеса фортуны

View File

@@ -0,0 +1,789 @@
# ТЗ: Скип с изгнанием, модерация и выдача предметов
## Обзор
Три связанные фичи:
1. **Скип с изгнанием** — новый консамбл, который скипает задание И навсегда исключает игру из пула участника
2. **Модерация марафона** — организаторы могут скипать задания у участников (обычный скип / скип с изгнанием)
3. **Выдача предметов админами** — UI для системных администраторов для выдачи предметов пользователям
---
## 1. Скип с изгнанием (SKIP_EXILE)
### 1.1 Концепция
| Тип скипа | Штраф | Стрик | Игра может выпасть снова |
|-----------|-------|-------|--------------------------|
| Обычный DROP | Да (прогрессивный) | Сбрасывается | Да (для challenges) / Нет (для playthrough) |
| SKIP (консамбл) | Нет | Сохраняется | Да (для challenges) / Нет (для playthrough) |
| **SKIP_EXILE** | Нет | Сохраняется | **Нет** |
### 1.2 Backend
#### Новая модель: ExiledGame
```python
# backend/app/models/exiled_game.py
class ExiledGame(Base):
__tablename__ = "exiled_games"
__table_args__ = (
UniqueConstraint("participant_id", "game_id", name="unique_participant_game_exile"),
)
id: int (PK)
participant_id: int (FK -> participants.id, ondelete=CASCADE)
game_id: int (FK -> games.id, ondelete=CASCADE)
assignment_id: int | None (FK -> assignments.id) # Какое задание было при изгнании
exiled_at: datetime
exiled_by: str # "user" | "organizer" | "admin"
reason: str | None # Опциональная причина
# История восстановления (soft-delete pattern)
is_active: bool = True # False = игра возвращена в пул
unexiled_at: datetime | None
unexiled_by: str | None # "organizer" | "admin"
```
> **Примечание**: При восстановлении игры запись НЕ удаляется, а помечается `is_active=False`.
> Это сохраняет историю изгнаний для аналитики и разрешения споров.
#### Новый ConsumableType
```python
# backend/app/models/shop.py
class ConsumableType(str, Enum):
SKIP = "skip"
SKIP_EXILE = "skip_exile" # NEW
BOOST = "boost"
WILD_CARD = "wild_card"
LUCKY_DICE = "lucky_dice"
COPYCAT = "copycat"
UNDO = "undo"
```
#### Создание предмета в магазине
```python
# Предмет добавляется через админку или миграцию
ShopItem(
item_type="consumable",
code="skip_exile",
name="Скип с изгнанием",
description="Пропустить текущее задание без штрафа. Игра навсегда исключается из вашего пула.",
price=150, # Дороже обычного скипа (50)
rarity="rare",
)
```
#### Сервис: use_skip_exile
```python
# backend/app/services/consumables.py
async def use_skip_exile(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
assignment: Assignment,
) -> dict:
"""
Skip assignment AND exile the game permanently.
- No streak loss
- No drop penalty
- Game is permanently excluded from participant's pool
"""
# Проверки как у обычного skip
if not marathon.allow_skips:
raise HTTPException(400, "Skips not allowed")
if marathon.max_skips_per_participant is not None:
if participant.skips_used >= marathon.max_skips_per_participant:
raise HTTPException(400, "Skip limit reached")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(400, "Can only skip active assignments")
# Получаем game_id
if assignment.is_playthrough:
game_id = assignment.game_id
else:
game_id = assignment.challenge.game_id
# Consume from inventory
item = await self._consume_item(db, user, ConsumableType.SKIP_EXILE.value)
# Mark assignment as dropped (без штрафа)
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Track skip usage
participant.skips_used += 1
# НОВОЕ: Добавляем игру в exiled
exiled = ExiledGame(
participant_id=participant.id,
game_id=game_id,
exiled_by="user",
)
db.add(exiled)
# Log usage
usage = ConsumableUsage(...)
db.add(usage)
return {
"success": True,
"skipped": True,
"exiled": True,
"game_id": game_id,
"penalty": 0,
"streak_preserved": True,
}
```
#### Изменение get_available_games_for_participant
```python
# backend/app/api/v1/games.py
async def get_available_games_for_participant(...):
# ... existing code ...
# НОВОЕ: Получаем изгнанные игры
exiled_result = await db.execute(
select(ExiledGame.game_id)
.where(ExiledGame.participant_id == participant.id)
)
exiled_game_ids = set(exiled_result.scalars().all())
# Фильтруем доступные игры
available_games = []
for game in games_with_content:
# НОВОЕ: Исключаем изгнанные игры
if game.id in exiled_game_ids:
continue
if game.game_type == GameType.PLAYTHROUGH.value:
if game.id not in finished_playthrough_game_ids:
available_games.append(game)
else:
# ...existing logic...
```
### 1.3 Frontend
#### Обновление UI использования консамблов
- В `PlayPage.tsx` добавить кнопку "Скип с изгнанием" рядом с обычным скипом
- Показывать предупреждение: "Игра будет навсегда исключена из вашего пула"
- В инвентаре показывать оба типа скипов отдельно
### 1.4 API Endpoints
```
POST /shop/use
Body: {
"item_code": "skip_exile",
"marathon_id": 123,
"assignment_id": 456
}
Response: {
"success": true,
"remaining_quantity": 2,
"effect_description": "Задание пропущено, игра изгнана",
"effect_data": {
"skipped": true,
"exiled": true,
"game_id": 789,
"penalty": 0,
"streak_preserved": true
}
}
```
---
## 2. Модерация марафона (скипы организаторами)
### 2.1 Концепция
Организаторы марафона могут скипать задания у участников:
- **Скип** — пропустить задание без штрафа (игра может выпасть снова)
- **Скип с изгнанием** — пропустить и исключить игру из пула участника
Причины использования:
- Участник просит пропустить игру (технические проблемы, неподходящая игра)
- Модерация спорных ситуаций
- Исправление ошибок
### 2.2 Backend
#### Новые эндпоинты
```python
# backend/app/api/v1/marathons.py
@router.post("/{marathon_id}/participants/{user_id}/skip-assignment")
async def organizer_skip_assignment(
marathon_id: int,
user_id: int,
data: OrganizerSkipRequest,
current_user: CurrentUser,
db: DbSession,
):
"""
Организатор скипает текущее задание участника.
Body:
exile: bool = False # Если true — скип с изгнанием
reason: str | None # Причина (опционально)
"""
await require_organizer(db, current_user, marathon_id)
# Получаем участника
participant = await get_participant_by_user_id(db, user_id, marathon_id)
if not participant:
raise HTTPException(404, "Participant not found")
# Получаем активное задание
assignment = await get_active_assignment(db, participant.id)
if not assignment:
raise HTTPException(400, "No active assignment")
# Определяем game_id
if assignment.is_playthrough:
game_id = assignment.game_id
else:
game_id = assignment.challenge.game_id
# Скипаем
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# НЕ увеличиваем skips_used (это модераторский скип, не консамбл)
# НЕ сбрасываем стрик
# НЕ увеличиваем drop_count
# Если exile — добавляем в exiled
if data.exile:
exiled = ExiledGame(
participant_id=participant.id,
game_id=game_id,
exiled_by="organizer",
reason=data.reason,
)
db.add(exiled)
# Логируем в Activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.MODERATION.value,
data={
"action": "skip_assignment",
"target_user_id": user_id,
"assignment_id": assignment.id,
"game_id": game_id,
"exile": data.exile,
"reason": data.reason,
}
)
db.add(activity)
await db.commit()
return {"success": True, "exiled": data.exile}
@router.get("/{marathon_id}/participants/{user_id}/exiled-games")
async def get_participant_exiled_games(
marathon_id: int,
user_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Список изгнанных игр участника (для организаторов)"""
await require_organizer(db, current_user, marathon_id)
# ...
@router.delete("/{marathon_id}/participants/{user_id}/exiled-games/{game_id}")
async def remove_exiled_game(
marathon_id: int,
user_id: int,
game_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Убрать игру из изгнанных (вернуть в пул)"""
await require_organizer(db, current_user, marathon_id)
# ...
```
#### Схемы
```python
# backend/app/schemas/marathon.py
class OrganizerSkipRequest(BaseModel):
exile: bool = False
reason: str | None = None
class ExiledGameResponse(BaseModel):
id: int
game_id: int
game_title: str
exiled_at: datetime
exiled_by: str
reason: str | None
```
### 2.3 Frontend
#### Страница участников марафона
В списке участников (`MarathonPage.tsx` или отдельная страница модерации):
```tsx
// Для каждого участника с активным заданием показываем кнопки:
<button onClick={() => skipAssignment(userId, false)}>
Скип
</button>
<button onClick={() => skipAssignment(userId, true)}>
Скип с изгнанием
</button>
```
#### Модальное окно скипа
```tsx
<Modal>
<h2>Скип задания у {participant.nickname}</h2>
<p>Текущее задание: {assignment.game.title}</p>
<label>
<input type="checkbox" checked={exile} onChange={...} />
Изгнать игру (не будет выпадать снова)
</label>
<textarea placeholder="Причина (опционально)" />
<button>Подтвердить</button>
</Modal>
```
### 2.4 Telegram уведомления
При модераторском скипе отправляем уведомление участнику:
```python
# backend/app/services/telegram_notifier.py
async def notify_assignment_skipped_by_moderator(
user: User,
marathon_title: str,
game_title: str,
exiled: bool,
reason: str | None,
moderator_nickname: str,
):
"""Уведомление о скипе задания организатором"""
if not user.telegram_id or not user.notify_moderation:
return
exile_text = "\n🚫 Игра исключена из вашего пула" if exiled else ""
reason_text = f"\n📝 Причина: {reason}" if reason else ""
message = f"""⏭️ <b>Задание пропущено</b>
Марафон: {marathon_title}
Игра: {game_title}
Организатор: {moderator_nickname}{exile_text}{reason_text}
Вы можете крутить колесо заново."""
await self._send_message(user.telegram_id, message)
```
#### Добавить поле notify_moderation в User
```python
# backend/app/models/user.py
class User(Base):
# ... existing fields ...
notify_moderation: bool = True # Уведомления о действиях модераторов
```
#### Интеграция в эндпоинт
```python
# В organizer_skip_assignment после db.commit():
await telegram_notifier.notify_assignment_skipped_by_moderator(
user=target_user,
marathon_title=marathon.title,
game_title=game.title,
exiled=data.exile,
reason=data.reason,
moderator_nickname=current_user.nickname,
)
```
---
## 3. Выдача предметов админами
### 3.1 Backend
#### Новые эндпоинты
```python
# backend/app/api/v1/shop.py
@router.post("/admin/users/{user_id}/items/grant", response_model=MessageResponse)
async def admin_grant_item(
user_id: int,
data: AdminGrantItemRequest,
current_user: CurrentUser,
db: DbSession,
):
"""
Выдать предмет пользователю (admin only).
Body:
item_id: int # ID предмета в магазине
quantity: int = 1 # Количество (для консамблов)
reason: str # Причина выдачи
"""
require_admin_with_2fa(current_user)
# Получаем пользователя
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(404, "User not found")
# Получаем предмет
item = await shop_service.get_item_by_id(db, data.item_id)
if not item:
raise HTTPException(404, "Item not found")
# Проверяем quantity для не-консамблов
if item.item_type != "consumable" and data.quantity > 1:
raise HTTPException(400, "Non-consumables can only have quantity 1")
# Проверяем, есть ли уже такой предмет
existing = await db.execute(
select(UserInventory)
.where(
UserInventory.user_id == user_id,
UserInventory.item_id == item.id,
)
)
inv_item = existing.scalar_one_or_none()
if inv_item:
if item.item_type == "consumable":
inv_item.quantity += data.quantity
else:
raise HTTPException(400, "User already owns this item")
else:
inv_item = UserInventory(
user_id=user_id,
item_id=item.id,
quantity=data.quantity if item.item_type == "consumable" else 1,
)
db.add(inv_item)
# Логируем
log = AdminLog(
admin_id=current_user.id,
action="ITEM_GRANT",
target_type="user",
target_id=user_id,
details={
"item_id": item.id,
"item_name": item.name,
"quantity": data.quantity,
"reason": data.reason,
}
)
db.add(log)
await db.commit()
return MessageResponse(
message=f"Granted {data.quantity}x {item.name} to {user.nickname}"
)
@router.get("/admin/users/{user_id}/inventory", response_model=list[InventoryItemResponse])
async def admin_get_user_inventory(
user_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Получить инвентарь пользователя (admin only)"""
require_admin_with_2fa(current_user)
# ...
@router.delete("/admin/users/{user_id}/inventory/{inventory_id}", response_model=MessageResponse)
async def admin_remove_item(
user_id: int,
inventory_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Удалить предмет из инвентаря пользователя (admin only)"""
require_admin_with_2fa(current_user)
# ...
```
#### Схемы
```python
class AdminGrantItemRequest(BaseModel):
item_id: int
quantity: int = 1
reason: str
```
### 3.2 Frontend
#### Новая страница: AdminItemsPage
`frontend/src/pages/admin/AdminItemsPage.tsx`
```tsx
export function AdminItemsPage() {
const [users, setUsers] = useState<User[]>([])
const [items, setItems] = useState<ShopItem[]>([])
const [selectedUser, setSelectedUser] = useState<User | null>(null)
const [grantModal, setGrantModal] = useState(false)
return (
<div>
<h1>Выдача предметов</h1>
{/* Поиск пользователя */}
<UserSearch onSelect={setSelectedUser} />
{selectedUser && (
<>
{/* Информация о пользователе */}
<UserCard user={selectedUser} />
{/* Инвентарь пользователя */}
<h2>Инвентарь</h2>
<UserInventoryList userId={selectedUser.id} />
{/* Кнопка выдачи */}
<button onClick={() => setGrantModal(true)}>
Выдать предмет
</button>
</>
)}
{/* Модалка выдачи */}
<GrantItemModal
isOpen={grantModal}
user={selectedUser}
items={items}
onClose={() => setGrantModal(false)}
onGrant={handleGrant}
/>
</div>
)
}
```
#### Компонент GrantItemModal
```tsx
function GrantItemModal({ isOpen, user, items, onClose, onGrant }) {
const [itemId, setItemId] = useState<number | null>(null)
const [quantity, setQuantity] = useState(1)
const [reason, setReason] = useState("")
const selectedItem = items.find(i => i.id === itemId)
const isConsumable = selectedItem?.item_type === "consumable"
return (
<Modal isOpen={isOpen} onClose={onClose}>
<h2>Выдать предмет: {user?.nickname}</h2>
{/* Выбор предмета */}
<select value={itemId} onChange={e => setItemId(Number(e.target.value))}>
<option value="">Выберите предмет</option>
{items.map(item => (
<option key={item.id} value={item.id}>
{item.name} ({item.item_type})
</option>
))}
</select>
{/* Количество (только для консамблов) */}
{isConsumable && (
<input
type="number"
min={1}
max={100}
value={quantity}
onChange={e => setQuantity(Number(e.target.value))}
/>
)}
{/* Причина */}
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="Причина выдачи (обязательно)"
required
/>
<button
onClick={() => onGrant({ itemId, quantity, reason })}
disabled={!itemId || !reason}
>
Выдать
</button>
</Modal>
)
}
```
#### Добавление в роутер
```tsx
// frontend/src/App.tsx
import { AdminItemsPage } from '@/pages/admin/AdminItemsPage'
// В админских роутах:
<Route path="items" element={<AdminItemsPage />} />
```
#### Добавление в меню админки
```tsx
// frontend/src/pages/admin/AdminLayout.tsx
const adminLinks = [
{ path: '/admin', label: 'Дашборд' },
{ path: '/admin/users', label: 'Пользователи' },
{ path: '/admin/marathons', label: 'Марафоны' },
{ path: '/admin/items', label: 'Предметы' }, // NEW
{ path: '/admin/promo', label: 'Промокоды' },
// ...
]
```
---
## 4. Миграции
### 4.1 Создание таблицы exiled_games
```python
# backend/alembic/versions/XXX_add_exiled_games.py
def upgrade():
op.create_table(
'exiled_games',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('participant_id', sa.Integer(), sa.ForeignKey('participants.id', ondelete='CASCADE'), nullable=False),
sa.Column('game_id', sa.Integer(), sa.ForeignKey('games.id', ondelete='CASCADE'), nullable=False),
sa.Column('assignment_id', sa.Integer(), sa.ForeignKey('assignments.id', ondelete='SET NULL'), nullable=True),
sa.Column('exiled_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('exiled_by', sa.String(20), nullable=False), # user, organizer, admin
sa.Column('reason', sa.String(500), nullable=True),
# История восстановления
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.Column('unexiled_at', sa.DateTime(), nullable=True),
sa.Column('unexiled_by', sa.String(20), nullable=True),
sa.UniqueConstraint('participant_id', 'game_id', name='unique_participant_game_exile'),
)
op.create_index('ix_exiled_games_participant_id', 'exiled_games', ['participant_id'])
op.create_index('ix_exiled_games_active', 'exiled_games', ['participant_id', 'is_active'])
def downgrade():
op.drop_table('exiled_games')
```
### 4.2 Добавление поля notify_moderation в users
```python
def upgrade():
op.add_column('users', sa.Column('notify_moderation', sa.Boolean(), server_default='true', nullable=False))
def downgrade():
op.drop_column('users', 'notify_moderation')
```
### 4.3 Добавление предмета skip_exile
```python
# Можно через миграцию или вручную через админку
# Если через миграцию:
def upgrade():
# ... create table ...
# Добавляем предмет в магазин
op.execute("""
INSERT INTO shop_items (item_type, code, name, description, price, rarity, is_active, created_at)
VALUES (
'consumable',
'skip_exile',
'Скип с изгнанием',
'Пропустить текущее задание без штрафа. Игра навсегда исключается из вашего пула.',
150,
'rare',
true,
NOW()
)
""")
```
---
## 5. Чеклист реализации
### Backend — Модели и миграции
- [ ] Создать модель ExiledGame (с полями assignment_id, is_active, unexiled_at, unexiled_by)
- [ ] Добавить поле notify_moderation в User
- [ ] Добавить ConsumableType.SKIP_EXILE
- [ ] Написать миграцию для exiled_games
- [ ] Написать миграцию для notify_moderation
- [ ] Добавить предмет skip_exile в магазин
### Backend — Скип с изгнанием
- [ ] Реализовать use_skip_exile в ConsumablesService
- [ ] Обновить get_available_games_for_participant (фильтр по is_active=True)
- [ ] Добавить обработку skip_exile в POST /shop/use
### Backend — Модерация
- [ ] Добавить эндпоинт POST /{marathon_id}/participants/{user_id}/skip-assignment
- [ ] Добавить эндпоинт GET /{marathon_id}/participants/{user_id}/exiled-games
- [ ] Добавить эндпоинт POST /{marathon_id}/participants/{user_id}/exiled-games/{game_id}/restore
- [ ] Добавить notify_assignment_skipped_by_moderator в telegram_notifier
### Backend — Админка предметов
- [ ] Добавить эндпоинт POST /shop/admin/users/{user_id}/items/grant
- [ ] Добавить эндпоинт GET /shop/admin/users/{user_id}/inventory
- [ ] Добавить эндпоинт DELETE /shop/admin/users/{user_id}/inventory/{inventory_id}
### Frontend — Игрок
- [ ] Добавить кнопку "Скип с изгнанием" в PlayPage
- [ ] Добавить чекбокс notify_moderation в настройках профиля
### Frontend — Админка
- [ ] Создать AdminItemsPage
- [ ] Добавить GrantItemModal
- [ ] Добавить роут /admin/items
- [ ] Добавить пункт меню в AdminLayout
### Frontend — Модерация марафона
- [ ] Создать UI модерации для организаторов (скип заданий)
- [ ] Добавить список изгнанных игр участника
- [ ] Добавить кнопку восстановления игры в пул
### Тестирование
- [ ] Тест: use_skip_exile корректно исключает игру
- [ ] Тест: изгнанная игра не выпадает при спине
- [ ] Тест: восстановленная игра (is_active=False) снова выпадает
- [ ] Тест: организатор может скипать задания
- [ ] Тест: Telegram уведомление отправляется при модераторском скипе
- [ ] Тест: админ может выдавать предметы
- [ ] Тест: лимиты скипов работают корректно
---
## 6. Вопросы для обсуждения
1. **Лимиты изгнания**: Нужен ли лимит на количество изгнанных игр у участника?
2. **Отмена изгнания**: Может ли участник сам отменить изгнание? Или только организатор?
3. **Стоимость**: Текущая цена skip_exile = 150 монет (обычный skip = 50). Подходит?
4. **Телеграм уведомления**: Нужны ли уведомления участнику при модераторском скипе?

View File

@@ -28,6 +28,12 @@ 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'
import CombinedWidget from '@/pages/widget/CombinedWidget'
// Admin Pages
import {
AdminLayout,
@@ -37,6 +43,8 @@ import {
AdminLogsPage,
AdminBroadcastPage,
AdminContentPage,
AdminPromoCodesPage,
AdminGrantItemPage,
} from '@/pages/admin'
// Protected route wrapper
@@ -85,6 +93,12 @@ 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="/widget/combined" element={<CombinedWidget />} />
<Route path="/" element={<Layout />}>
<Route index element={<HomePage />} />
@@ -228,7 +242,9 @@ function App() {
>
<Route index element={<AdminDashboardPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="users/:userId/grant-item" element={<AdminGrantItemPage />} />
<Route path="marathons" element={<AdminMarathonsPage />} />
<Route path="promo" element={<AdminPromoCodesPage />} />
<Route path="logs" element={<AdminLogsPage />} />
<Route path="broadcast" element={<AdminBroadcastPage />} />
<Route path="content" element={<AdminContentPage />} />

View File

@@ -10,3 +10,5 @@ export { assignmentsApi } from './assignments'
export { usersApi } from './users'
export { telegramApi } from './telegram'
export { shopApi } from './shop'
export { promoApi } from './promo'
export { widgetsApi } from './widgets'

View File

@@ -1,5 +1,5 @@
import client from './client'
import type { Marathon, MarathonListItem, MarathonPublicInfo, MarathonUpdate, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode, MarathonDispute } from '@/types'
import type { Marathon, MarathonListItem, MarathonPublicInfo, MarathonUpdate, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode, MarathonDispute, ExiledGame } from '@/types'
export interface CreateMarathonData {
title: string
@@ -112,4 +112,36 @@ export const marathonsApi = {
)
return response.data
},
// === Moderation ===
// Skip participant's assignment (organizer only)
skipParticipantAssignment: async (
marathonId: number,
userId: number,
exile: boolean = false,
reason?: string
): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(
`/marathons/${marathonId}/participants/${userId}/skip-assignment`,
{ exile, reason }
)
return response.data
},
// Get participant's exiled games (organizer only)
getExiledGames: async (marathonId: number, userId: number): Promise<ExiledGame[]> => {
const response = await client.get<ExiledGame[]>(
`/marathons/${marathonId}/participants/${userId}/exiled-games`
)
return response.data
},
// Restore exiled game (organizer only)
restoreExiledGame: async (marathonId: number, userId: number, gameId: number): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(
`/marathons/${marathonId}/participants/${userId}/exiled-games/${gameId}/restore`
)
return response.data
},
}

34
frontend/src/api/promo.ts Normal file
View File

@@ -0,0 +1,34 @@
import client from './client'
import type {
PromoCode,
PromoCodeCreate,
PromoCodeUpdate,
PromoCodeRedemption,
PromoCodeRedeemResponse,
} from '@/types'
export const promoApi = {
// User endpoint - redeem promo code
redeem: (code: string) =>
client.post<PromoCodeRedeemResponse>('/promo/redeem', { code }),
// Admin endpoints
admin: {
list: (includeInactive = false) =>
client.get<PromoCode[]>('/promo/admin/list', {
params: { include_inactive: includeInactive },
}),
create: (data: PromoCodeCreate) =>
client.post<PromoCode>('/promo/admin/create', data),
update: (id: number, data: PromoCodeUpdate) =>
client.put<PromoCode>(`/promo/admin/${id}`, data),
delete: (id: number) =>
client.delete<{ message: string }>(`/promo/admin/${id}`),
getRedemptions: (id: number) =>
client.get<PromoCodeRedemption[]>(`/promo/admin/${id}/redemptions`),
},
}

View File

@@ -10,6 +10,7 @@ import type {
CoinTransaction,
ConsumablesStatus,
UserCosmetics,
SwapCandidate,
} from '@/types'
export const shopApi = {
@@ -84,6 +85,12 @@ export const shopApi = {
return response.data
},
// Получить кандидатов для Copycat (участники с активными заданиями)
getCopycatCandidates: async (marathonId: number): Promise<SwapCandidate[]> => {
const response = await client.get<SwapCandidate[]>(`/shop/copycat-candidates/${marathonId}`)
return response.data
},
// === Монеты ===
// Получить баланс и последние транзакции
@@ -99,4 +106,31 @@ export const shopApi = {
})
return response.data
},
// === Админские функции ===
// Получить инвентарь пользователя (админ)
adminGetUserInventory: async (userId: number, itemType?: ShopItemType): Promise<InventoryItem[]> => {
const params = itemType ? { item_type: itemType } : {}
const response = await client.get<InventoryItem[]>(`/shop/admin/users/${userId}/inventory`, { params })
return response.data
},
// Выдать предмет пользователю (админ)
adminGrantItem: async (userId: number, itemId: number, quantity: number, reason: string): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(`/shop/admin/users/${userId}/items/grant`, {
item_id: itemId,
quantity,
reason,
})
return response.data
},
// Удалить предмет из инвентаря пользователя (админ)
adminRemoveItem: async (userId: number, inventoryId: number, quantity: number = 1): Promise<{ message: string }> => {
const response = await client.delete<{ message: string }>(`/shop/admin/users/${userId}/inventory/${inventoryId}`, {
params: { quantity },
})
return response.data
},
}

View 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
},
}

View File

@@ -0,0 +1,273 @@
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'
type WidgetType = 'leaderboard' | 'current' | 'progress' | 'combined'
export function WidgetSettingsModal({ marathonId, isOpen, onClose }: WidgetSettingsModalProps) {
const [token, setToken] = useState<WidgetToken | null>(null)
const [loading, setLoading] = useState(false)
const [theme, setTheme] = useState<WidgetTheme>('dark')
const [count, setCount] = useState(5)
const [showAvatars, setShowAvatars] = useState(true)
const [transparent, setTransparent] = useState(false)
const [previewType, setPreviewType] = useState<WidgetType>('leaderboard')
const toast = useToast()
useEffect(() => {
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: WidgetType) => {
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
const widgetNames: Record<WidgetType, string> = {
leaderboard: 'Лидерборд',
current: 'Текущее задание',
progress: 'Прогресс',
combined: 'Всё в одном',
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl max-w-5xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-dark-700">
<div className="flex justify-between items-center">
<h2 className="text-xl font-bold">Виджеты для OBS</h2>
<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">
{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 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left column - Preview */}
<div className="space-y-4">
<h3 className="font-semibold text-lg">Превью</h3>
{/* Widget type tabs */}
<div className="flex flex-wrap gap-2">
{(['leaderboard', 'current', 'progress', 'combined'] as WidgetType[]).map((type) => (
<button
key={type}
onClick={() => setPreviewType(type)}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
previewType === type
? 'bg-primary text-white'
: 'bg-dark-700 text-gray-400 hover:text-white'
}`}
>
{widgetNames[type]}
</button>
))}
</div>
{/* Preview iframe */}
<div
className="rounded-lg overflow-hidden border border-dark-600"
style={{
background: transparent ? 'repeating-conic-gradient(#1a1a1a 0% 25%, #2a2a2a 0% 50%) 50% / 20px 20px'
: theme === 'light' ? '#f5f5f5' : '#121212'
}}
>
<iframe
key={`${previewType}-${theme}-${count}-${showAvatars}-${transparent}`}
src={buildWidgetUrl(previewType)}
className="w-full"
style={{ height: '320px', border: 'none' }}
title="Widget Preview"
/>
</div>
{/* Copy button for current preview */}
<button
onClick={() => copyToClipboard(buildWidgetUrl(previewType), widgetNames[previewType])}
className="w-full px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/80 transition-colors"
>
Копировать ссылку на {widgetNames[previewType]}
</button>
</div>
{/* Right column - Settings */}
<div className="space-y-6">
{/* Settings */}
<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>
{/* All Widget URLs */}
<div className="space-y-3">
<h3 className="font-semibold text-lg">Все ссылки</h3>
{([
{ type: 'leaderboard' as const, desc: 'Таблица участников' },
{ type: 'current' as const, desc: 'Активное задание' },
{ type: 'progress' as const, desc: 'Статистика' },
{ type: 'combined' as const, desc: 'Всё в одном виджете' },
]).map(({ type, desc }) => (
<div key={type} className="flex items-center justify-between bg-dark-700 rounded-lg px-4 py-3">
<div>
<div className="font-medium text-sm">{widgetNames[type]}</div>
<div className="text-xs text-gray-500">{desc}</div>
</div>
<button
onClick={() => copyToClipboard(buildWidgetUrl(type), widgetNames[type])}
className="px-3 py-1 bg-dark-600 text-white text-sm rounded-lg hover:bg-dark-500 transition-colors"
>
Копировать
</button>
</div>
))}
</div>
{/* Instructions */}
<div className="bg-dark-700/50 rounded-lg p-4">
<h4 className="font-medium mb-2 text-sm">Как добавить в OBS</h4>
<ol className="text-xs text-gray-400 space-y-1 list-decimal list-inside">
<li>Скопируйте нужную ссылку</li>
<li>В OBS: "+" "Браузер"</li>
<li>Вставьте ссылку в поле URL</li>
<li>Размер: 400×300 px</li>
</ol>
</div>
{/* Token actions */}
<div className="flex justify-between items-center pt-4 border-t border-dark-700">
<div className="text-xs text-gray-500">
Токен: {token.token.substring(0, 16)}...
</div>
<button
onClick={regenerateToken}
className="text-xs text-red-400 hover:text-red-300"
>
Сбросить токен
</button>
</div>
</div>
</div>
) : (
<div className="text-center py-8 text-gray-400">
Не удалось загрузить данные
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -5,7 +5,7 @@ import { useToast } from '@/store/toast'
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
import {
Loader2, Package, ShoppingBag, Coins, Check,
Frame, Type, Palette, Image, Zap, Shield, RefreshCw, SkipForward
Frame, Type, Palette, Image, Zap, SkipForward, Shuffle, Dice5, Copy, Undo2
} from 'lucide-react'
import type { InventoryItem, ShopItemType } from '@/types'
import { RARITY_COLORS, RARITY_NAMES, ITEM_TYPE_NAMES } from '@/types'
@@ -13,9 +13,11 @@ import clsx from 'clsx'
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
skip: <SkipForward className="w-8 h-8" />,
shield: <Shield className="w-8 h-8" />,
boost: <Zap className="w-8 h-8" />,
reroll: <RefreshCw className="w-8 h-8" />,
wild_card: <Shuffle className="w-8 h-8" />,
lucky_dice: <Dice5 className="w-8 h-8" />,
copycat: <Copy className="w-8 h-8" />,
undo: <Undo2 className="w-8 h-8" />,
}
interface InventoryItemCardProps {

View File

@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { marathonsApi } from '@/api'
import type { LeaderboardEntry, ShopItemPublic, User } from '@/types'
import { GlassCard, UserAvatar } from '@/components/ui'
import type { LeaderboardEntry, ShopItemPublic, User, Marathon } from '@/types'
import { GlassCard, UserAvatar, NeonButton } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target } from 'lucide-react'
import { useToast } from '@/store/toast'
import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target, SkipForward, X, Ban } from 'lucide-react'
// Helper to get name color styles and animation class
function getNameColorData(nameColor: ShopItemPublic | null | undefined): { styles: React.CSSProperties; className: string } {
@@ -80,25 +81,67 @@ function StyledNickname({ user, className = '' }: { user: User; className?: stri
export function LeaderboardPage() {
const { id } = useParams<{ id: string }>()
const user = useAuthStore((state) => state.user)
const toast = useToast()
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([])
const [marathon, setMarathon] = useState<Marathon | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Skip modal state
const [skipModalUser, setSkipModalUser] = useState<User | null>(null)
const [skipExile, setSkipExile] = useState(false)
const [skipReason, setSkipReason] = useState('')
const [isSkipping, setIsSkipping] = useState(false)
const isOrganizer = marathon?.my_participation?.role === 'organizer' || user?.role === 'admin'
useEffect(() => {
loadLeaderboard()
loadData()
}, [id])
const loadLeaderboard = async () => {
const loadData = async () => {
if (!id) return
try {
const data = await marathonsApi.getLeaderboard(parseInt(id))
setLeaderboard(data)
const [leaderboardData, marathonData] = await Promise.all([
marathonsApi.getLeaderboard(parseInt(id)),
marathonsApi.get(parseInt(id)),
])
setLeaderboard(leaderboardData)
setMarathon(marathonData)
} catch (error) {
console.error('Failed to load leaderboard:', error)
console.error('Failed to load data:', error)
} finally {
setIsLoading(false)
}
}
const handleSkip = async () => {
if (!skipModalUser || !id) return
setIsSkipping(true)
try {
await marathonsApi.skipParticipantAssignment(
parseInt(id),
skipModalUser.id,
skipExile,
skipReason || undefined
)
toast.success(
skipExile
? `Задание ${skipModalUser.nickname} пропущено, игра изгнана`
: `Задание ${skipModalUser.nickname} пропущено`
)
setSkipModalUser(null)
setSkipExile(false)
setSkipReason('')
loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось пропустить задание')
} finally {
setIsSkipping(false)
}
}
const getRankConfig = (rank: number) => {
switch (rank) {
case 1:
@@ -366,6 +409,20 @@ export function LeaderboardPage() {
</div>
)}
{/* Skip button for organizers */}
{isOrganizer && (
<button
onClick={(e) => {
e.preventDefault()
setSkipModalUser(entry.user)
}}
className="relative p-2 text-orange-400 hover:bg-orange-500/20 rounded-lg transition-colors"
title="Скипнуть задание"
>
<SkipForward className="w-4 h-4" />
</button>
)}
{/* Points */}
<div className="relative text-right">
<div className={`text-xl font-bold ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}>
@@ -380,6 +437,104 @@ export function LeaderboardPage() {
</GlassCard>
</>
)}
{/* Skip Modal */}
{skipModalUser && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<SkipForward className="w-5 h-5 text-orange-400" />
Скипнуть задание {skipModalUser.nickname}
</h3>
<button
onClick={() => {
setSkipModalUser(null)
setSkipExile(false)
setSkipReason('')
}}
className="p-1.5 text-gray-400 hover:text-white rounded-lg hover:bg-dark-600/50 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Skip type */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-3">
Тип скипа
</label>
<div className="space-y-2">
<label className="flex items-center gap-3 p-3 rounded-xl bg-dark-700/50 border border-dark-600 cursor-pointer hover:border-orange-500/30 transition-colors">
<input
type="radio"
name="skipType"
checked={!skipExile}
onChange={() => setSkipExile(false)}
className="w-4 h-4 text-orange-500 bg-dark-700 border-dark-500 focus:ring-orange-500/50"
/>
<div>
<div className="text-white font-medium">Обычный скип</div>
<div className="text-sm text-gray-400">Задание пропускается, игра может выпасть снова</div>
</div>
</label>
<label className="flex items-center gap-3 p-3 rounded-xl bg-dark-700/50 border border-dark-600 cursor-pointer hover:border-red-500/30 transition-colors">
<input
type="radio"
name="skipType"
checked={skipExile}
onChange={() => setSkipExile(true)}
className="w-4 h-4 text-red-500 bg-dark-700 border-dark-500 focus:ring-red-500/50"
/>
<div>
<div className="text-white font-medium flex items-center gap-2">
Скип с изгнанием
<Ban className="w-4 h-4 text-red-400" />
</div>
<div className="text-sm text-gray-400">Игра навсегда удаляется из пула участника</div>
</div>
</label>
</div>
</div>
{/* Reason */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-300 mb-2">
Причина (опционально)
</label>
<textarea
value={skipReason}
onChange={(e) => setSkipReason(e.target.value)}
placeholder="Причина скипа..."
rows={2}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-orange-500/50 transition-colors resize-none"
/>
</div>
<div className="flex gap-3 justify-end">
<NeonButton
variant="ghost"
onClick={() => {
setSkipModalUser(null)
setSkipExile(false)
setSkipReason('')
}}
>
Отмена
</NeonButton>
<NeonButton
color={skipExile ? 'pink' : 'neon'}
onClick={handleSkip}
disabled={isSkipping}
isLoading={isSkipping}
icon={<SkipForward className="w-4 h-4" />}
>
{skipExile ? 'Скипнуть и изгнать' : 'Скипнуть'}
</NeonButton>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -949,7 +949,6 @@ export function LobbyPage() {
value={editChallenge.points}
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
min={1}
max={1000}
/>
</div>
<div>
@@ -1109,7 +1108,6 @@ export function LobbyPage() {
value={newChallenge.points}
onChange={(e) => setNewChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
min={1}
max={1000}
/>
</div>
<div>
@@ -1351,7 +1349,6 @@ export function LobbyPage() {
value={editChallenge.points}
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
min={1}
max={1000}
/>
</div>
<div>
@@ -1974,7 +1971,6 @@ export function LobbyPage() {
value={playthroughPoints}
onChange={(e) => setPlaythroughPoints(parseInt(e.target.value) || 50)}
min={1}
max={1000}
/>
</div>
<div>
@@ -2151,7 +2147,6 @@ export function LobbyPage() {
value={editPlaythroughPoints}
onChange={(e) => setEditPlaythroughPoints(parseInt(e.target.value) || 50)}
min={1}
max={1000}
/>
</div>
<div>

View File

@@ -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>
)
}

View File

@@ -5,7 +5,7 @@ import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequ
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel'
import { EventBanner } from '@/components/EventBanner'
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download, Shield, RefreshCw, SkipForward, Package } from 'lucide-react'
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download, SkipForward, Package, Dice5, Copy, Undo2, Shuffle } from 'lucide-react'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { useShopStore } from '@/store/shop'
@@ -494,40 +494,30 @@ export function PlayPage() {
}
}
const handleUseReroll = async () => {
const handleUseSkipExile = async () => {
if (!currentAssignment || !id) return
setIsUsingConsumable('reroll')
const confirmed = await confirm({
title: 'Скип с изгнанием?',
message: 'Задание будет пропущено без штрафа, а игра навсегда удалена из вашего пула.',
confirmText: 'Использовать',
cancelText: 'Отмена',
variant: 'warning',
})
if (!confirmed) return
setIsUsingConsumable('skip_exile')
try {
await shopApi.useConsumable({
item_code: 'reroll',
item_code: 'skip_exile',
marathon_id: parseInt(id),
assignment_id: currentAssignment.id,
})
toast.success('Задание отменено! Можно крутить заново.')
toast.success('Задание пропущено, игра изгнана из пула!')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Reroll')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseShield = async () => {
if (!id) return
setIsUsingConsumable('shield')
try {
await shopApi.useConsumable({
item_code: 'shield',
marathon_id: parseInt(id),
})
toast.success('Shield активирован! Следующий пропуск будет бесплатным.')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось активировать Shield')
toast.error(error.response?.data?.detail || 'Не удалось использовать Skip с изгнанием')
} finally {
setIsUsingConsumable(null)
}
@@ -541,7 +531,7 @@ export function PlayPage() {
item_code: 'boost',
marathon_id: parseInt(id),
})
toast.success('Boost активирован! x1.5 очков за следующее выполнение.')
toast.success('Boost активирован! x1.5 очков за текущее задание.')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
@@ -552,6 +542,119 @@ export function PlayPage() {
}
}
// Wild Card modal state
const [showWildCardModal, setShowWildCardModal] = useState(false)
const handleUseWildCard = async (gameId: number) => {
if (!currentAssignment || !id) return
setIsUsingConsumable('wild_card')
try {
const result = await shopApi.useConsumable({
item_code: 'wild_card',
marathon_id: parseInt(id),
assignment_id: currentAssignment.id,
game_id: gameId,
})
toast.success(result.effect_description)
setShowWildCardModal(false)
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Wild Card')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseLuckyDice = async () => {
if (!id) return
setIsUsingConsumable('lucky_dice')
try {
const result = await shopApi.useConsumable({
item_code: 'lucky_dice',
marathon_id: parseInt(id),
})
const multiplier = result.effect_data?.multiplier as number
toast.success(`Lucky Dice: x${multiplier} множитель!`)
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Lucky Dice')
} finally {
setIsUsingConsumable(null)
}
}
// Copycat modal state
const [showCopycatModal, setShowCopycatModal] = useState(false)
const [copycatCandidates, setCopycatCandidates] = useState<SwapCandidate[]>([])
const [isLoadingCopycatCandidates, setIsLoadingCopycatCandidates] = useState(false)
const loadCopycatCandidates = async () => {
if (!id) return
setIsLoadingCopycatCandidates(true)
try {
const candidates = await shopApi.getCopycatCandidates(parseInt(id))
setCopycatCandidates(candidates)
} catch (error) {
console.error('Failed to load copycat candidates:', error)
} finally {
setIsLoadingCopycatCandidates(false)
}
}
const handleUseCopycat = async (targetParticipantId: number) => {
if (!currentAssignment || !id) return
setIsUsingConsumable('copycat')
try {
const result = await shopApi.useConsumable({
item_code: 'copycat',
marathon_id: parseInt(id),
assignment_id: currentAssignment.id,
target_participant_id: targetParticipantId,
})
toast.success(result.effect_description)
setShowCopycatModal(false)
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Copycat')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseUndo = async () => {
if (!id) return
const confirmed = await confirm({
title: 'Использовать Undo?',
message: 'Это вернёт очки и серию от последнего пропуска.',
confirmText: 'Использовать',
cancelText: 'Отмена',
variant: 'info',
})
if (!confirmed) return
setIsUsingConsumable('undo')
try {
const result = await shopApi.useConsumable({
item_code: 'undo',
marathon_id: parseInt(id),
})
toast.success(result.effect_description)
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Undo')
} finally {
setIsUsingConsumable(null)
}
}
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24">
@@ -710,18 +813,18 @@ export function PlayPage() {
</div>
{/* Active effects */}
{(consumablesStatus.has_shield || consumablesStatus.has_active_boost) && (
{(consumablesStatus.has_active_boost || consumablesStatus.has_lucky_dice) && (
<div className="mb-4 p-3 bg-green-500/10 border border-green-500/30 rounded-xl">
<p className="text-green-400 text-sm font-medium mb-2">Активные эффекты:</p>
<div className="flex flex-wrap gap-2">
{consumablesStatus.has_shield && (
<span className="px-2 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-lg border border-blue-500/30 flex items-center gap-1">
<Shield className="w-3 h-3" /> Shield (следующий drop бесплатный)
</span>
)}
{consumablesStatus.has_active_boost && (
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded-lg border border-yellow-500/30 flex items-center gap-1">
<Zap className="w-3 h-3" /> Boost x1.5 (следующий complete)
<Zap className="w-3 h-3" /> Boost x1.5
</span>
)}
{consumablesStatus.has_lucky_dice && (
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 text-xs rounded-lg border border-purple-500/30 flex items-center gap-1">
<Dice5 className="w-3 h-3" /> Lucky Dice x{consumablesStatus.lucky_dice_multiplier}
</span>
)}
</div>
@@ -752,52 +855,28 @@ export function PlayPage() {
</NeonButton>
</div>
{/* Reroll */}
{/* Skip with Exile */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<RefreshCw className="w-4 h-4 text-cyan-400" />
<span className="text-white font-medium">Reroll</span>
<XCircle className="w-4 h-4 text-red-400" />
<span className="text-white font-medium">Skip + Изгнание</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.rerolls_available} шт.</span>
<span className="text-gray-400 text-sm">{consumablesStatus.skip_exiles_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Переспинить задание</p>
<p className="text-gray-500 text-xs mb-2">Скип + убрать игру из пула</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseReroll}
disabled={consumablesStatus.rerolls_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'reroll'}
onClick={handleUseSkipExile}
disabled={consumablesStatus.skip_exiles_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'skip_exile'}
className="w-full"
>
Использовать
</NeonButton>
</div>
{/* Shield */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-blue-400" />
<span className="text-white font-medium">Shield</span>
</div>
<span className="text-gray-400 text-sm">
{consumablesStatus.has_shield ? 'Активен' : `${consumablesStatus.shields_available} шт.`}
</span>
</div>
<p className="text-gray-500 text-xs mb-2">Защита от штрафа</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseShield}
disabled={consumablesStatus.has_shield || consumablesStatus.shields_available === 0 || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'shield'}
className="w-full"
>
{consumablesStatus.has_shield ? 'Активен' : 'Активировать'}
</NeonButton>
</div>
{/* Boost */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
@@ -821,10 +900,188 @@ export function PlayPage() {
{consumablesStatus.has_active_boost ? 'Активен' : 'Активировать'}
</NeonButton>
</div>
{/* Wild Card */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Shuffle className="w-4 h-4 text-green-400" />
<span className="text-white font-medium">Wild Card</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.wild_cards_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Выбрать игру</p>
<NeonButton
size="sm"
variant="outline"
onClick={() => setShowWildCardModal(true)}
disabled={consumablesStatus.wild_cards_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'wild_card'}
className="w-full"
>
Выбрать
</NeonButton>
</div>
{/* Lucky Dice */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Dice5 className="w-4 h-4 text-purple-400" />
<span className="text-white font-medium">Lucky Dice</span>
</div>
<span className="text-gray-400 text-sm">
{consumablesStatus.has_lucky_dice ? `x${consumablesStatus.lucky_dice_multiplier}` : `${consumablesStatus.lucky_dice_available} шт.`}
</span>
</div>
<p className="text-gray-500 text-xs mb-2">Случайный множитель</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseLuckyDice}
disabled={consumablesStatus.has_lucky_dice || consumablesStatus.lucky_dice_available === 0 || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'lucky_dice'}
className="w-full"
>
{consumablesStatus.has_lucky_dice ? `x${consumablesStatus.lucky_dice_multiplier}` : 'Бросить'}
</NeonButton>
</div>
{/* Copycat */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Copy className="w-4 h-4 text-cyan-400" />
<span className="text-white font-medium">Copycat</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.copycats_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Скопировать задание</p>
<NeonButton
size="sm"
variant="outline"
onClick={() => {
setShowCopycatModal(true)
loadCopycatCandidates()
}}
disabled={consumablesStatus.copycats_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'copycat'}
className="w-full"
>
Выбрать
</NeonButton>
</div>
{/* Undo */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Undo2 className="w-4 h-4 text-red-400" />
<span className="text-white font-medium">Undo</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.undos_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Отменить дроп</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseUndo}
disabled={!consumablesStatus.can_undo || consumablesStatus.undos_available === 0 || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'undo'}
className="w-full"
>
{consumablesStatus.can_undo ? 'Отменить' : 'Нет дропа'}
</NeonButton>
</div>
</div>
</GlassCard>
)}
{/* Wild Card Modal */}
{showWildCardModal && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<GlassCard className="w-full max-w-md max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-white">Выберите игру</h3>
<button
onClick={() => setShowWildCardModal(false)}
className="p-2 text-gray-400 hover:text-white"
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-gray-400 text-sm mb-4">
Вы получите случайное задание из выбранной игры
</p>
<div className="space-y-2">
{games.map((game) => (
<button
key={game.id}
onClick={() => handleUseWildCard(game.id)}
disabled={isUsingConsumable === 'wild_card'}
className="w-full p-3 bg-dark-700/50 hover:bg-dark-700 rounded-xl text-left transition-colors border border-dark-600 hover:border-green-500/30 disabled:opacity-50"
>
<p className="text-white font-medium">{game.title}</p>
<p className="text-gray-400 text-xs">{game.challenges_count} челленджей</p>
</button>
))}
</div>
</GlassCard>
</div>
)}
{/* Copycat Modal */}
{showCopycatModal && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<GlassCard className="w-full max-w-md max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-white">Скопировать задание</h3>
<button
onClick={() => setShowCopycatModal(false)}
className="p-2 text-gray-400 hover:text-white"
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-gray-400 text-sm mb-4">
Выберите участника, чьё задание хотите скопировать
</p>
{isLoadingCopycatCandidates ? (
<div className="flex justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-cyan-400" />
</div>
) : copycatCandidates.length === 0 ? (
<p className="text-center text-gray-500 py-8">Нет доступных заданий для копирования</p>
) : (
<div className="space-y-2">
{copycatCandidates.map((candidate) => {
const displayTitle = candidate.is_playthrough
? `Прохождение: ${candidate.game_title}`
: candidate.challenge_title || ''
const displayDetails = candidate.is_playthrough
? `${candidate.playthrough_points || 0} очков`
: `${candidate.game_title}${candidate.challenge_points} очков • ${candidate.challenge_difficulty}`
return (
<button
key={candidate.participant_id}
onClick={() => handleUseCopycat(candidate.participant_id)}
disabled={isUsingConsumable === 'copycat'}
className="w-full p-3 bg-dark-700/50 hover:bg-dark-700 rounded-xl text-left transition-colors border border-dark-600 hover:border-cyan-500/30 disabled:opacity-50"
>
<p className="text-white font-medium">{candidate.user.nickname}</p>
<p className="text-cyan-400 text-sm">{displayTitle}</p>
<p className="text-gray-500 text-xs">
{displayDetails}
</p>
</button>
)
})}
</div>
)}
</GlassCard>
</div>
)}
{/* Tabs for Common Enemy event */}
{activeEvent?.event?.type === 'common_enemy' && (
<div className="flex gap-2 mb-6">
@@ -1220,9 +1477,28 @@ export function PlayPage() {
</div>
<div className="flex items-center gap-3 mb-6 flex-wrap">
{/* Points - calculated from tracked time if available */}
{currentAssignment.tracked_time_minutes !== undefined && currentAssignment.tracked_time_minutes > 0 ? (
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
~{Math.floor(currentAssignment.tracked_time_minutes / 60 * 30)} очков
</span>
) : (
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
+{currentAssignment.playthrough_info?.points} очков
</span>
)}
{/* Time tracker indicator */}
{currentAssignment.tracked_time_minutes !== undefined && currentAssignment.tracked_time_minutes > 0 ? (
<span className="px-3 py-1.5 bg-neon-500/20 text-neon-400 rounded-lg text-sm font-medium border border-neon-500/30 flex items-center gap-1.5">
<Clock className="w-4 h-4" />
{Math.floor(currentAssignment.tracked_time_minutes / 60)}ч {currentAssignment.tracked_time_minutes % 60}м
</span>
) : (
<span className="px-3 py-1.5 bg-gray-500/20 text-gray-400 rounded-lg text-sm font-medium border border-gray-500/30 flex items-center gap-1.5">
<Clock className="w-4 h-4" />
Установите трекер
</span>
)}
</div>
{currentAssignment.playthrough_info?.proof_hint && (
@@ -1564,7 +1840,14 @@ export function PlayPage() {
Входящие запросы ({swapRequests.incoming.length})
</h4>
<div className="space-y-3">
{swapRequests.incoming.map((request) => (
{swapRequests.incoming.map((request) => {
const challengeTitle = request.from_challenge.is_playthrough
? `Прохождение: ${request.from_challenge.game_title}`
: request.from_challenge.title || ''
const challengeDetails = request.from_challenge.is_playthrough
? `${request.from_challenge.playthrough_points || 0} очков`
: `${request.from_challenge.game_title}${request.from_challenge.points} очков`
return (
<div
key={request.id}
className="p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-xl"
@@ -1575,10 +1858,10 @@ export function PlayPage() {
{request.from_user.nickname} предлагает обмен
</p>
<p className="text-yellow-400 text-sm mt-1">
Вы получите: <span className="font-medium">{request.from_challenge.title}</span>
Вы получите: <span className="font-medium">{challengeTitle}</span>
</p>
<p className="text-gray-400 text-xs">
{request.from_challenge.game_title} {request.from_challenge.points} очков
{challengeDetails}
</p>
</div>
<div className="flex flex-col gap-2">
@@ -1605,7 +1888,8 @@ export function PlayPage() {
</div>
</div>
</div>
))}
)
})}
</div>
</div>
)}
@@ -1618,7 +1902,11 @@ export function PlayPage() {
Отправленные запросы ({swapRequests.outgoing.length})
</h4>
<div className="space-y-3">
{swapRequests.outgoing.map((request) => (
{swapRequests.outgoing.map((request) => {
const challengeTitle = request.to_challenge.is_playthrough
? `Прохождение: ${request.to_challenge.game_title}`
: request.to_challenge.title || ''
return (
<div
key={request.id}
className="p-4 bg-accent-500/10 border border-accent-500/30 rounded-xl"
@@ -1629,7 +1917,7 @@ export function PlayPage() {
Запрос к {request.to_user.nickname}
</p>
<p className="text-accent-400 text-sm mt-1">
Вы получите: <span className="font-medium">{request.to_challenge.title}</span>
Вы получите: <span className="font-medium">{challengeTitle}</span>
</p>
<p className="text-gray-500 text-xs mt-1">
Ожидание подтверждения...
@@ -1647,7 +1935,8 @@ export function PlayPage() {
</NeonButton>
</div>
</div>
))}
)
})}
</div>
</div>
)}
@@ -1675,7 +1964,14 @@ export function PlayPage() {
!swapRequests.outgoing.some(r => r.to_user.id === c.user.id) &&
!swapRequests.incoming.some(r => r.from_user.id === c.user.id)
)
.map((candidate) => (
.map((candidate) => {
const displayTitle = candidate.is_playthrough
? `Прохождение: ${candidate.game_title}`
: candidate.challenge_title || ''
const displayDetails = candidate.is_playthrough
? `${candidate.playthrough_points || 0} очков`
: `${candidate.game_title}${candidate.challenge_points} очков • ${candidate.challenge_difficulty}`
return (
<div
key={candidate.participant_id}
className="p-4 bg-dark-700/50 rounded-xl border border-dark-600"
@@ -1686,10 +1982,10 @@ export function PlayPage() {
{candidate.user.nickname}
</p>
<p className="text-neon-400 text-sm font-medium truncate">
{candidate.challenge_title}
{displayTitle}
</p>
<p className="text-gray-400 text-xs mt-1">
{candidate.game_title} {candidate.challenge_points} очков {candidate.challenge_difficulty}
{displayDetails}
</p>
</div>
<NeonButton
@@ -1698,7 +1994,7 @@ export function PlayPage() {
onClick={() => handleSendSwapRequest(
candidate.participant_id,
candidate.user.nickname,
candidate.challenge_title
displayTitle
)}
isLoading={sendingRequestTo === candidate.participant_id}
disabled={sendingRequestTo !== null}
@@ -1708,7 +2004,8 @@ export function PlayPage() {
</NeonButton>
</div>
</div>
))}
)
})}
</div>
)}
</div>

View File

@@ -4,7 +4,8 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { usersApi, telegramApi, authApi } from '@/api'
import { useShopStore } from '@/store/shop'
import { usersApi, telegramApi, authApi, promoApi } from '@/api'
import type { UserStats, ShopItemPublic } from '@/types'
import { useToast } from '@/store/toast'
import {
@@ -14,7 +15,7 @@ import {
User, Camera, Trophy, Target, CheckCircle, Flame,
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles,
AlertTriangle, FileCheck, Backpack, Edit3
AlertTriangle, FileCheck, Backpack, Edit3, Gift
} from 'lucide-react'
import clsx from 'clsx'
@@ -289,6 +290,10 @@ export function ProfilePage() {
const [notifyModeration, setNotifyModeration] = useState(user?.notify_moderation ?? true)
const [notificationUpdating, setNotificationUpdating] = useState<string | null>(null)
// Promo code state
const [promoCode, setPromoCode] = useState('')
const [isRedeemingPromo, setIsRedeemingPromo] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Forms
@@ -526,6 +531,27 @@ export function ProfilePage() {
}
}
// Redeem promo code
const handleRedeemPromo = async (e: React.FormEvent) => {
e.preventDefault()
if (!promoCode.trim()) return
setIsRedeemingPromo(true)
try {
const response = await promoApi.redeem(promoCode.trim())
toast.success(response.data.message)
setPromoCode('')
// Update coin balance in store
useShopStore.getState().updateBalance(response.data.new_balance)
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
const message = err.response?.data?.detail || 'Не удалось активировать промокод'
toast.error(message)
} finally {
setIsRedeemingPromo(false)
}
}
const isLinked = !!user?.telegram_id
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
@@ -773,6 +799,37 @@ export function ProfilePage() {
)}
</div>
{/* Promo Code */}
<GlassCard>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
<Gift className="w-5 h-5 text-yellow-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Промокод</h2>
<p className="text-sm text-gray-400">Введите код для получения монет</p>
</div>
</div>
<form onSubmit={handleRedeemPromo} className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
placeholder="Введите промокод"
value={promoCode}
onChange={(e) => setPromoCode(e.target.value.toUpperCase())}
maxLength={50}
/>
</div>
<NeonButton
type="submit"
isLoading={isRedeemingPromo}
disabled={!promoCode.trim()}
icon={<Gift className="w-4 h-4" />}
>
Активировать
</NeonButton>
</form>
</GlassCard>
{/* Telegram */}
<GlassCard>
<div className="flex items-center gap-3 mb-6">

View File

@@ -6,8 +6,8 @@ import { useConfirm } from '@/store/confirm'
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
import {
Loader2, Coins, ShoppingBag, Package, Sparkles,
Frame, Type, Palette, Image, Zap, Shield, RefreshCw, SkipForward,
Minus, Plus
Frame, Type, Palette, Image, Zap, SkipForward,
Minus, Plus, Shuffle, Dice5, Copy, Undo2
} from 'lucide-react'
import type { ShopItem, ShopItemType, ShopItemPublic } from '@/types'
import { RARITY_COLORS, RARITY_NAMES } from '@/types'
@@ -23,9 +23,11 @@ const ITEM_TYPE_ICONS: Record<ShopItemType, React.ReactNode> = {
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
skip: <SkipForward className="w-8 h-8" />,
shield: <Shield className="w-8 h-8" />,
boost: <Zap className="w-8 h-8" />,
reroll: <RefreshCw className="w-8 h-8" />,
wild_card: <Shuffle className="w-8 h-8" />,
lucky_dice: <Dice5 className="w-8 h-8" />,
copycat: <Copy className="w-8 h-8" />,
undo: <Undo2 className="w-8 h-8" />,
}
interface ShopItemCardProps {
@@ -176,7 +178,7 @@ function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
className={clsx(
'p-4 border transition-all duration-300',
rarityColors.border,
item.is_owned && 'opacity-60'
item.is_owned && !isConsumable && 'opacity-60'
)}
>
{/* Rarity badge */}
@@ -196,7 +198,7 @@ function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
</p>
{/* Quantity selector for consumables */}
{isConsumable && !item.is_owned && item.is_available && (
{isConsumable && item.is_available && (
<div className="flex items-center justify-center gap-2 mb-3">
<button
onClick={decrementQuantity}
@@ -236,7 +238,7 @@ function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
) : (
<NeonButton
size="sm"
onClick={() => onPurchase(item, quantity)}
onClick={() => onPurchase(item, isConsumable ? quantity : 1)}
disabled={isPurchasing || !item.is_available}
>
{isPurchasing ? (
@@ -460,9 +462,9 @@ export function ShopPage() {
</h3>
<ul className="text-gray-400 text-sm space-y-1">
<li> Выполняй задания в <span className="text-neon-400">сертифицированных</span> марафонах</li>
<li> Easy задание 5 монет, Medium 12 монет, Hard 25 монет</li>
<li> Playthrough ~5% от заработанных очков</li>
<li> Топ-3 места в марафоне: 1-е 100, 2-е 50, 3-е 30 монет</li>
<li> Easy задание 10 монет, Medium 20 монет, Hard 35 монет</li>
<li> Playthrough ~10% от заработанных очков</li>
<li> Топ-3 места в марафоне: 1-е 500, 2-е 250, 3-е 150 монет</li>
</ul>
</GlassCard>
</div>

View File

@@ -0,0 +1,404 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { shopApi, adminApi } from '@/api'
import { useToast } from '@/store/toast'
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
import {
Loader2, Gift, ArrowLeft, Package,
Frame, Type, Palette, Image, Zap, SkipForward,
Minus, Plus, Shuffle, Dice5, Copy, Undo2, X, XCircle
} from 'lucide-react'
import type { ShopItem, ShopItemType, ShopItemPublic, AdminUser } from '@/types'
import { RARITY_COLORS, RARITY_NAMES } from '@/types'
import clsx from 'clsx'
const ITEM_TYPE_ICONS: Record<ShopItemType, React.ReactNode> = {
frame: <Frame className="w-5 h-5" />,
title: <Type className="w-5 h-5" />,
name_color: <Palette className="w-5 h-5" />,
background: <Image className="w-5 h-5" />,
consumable: <Zap className="w-5 h-5" />,
}
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
skip: <SkipForward className="w-8 h-8" />,
skip_exile: <XCircle className="w-8 h-8" />,
boost: <Zap className="w-8 h-8" />,
wild_card: <Shuffle className="w-8 h-8" />,
lucky_dice: <Dice5 className="w-8 h-8" />,
copycat: <Copy className="w-8 h-8" />,
undo: <Undo2 className="w-8 h-8" />,
}
const ITEM_TYPE_LABELS: Record<ShopItemType | 'all', string> = {
all: 'Все',
consumable: 'Расходники',
frame: 'Рамки',
title: 'Титулы',
name_color: 'Цвета',
background: 'Фоны',
}
interface GrantItemCardProps {
item: ShopItem
onGrant: (item: ShopItem) => void
}
function GrantItemCard({ item, onGrant }: GrantItemCardProps) {
const rarityColors = RARITY_COLORS[item.rarity]
const getItemPreview = () => {
if (item.item_type === 'consumable') {
return CONSUMABLE_ICONS[item.code] || <Package className="w-8 h-8" />
}
if (item.item_type === 'name_color') {
const data = item.asset_data as { style?: string; color?: string; gradient?: string[] } | null
if (data?.style === 'gradient' && data.gradient) {
return (
<div
className="w-12 h-12 rounded-full border-2 border-dark-600"
style={{ background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }}
/>
)
}
if (data?.style === 'animated') {
return (
<div
className="w-12 h-12 rounded-full border-2 border-dark-600 animate-rainbow-rotate"
style={{
background: 'linear-gradient(135deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #9400d3, #ff0000)',
backgroundSize: '400% 400%'
}}
/>
)
}
const solidColor = data?.color || '#ffffff'
return (
<div
className="w-12 h-12 rounded-full border-2 border-dark-600"
style={{ backgroundColor: solidColor }}
/>
)
}
if (item.item_type === 'background') {
const data = item.asset_data as { type?: string; color?: string; gradient?: string[] } | null
let bgStyle: React.CSSProperties = {}
if (data?.type === 'solid' && data.color) {
bgStyle = { backgroundColor: data.color }
} else if (data?.type === 'gradient' && data.gradient) {
bgStyle = { background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }
}
return (
<div
className="w-16 h-12 rounded-lg border-2 border-dark-600"
style={bgStyle}
/>
)
}
if (item.item_type === 'frame') {
const frameItem: ShopItemPublic = {
id: item.id,
code: item.code,
name: item.name,
item_type: item.item_type,
rarity: item.rarity,
asset_data: item.asset_data,
}
return <FramePreview frame={frameItem} size="lg" />
}
if (item.item_type === 'title' && item.asset_data?.text) {
return (
<span
className="text-lg font-bold"
style={{ color: (item.asset_data.color as string) || '#ffffff' }}
>
{item.asset_data.text as string}
</span>
)
}
return ITEM_TYPE_ICONS[item.item_type]
}
return (
<GlassCard
className={clsx(
'p-4 border transition-all duration-300 hover:scale-[1.02]',
rarityColors.border
)}
>
{/* Rarity badge */}
<div className={clsx('text-xs font-medium mb-2', rarityColors.text)}>
{RARITY_NAMES[item.rarity]}
</div>
{/* Item preview */}
<div className="flex justify-center items-center h-20 mb-3">
{getItemPreview()}
</div>
{/* Item info */}
<h3 className="text-white font-semibold text-center mb-1">{item.name}</h3>
<p className="text-gray-400 text-xs text-center mb-3 line-clamp-2">
{item.description}
</p>
{/* Grant button */}
<NeonButton
size="sm"
color="neon"
onClick={() => onGrant(item)}
className="w-full"
icon={<Gift className="w-4 h-4" />}
>
Выдать
</NeonButton>
</GlassCard>
)
}
export function AdminGrantItemPage() {
const { userId } = useParams<{ userId: string }>()
const navigate = useNavigate()
const toast = useToast()
const [user, setUser] = useState<AdminUser | null>(null)
const [items, setItems] = useState<ShopItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState<ShopItemType | 'all'>('all')
// Grant modal
const [grantItem, setGrantItem] = useState<ShopItem | null>(null)
const [grantQuantity, setGrantQuantity] = useState(1)
const [grantReason, setGrantReason] = useState('')
const [isGranting, setIsGranting] = useState(false)
useEffect(() => {
loadData()
}, [userId])
const loadData = async () => {
if (!userId) return
setIsLoading(true)
try {
const [userData, itemsData] = await Promise.all([
adminApi.getUser(parseInt(userId)),
shopApi.getItems(),
])
setUser(userData)
setItems(itemsData)
} catch (err) {
console.error('Failed to load data:', err)
toast.error('Ошибка загрузки данных')
} finally {
setIsLoading(false)
}
}
const handleGrant = async () => {
if (!grantItem || !userId || !grantReason.trim()) return
setIsGranting(true)
try {
await shopApi.adminGrantItem(
parseInt(userId),
grantItem.id,
grantQuantity,
grantReason
)
toast.success(`Выдано ${grantItem.name} x${grantQuantity} для ${user?.nickname}`)
setGrantItem(null)
setGrantQuantity(1)
setGrantReason('')
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Ошибка выдачи предмета')
} finally {
setIsGranting(false)
}
}
const filteredItems = activeTab === 'all'
? items
: items.filter(item => item.item_type === activeTab)
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка...</p>
</div>
)
}
if (!user) {
return (
<div className="text-center py-24">
<p className="text-gray-400">Пользователь не найден</p>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/admin/users')}
className="w-10 h-10 rounded-xl bg-dark-700 border border-dark-600 flex items-center justify-center text-gray-400 hover:text-white hover:border-neon-500/30 transition-all"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<Gift className="w-7 h-7 text-green-400" />
Выдать предмет
</h1>
<p className="text-gray-400">
Получатель: <span className="text-white font-medium">{user.nickname}</span>
</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 flex-wrap">
{(Object.keys(ITEM_TYPE_LABELS) as (ShopItemType | 'all')[]).map((type) => (
<button
key={type}
onClick={() => setActiveTab(type)}
className={clsx(
'px-4 py-2 rounded-xl text-sm font-medium transition-all',
activeTab === type
? 'bg-neon-500/20 text-neon-400 border border-neon-500/30'
: 'bg-dark-700/50 text-gray-400 border border-dark-600 hover:text-white hover:border-dark-500'
)}
>
{ITEM_TYPE_LABELS[type]}
</button>
))}
</div>
{/* Items grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{filteredItems.map(item => (
<GrantItemCard
key={item.id}
item={item}
onGrant={setGrantItem}
/>
))}
</div>
{filteredItems.length === 0 && (
<div className="text-center py-12">
<Package className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400">Нет предметов в этой категории</p>
</div>
)}
{/* Grant Modal */}
{grantItem && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Gift className="w-5 h-5 text-green-400" />
Выдать {grantItem.name}
</h3>
<button
onClick={() => {
setGrantItem(null)
setGrantQuantity(1)
setGrantReason('')
}}
className="p-1.5 text-gray-400 hover:text-white rounded-lg hover:bg-dark-600/50 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-gray-400 mb-4">
Получатель: <span className="text-white">{user.nickname}</span>
</p>
{/* Quantity */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Количество
</label>
<div className="flex items-center gap-3">
<button
onClick={() => setGrantQuantity(Math.max(1, grantQuantity - 1))}
disabled={grantQuantity <= 1}
className="w-10 h-10 rounded-xl bg-dark-700 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-gray-400 hover:text-white transition-colors"
>
<Minus className="w-5 h-5" />
</button>
<input
type="number"
min="1"
max="100"
value={grantQuantity}
onChange={(e) => setGrantQuantity(Math.max(1, Math.min(100, parseInt(e.target.value) || 1)))}
className="w-20 text-center bg-dark-700/50 border border-dark-600 rounded-xl px-3 py-2 text-white font-bold text-lg focus:outline-none focus:border-neon-500/50"
/>
<button
onClick={() => setGrantQuantity(Math.min(100, grantQuantity + 1))}
disabled={grantQuantity >= 100}
className="w-10 h-10 rounded-xl bg-dark-700 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-gray-400 hover:text-white transition-colors"
>
<Plus className="w-5 h-5" />
</button>
</div>
</div>
{/* Reason */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-300 mb-2">
Причина <span className="text-red-400">*</span>
</label>
<textarea
value={grantReason}
onChange={(e) => setGrantReason(e.target.value)}
placeholder="Причина выдачи предмета..."
rows={3}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-neon-500/50 transition-colors resize-none"
/>
</div>
<div className="flex gap-3 justify-end">
<NeonButton
variant="ghost"
onClick={() => {
setGrantItem(null)
setGrantQuantity(1)
setGrantReason('')
}}
>
Отмена
</NeonButton>
<NeonButton
color="neon"
onClick={handleGrant}
disabled={!grantReason.trim() || isGranting}
isLoading={isGranting}
icon={<Gift className="w-4 h-4" />}
>
Выдать x{grantQuantity}
</NeonButton>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -12,13 +12,15 @@ import {
Shield,
MessageCircle,
Sparkles,
Lock
Lock,
Gift
} from 'lucide-react'
const navItems = [
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
{ to: '/admin/promo', icon: Gift, label: 'Промокоды' },
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
{ to: '/admin/content', icon: FileText, label: 'Контент' },

Some files were not shown because too many files have changed in this diff Show More