Compare commits
18 Commits
2874b64481
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b1490dec8 | |||
| 765da3c37f | |||
| 9f79daf796 | |||
| 58c390c768 | |||
| 72089d1b47 | |||
| 9cfe99ff7e | |||
| 2d8e80f258 | |||
| f78eacb1a5 | |||
| cf0df928b1 | |||
| 5c452c5c74 | |||
| 2b6f2888ee | |||
| b6eecc4483 | |||
| 3256c40841 | |||
| 146ed5e489 | |||
| cd78a99ce7 | |||
| 76de7ccbdb | |||
| e63d6c8489 | |||
| 1751c4dd4c |
@@ -450,13 +450,13 @@ def upgrade() -> None:
|
|||||||
'item_type': 'consumable',
|
'item_type': 'consumable',
|
||||||
'code': 'boost',
|
'code': 'boost',
|
||||||
'name': 'Буст x1.5',
|
'name': 'Буст x1.5',
|
||||||
'description': 'Множитель очков x1.5 на следующие 2 часа',
|
'description': 'Множитель очков x1.5 на текущее задание',
|
||||||
'price': 200,
|
'price': 200,
|
||||||
'rarity': 'rare',
|
'rarity': 'rare',
|
||||||
'asset_data': {
|
'asset_data': {
|
||||||
'effect': 'boost',
|
'effect': 'boost',
|
||||||
'multiplier': 1.5,
|
'multiplier': 1.5,
|
||||||
'duration_hours': 2,
|
'one_time': True,
|
||||||
'icon': 'zap'
|
'icon': 'zap'
|
||||||
},
|
},
|
||||||
'is_active': True,
|
'is_active': True,
|
||||||
|
|||||||
46
backend/alembic/versions/026_update_boost_description.py
Normal file
46
backend/alembic/versions/026_update_boost_description.py
Normal 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'
|
||||||
|
""")
|
||||||
83
backend/alembic/versions/027_consumables_redesign.py
Normal file
83
backend/alembic/versions/027_consumables_redesign.py
Normal 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}')
|
||||||
|
""")
|
||||||
58
backend/alembic/versions/028_add_promo_codes.py
Normal file
58
backend/alembic/versions/028_add_promo_codes.py
Normal 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')
|
||||||
30
backend/alembic/versions/029_add_tracked_time.py
Normal file
30
backend/alembic/versions/029_add_tracked_time.py
Normal 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')
|
||||||
46
backend/alembic/versions/029_add_widget_tokens.py
Normal file
46
backend/alembic/versions/029_add_widget_tokens.py
Normal 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')
|
||||||
28
backend/alembic/versions/030_merge_029_heads.py
Normal file
28
backend/alembic/versions/030_merge_029_heads.py
Normal 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
|
||||||
65
backend/alembic/versions/031_add_exiled_games.py
Normal file
65
backend/alembic/versions/031_add_exiled_games.py
Normal 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')
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop
|
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop, promo, widgets
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
@@ -17,3 +17,5 @@ router.include_router(assignments.router)
|
|||||||
router.include_router(telegram.router)
|
router.include_router(telegram.router)
|
||||||
router.include_router(content.router)
|
router.include_router(content.router)
|
||||||
router.include_router(shop.router)
|
router.include_router(shop.router)
|
||||||
|
router.include_router(promo.router)
|
||||||
|
router.include_router(widgets.router)
|
||||||
|
|||||||
@@ -366,7 +366,7 @@ async def save_challenges(
|
|||||||
description=ch_data.description,
|
description=ch_data.description,
|
||||||
type=ch_type,
|
type=ch_type,
|
||||||
difficulty=difficulty,
|
difficulty=difficulty,
|
||||||
points=max(1, min(500, ch_data.points)),
|
points=max(1, ch_data.points),
|
||||||
estimated_time=ch_data.estimated_time,
|
estimated_time=ch_data.estimated_time,
|
||||||
proof_type=proof_type,
|
proof_type=proof_type,
|
||||||
proof_hint=ch_data.proof_hint,
|
proof_hint=ch_data.proof_hint,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from app.models import (
|
|||||||
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Game,
|
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Game,
|
||||||
SwapRequest as SwapRequestModel, SwapRequestStatus, User,
|
SwapRequest as SwapRequestModel, SwapRequestStatus, User,
|
||||||
)
|
)
|
||||||
|
from app.models.bonus_assignment import BonusAssignment
|
||||||
from fastapi import UploadFile, File, Form
|
from fastapi import UploadFile, File, Form
|
||||||
|
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
@@ -275,6 +276,26 @@ async def stop_event(
|
|||||||
return MessageResponse(message="Event stopped")
|
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(
|
def build_swap_request_response(
|
||||||
swap_req: SwapRequestModel,
|
swap_req: SwapRequestModel,
|
||||||
) -> SwapRequestResponse:
|
) -> SwapRequestResponse:
|
||||||
@@ -298,20 +319,8 @@ def build_swap_request_response(
|
|||||||
role=swap_req.to_participant.user.role,
|
role=swap_req.to_participant.user.role,
|
||||||
created_at=swap_req.to_participant.user.created_at,
|
created_at=swap_req.to_participant.user.created_at,
|
||||||
),
|
),
|
||||||
from_challenge=SwapRequestChallengeInfo(
|
from_challenge=build_assignment_info(swap_req.from_assignment),
|
||||||
title=swap_req.from_assignment.challenge.title,
|
to_challenge=build_assignment_info(swap_req.to_assignment),
|
||||||
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,
|
|
||||||
),
|
|
||||||
created_at=swap_req.created_at,
|
created_at=swap_req.created_at,
|
||||||
responded_at=swap_req.responded_at,
|
responded_at=swap_req.responded_at,
|
||||||
)
|
)
|
||||||
@@ -349,11 +358,12 @@ async def create_swap_request(
|
|||||||
if target.id == participant.id:
|
if target.id == participant.id:
|
||||||
raise HTTPException(status_code=400, detail="Cannot swap with yourself")
|
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(
|
result = await db.execute(
|
||||||
select(Assignment)
|
select(Assignment)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
selectinload(Assignment.game), # For playthrough
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Assignment.participant_id == participant.id,
|
Assignment.participant_id == participant.id,
|
||||||
@@ -365,7 +375,8 @@ async def create_swap_request(
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Assignment)
|
select(Assignment)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
selectinload(Assignment.game), # For playthrough
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Assignment.participant_id == target.id,
|
Assignment.participant_id == target.id,
|
||||||
@@ -417,7 +428,7 @@ async def create_swap_request(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(swap_request)
|
await db.refresh(swap_request)
|
||||||
|
|
||||||
# Load relationships for response
|
# Load relationships for response (including game for playthrough)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(SwapRequestModel)
|
select(SwapRequestModel)
|
||||||
.options(
|
.options(
|
||||||
@@ -426,9 +437,13 @@ async def create_swap_request(
|
|||||||
selectinload(SwapRequestModel.from_assignment)
|
selectinload(SwapRequestModel.from_assignment)
|
||||||
.selectinload(Assignment.challenge)
|
.selectinload(Assignment.challenge)
|
||||||
.selectinload(Challenge.game),
|
.selectinload(Challenge.game),
|
||||||
|
selectinload(SwapRequestModel.from_assignment)
|
||||||
|
.selectinload(Assignment.game),
|
||||||
selectinload(SwapRequestModel.to_assignment)
|
selectinload(SwapRequestModel.to_assignment)
|
||||||
.selectinload(Assignment.challenge)
|
.selectinload(Assignment.challenge)
|
||||||
.selectinload(Challenge.game),
|
.selectinload(Challenge.game),
|
||||||
|
selectinload(SwapRequestModel.to_assignment)
|
||||||
|
.selectinload(Assignment.game),
|
||||||
)
|
)
|
||||||
.where(SwapRequestModel.id == swap_request.id)
|
.where(SwapRequestModel.id == swap_request.id)
|
||||||
)
|
)
|
||||||
@@ -461,9 +476,13 @@ async def get_my_swap_requests(
|
|||||||
selectinload(SwapRequestModel.from_assignment)
|
selectinload(SwapRequestModel.from_assignment)
|
||||||
.selectinload(Assignment.challenge)
|
.selectinload(Assignment.challenge)
|
||||||
.selectinload(Challenge.game),
|
.selectinload(Challenge.game),
|
||||||
|
selectinload(SwapRequestModel.from_assignment)
|
||||||
|
.selectinload(Assignment.game),
|
||||||
selectinload(SwapRequestModel.to_assignment)
|
selectinload(SwapRequestModel.to_assignment)
|
||||||
.selectinload(Assignment.challenge)
|
.selectinload(Assignment.challenge)
|
||||||
.selectinload(Challenge.game),
|
.selectinload(Challenge.game),
|
||||||
|
selectinload(SwapRequestModel.to_assignment)
|
||||||
|
.selectinload(Assignment.game),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
SwapRequestModel.event_id == event.id,
|
SwapRequestModel.event_id == event.id,
|
||||||
@@ -553,10 +572,39 @@ async def accept_swap_request(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
raise HTTPException(status_code=400, detail="One or both assignments are no longer active")
|
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_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.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.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
|
# Update request status
|
||||||
swap_request.status = SwapRequestStatus.ACCEPTED.value
|
swap_request.status = SwapRequestStatus.ACCEPTED.value
|
||||||
@@ -865,8 +913,10 @@ async def get_swap_candidates(
|
|||||||
if not event or event.type != EventType.SWAP.value:
|
if not event or event.type != EventType.SWAP.value:
|
||||||
raise HTTPException(status_code=400, detail="No active swap event")
|
raise HTTPException(status_code=400, detail="No active swap event")
|
||||||
|
|
||||||
# Get all participants except current user with active assignments
|
|
||||||
from app.models import Game
|
from app.models import Game
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
# Get challenge-based assignments
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Participant, Assignment, Challenge, Game)
|
select(Participant, Assignment, Challenge, Game)
|
||||||
.join(Assignment, Assignment.participant_id == Participant.id)
|
.join(Assignment, Assignment.participant_id == Participant.id)
|
||||||
@@ -877,12 +927,11 @@ async def get_swap_candidates(
|
|||||||
Participant.marathon_id == marathon_id,
|
Participant.marathon_id == marathon_id,
|
||||||
Participant.id != participant.id,
|
Participant.id != participant.id,
|
||||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||||
|
Assignment.is_playthrough == False,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
rows = result.all()
|
for p, assignment, challenge, game in result.all():
|
||||||
|
candidates.append(SwapCandidate(
|
||||||
return [
|
|
||||||
SwapCandidate(
|
|
||||||
participant_id=p.id,
|
participant_id=p.id,
|
||||||
user=UserPublic(
|
user=UserPublic(
|
||||||
id=p.user.id,
|
id=p.user.id,
|
||||||
@@ -892,14 +941,45 @@ async def get_swap_candidates(
|
|||||||
role=p.user.role,
|
role=p.user.role,
|
||||||
created_at=p.user.created_at,
|
created_at=p.user.created_at,
|
||||||
),
|
),
|
||||||
|
is_playthrough=False,
|
||||||
challenge_title=challenge.title,
|
challenge_title=challenge.title,
|
||||||
challenge_description=challenge.description,
|
challenge_description=challenge.description,
|
||||||
challenge_points=challenge.points,
|
challenge_points=challenge.points,
|
||||||
challenge_difficulty=challenge.difficulty,
|
challenge_difficulty=challenge.difficulty,
|
||||||
game_title=game.title,
|
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])
|
@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,
|
streak_at_completion=assignment.streak_at_completion,
|
||||||
started_at=assignment.started_at,
|
started_at=assignment.started_at,
|
||||||
completed_at=assignment.completed_at,
|
completed_at=assignment.completed_at,
|
||||||
|
tracked_time_minutes=assignment.tracked_time_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Regular challenge assignment
|
# Regular challenge assignment
|
||||||
@@ -1026,6 +1107,7 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
|
|||||||
streak_at_completion=assignment.streak_at_completion,
|
streak_at_completion=assignment.streak_at_completion,
|
||||||
started_at=assignment.started_at,
|
started_at=assignment.started_at,
|
||||||
completed_at=assignment.completed_at,
|
completed_at=assignment.completed_at,
|
||||||
|
tracked_time_minutes=assignment.tracked_time_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from app.api.deps import (
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, GameType,
|
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 import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
||||||
from app.schemas.assignment import AvailableGamesCount
|
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())
|
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 = []
|
available_games = []
|
||||||
for game in games_with_content:
|
for game in games_with_content:
|
||||||
|
# Исключаем изгнанные игры
|
||||||
|
if game.id in exiled_game_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
if game.game_type == GameType.PLAYTHROUGH.value:
|
if game.game_type == GameType.PLAYTHROUGH.value:
|
||||||
# Исключаем если игра уже завершена/дропнута
|
# Исключаем если игра уже завершена/дропнута
|
||||||
if game.id not in finished_playthrough_game_ids:
|
if game.id not in finished_playthrough_game_ids:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.models import (
|
|||||||
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
||||||
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
||||||
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus, User,
|
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus, User,
|
||||||
|
ExiledGame,
|
||||||
)
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
MarathonCreate,
|
MarathonCreate,
|
||||||
@@ -35,6 +36,8 @@ from app.schemas import (
|
|||||||
MessageResponse,
|
MessageResponse,
|
||||||
UserPublic,
|
UserPublic,
|
||||||
SetParticipantRole,
|
SetParticipantRole,
|
||||||
|
OrganizerSkipRequest,
|
||||||
|
ExiledGameResponse,
|
||||||
)
|
)
|
||||||
from app.services.telegram_notifier import telegram_notifier
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
@@ -1004,3 +1007,224 @@ async def resolve_marathon_dispute(
|
|||||||
return MessageResponse(
|
return MessageResponse(
|
||||||
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
|
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
299
backend/app/api/v1/promo.py
Normal 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
|
||||||
|
]
|
||||||
@@ -10,7 +10,7 @@ from app.api.deps import CurrentUser, DbSession, require_participant, require_ad
|
|||||||
from app.models import (
|
from app.models import (
|
||||||
User, Marathon, Participant, Assignment, AssignmentStatus,
|
User, Marathon, Participant, Assignment, AssignmentStatus,
|
||||||
ShopItem, UserInventory, CoinTransaction, ShopItemType,
|
ShopItem, UserInventory, CoinTransaction, ShopItemType,
|
||||||
CertificationStatus,
|
CertificationStatus, Challenge, Game,
|
||||||
)
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
ShopItemResponse, ShopItemCreate, ShopItemUpdate,
|
ShopItemResponse, ShopItemCreate, ShopItemUpdate,
|
||||||
@@ -19,11 +19,14 @@ from app.schemas import (
|
|||||||
EquipItemRequest, EquipItemResponse,
|
EquipItemRequest, EquipItemResponse,
|
||||||
CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest,
|
CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest,
|
||||||
CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse,
|
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.shop import shop_service
|
||||||
from app.services.coins import coins_service
|
from app.services.coins import coins_service
|
||||||
from app.services.consumables import consumables_service
|
from app.services.consumables import consumables_service
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
router = APIRouter(prefix="/shop", tags=["shop"])
|
router = APIRouter(prefix="/shop", tags=["shop"])
|
||||||
|
|
||||||
@@ -181,12 +184,23 @@ async def use_consumable(
|
|||||||
# Get participant
|
# Get participant
|
||||||
participant = await require_participant(db, current_user.id, data.marathon_id)
|
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
|
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:
|
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(
|
result = await db.execute(
|
||||||
select(Assignment).where(
|
select(Assignment).where(
|
||||||
Assignment.id == data.assignment_id,
|
Assignment.id == data.assignment_id,
|
||||||
@@ -201,15 +215,32 @@ async def use_consumable(
|
|||||||
if data.item_code == "skip":
|
if data.item_code == "skip":
|
||||||
effect = await consumables_service.use_skip(db, current_user, participant, marathon, assignment)
|
effect = await consumables_service.use_skip(db, current_user, participant, marathon, assignment)
|
||||||
effect_description = "Assignment skipped without penalty"
|
effect_description = "Assignment skipped without penalty"
|
||||||
elif data.item_code == "shield":
|
elif data.item_code == "skip_exile":
|
||||||
effect = await consumables_service.use_shield(db, current_user, participant, marathon)
|
effect = await consumables_service.use_skip_exile(db, current_user, participant, marathon, assignment)
|
||||||
effect_description = "Shield activated - next drop will be free"
|
effect_description = "Assignment skipped, game exiled from pool"
|
||||||
elif data.item_code == "boost":
|
elif data.item_code == "boost":
|
||||||
effect = await consumables_service.use_boost(db, current_user, participant, marathon)
|
effect = await consumables_service.use_boost(db, current_user, participant, marathon)
|
||||||
effect_description = f"Boost x{effect['multiplier']} activated for next complete"
|
effect_description = f"Boost x{effect['multiplier']} activated for current assignment"
|
||||||
elif data.item_code == "reroll":
|
elif data.item_code == "wild_card":
|
||||||
effect = await consumables_service.use_reroll(db, current_user, participant, marathon, assignment)
|
if data.game_id is None:
|
||||||
effect_description = "Assignment rerolled - you can spin again"
|
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:
|
else:
|
||||||
raise HTTPException(status_code=400, detail=f"Unknown consumable: {data.item_code}")
|
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
|
# Get inventory counts for all consumables
|
||||||
skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip")
|
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")
|
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
|
# Calculate remaining skips for this marathon
|
||||||
skips_remaining = None
|
skips_remaining = None
|
||||||
@@ -254,17 +288,107 @@ async def get_consumables_status(
|
|||||||
|
|
||||||
return ConsumablesStatusResponse(
|
return ConsumablesStatusResponse(
|
||||||
skips_available=skips_available,
|
skips_available=skips_available,
|
||||||
|
skip_exiles_available=skip_exiles_available,
|
||||||
skips_used=participant.skips_used,
|
skips_used=participant.skips_used,
|
||||||
skips_remaining=skips_remaining,
|
skips_remaining=skips_remaining,
|
||||||
shields_available=shields_available,
|
|
||||||
has_shield=participant.has_shield,
|
|
||||||
boosts_available=boosts_available,
|
boosts_available=boosts_available,
|
||||||
has_active_boost=participant.has_active_boost,
|
has_active_boost=participant.has_active_boost,
|
||||||
boost_multiplier=consumables_service.BOOST_MULTIPLIER if participant.has_active_boost else None,
|
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 ===
|
# === Coins ===
|
||||||
|
|
||||||
@router.get("/balance", response_model=CoinsBalanceResponse)
|
@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,
|
certified_by_nickname=current_user.nickname if data.approve else None,
|
||||||
rejection_reason=marathon.certification_rejection_reason,
|
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}")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.models import (
|
|||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
||||||
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse,
|
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse,
|
||||||
|
TrackTimeRequest,
|
||||||
)
|
)
|
||||||
from app.schemas.game import PlaythroughInfo
|
from app.schemas.game import PlaythroughInfo
|
||||||
from app.services.points import PointsService
|
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,
|
drop_penalty=drop_penalty,
|
||||||
bonus_challenges=bonus_responses,
|
bonus_challenges=bonus_responses,
|
||||||
event_type=assignment.event_type,
|
event_type=assignment.event_type,
|
||||||
|
tracked_time_minutes=assignment.tracked_time_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Regular challenge assignment
|
# Regular challenge assignment
|
||||||
@@ -476,6 +478,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
|
|||||||
completed_at=assignment.completed_at,
|
completed_at=assignment.completed_at,
|
||||||
drop_penalty=drop_penalty,
|
drop_penalty=drop_penalty,
|
||||||
event_type=assignment.event_type,
|
event_type=assignment.event_type,
|
||||||
|
tracked_time_minutes=assignment.tracked_time_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -589,6 +592,13 @@ async def complete_assignment(
|
|||||||
if assignment.is_playthrough:
|
if assignment.is_playthrough:
|
||||||
game = assignment.game
|
game = assignment.game
|
||||||
marathon_id = game.marathon_id
|
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
|
base_playthrough_points = game.playthrough_points
|
||||||
|
|
||||||
# Calculate BASE bonus points from completed bonus assignments (before multiplier)
|
# Calculate BASE bonus points from completed bonus assignments (before multiplier)
|
||||||
@@ -621,10 +631,12 @@ async def complete_assignment(
|
|||||||
if ba.status == BonusAssignmentStatus.COMPLETED.value:
|
if ba.status == BonusAssignmentStatus.COMPLETED.value:
|
||||||
ba.points_earned = int(ba.challenge.points * multiplier)
|
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)
|
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
|
||||||
if boost_multiplier > 1.0:
|
lucky_dice_multiplier = consumables_service.consume_lucky_dice_on_complete(participant)
|
||||||
total_points = int(total_points * boost_multiplier)
|
combined_multiplier = boost_multiplier * lucky_dice_multiplier
|
||||||
|
if combined_multiplier != 1.0:
|
||||||
|
total_points = int(total_points * combined_multiplier)
|
||||||
|
|
||||||
# Update assignment
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.COMPLETED.value
|
assignment.status = AssignmentStatus.COMPLETED.value
|
||||||
@@ -666,6 +678,8 @@ async def complete_assignment(
|
|||||||
activity_data["is_redo"] = True
|
activity_data["is_redo"] = True
|
||||||
if boost_multiplier > 1.0:
|
if boost_multiplier > 1.0:
|
||||||
activity_data["boost_multiplier"] = boost_multiplier
|
activity_data["boost_multiplier"] = boost_multiplier
|
||||||
|
if lucky_dice_multiplier != 1.0:
|
||||||
|
activity_data["lucky_dice_multiplier"] = lucky_dice_multiplier
|
||||||
if coins_earned > 0:
|
if coins_earned > 0:
|
||||||
activity_data["coins_earned"] = coins_earned
|
activity_data["coins_earned"] = coins_earned
|
||||||
if playthrough_event:
|
if playthrough_event:
|
||||||
@@ -728,10 +742,12 @@ async def complete_assignment(
|
|||||||
total_points += common_enemy_bonus
|
total_points += common_enemy_bonus
|
||||||
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
|
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)
|
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
|
||||||
if boost_multiplier > 1.0:
|
lucky_dice_multiplier = consumables_service.consume_lucky_dice_on_complete(participant)
|
||||||
total_points = int(total_points * boost_multiplier)
|
combined_multiplier = boost_multiplier * lucky_dice_multiplier
|
||||||
|
if combined_multiplier != 1.0:
|
||||||
|
total_points = int(total_points * combined_multiplier)
|
||||||
|
|
||||||
# Update assignment
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.COMPLETED.value
|
assignment.status = AssignmentStatus.COMPLETED.value
|
||||||
@@ -772,6 +788,8 @@ async def complete_assignment(
|
|||||||
activity_data["is_redo"] = True
|
activity_data["is_redo"] = True
|
||||||
if boost_multiplier > 1.0:
|
if boost_multiplier > 1.0:
|
||||||
activity_data["boost_multiplier"] = boost_multiplier
|
activity_data["boost_multiplier"] = boost_multiplier
|
||||||
|
if lucky_dice_multiplier != 1.0:
|
||||||
|
activity_data["lucky_dice_multiplier"] = lucky_dice_multiplier
|
||||||
if coins_earned > 0:
|
if coins_earned > 0:
|
||||||
activity_data["coins_earned"] = coins_earned
|
activity_data["coins_earned"] = coins_earned
|
||||||
if assignment.event_type == EventType.JACKPOT.value:
|
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)
|
@router.post("/assignments/{assignment_id}/drop", response_model=DropResult)
|
||||||
async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession):
|
async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""Drop current assignment"""
|
"""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
|
participant.drop_count, game.playthrough_points, playthrough_event
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for shield - if active, no penalty
|
# Save drop data for potential undo
|
||||||
shield_used = False
|
consumables_service.save_drop_for_undo(
|
||||||
if consumables_service.consume_shield(participant):
|
participant, penalty, participant.current_streak
|
||||||
penalty = 0
|
)
|
||||||
shield_used = True
|
|
||||||
|
|
||||||
# Update assignment
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.DROPPED.value
|
assignment.status = AssignmentStatus.DROPPED.value
|
||||||
@@ -921,8 +969,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
|||||||
"penalty": penalty,
|
"penalty": penalty,
|
||||||
"lost_bonuses": completed_bonuses_count,
|
"lost_bonuses": completed_bonuses_count,
|
||||||
}
|
}
|
||||||
if shield_used:
|
|
||||||
activity_data["shield_used"] = True
|
|
||||||
if playthrough_event:
|
if playthrough_event:
|
||||||
activity_data["event_type"] = playthrough_event.type
|
activity_data["event_type"] = playthrough_event.type
|
||||||
activity_data["free_drop"] = True
|
activity_data["free_drop"] = True
|
||||||
@@ -941,7 +987,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
|||||||
penalty=penalty,
|
penalty=penalty,
|
||||||
total_points=participant.total_points,
|
total_points=participant.total_points,
|
||||||
new_drop_count=participant.drop_count,
|
new_drop_count=participant.drop_count,
|
||||||
shield_used=shield_used,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Regular challenge drop
|
# 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)
|
# Calculate penalty (0 if double_risk event is active)
|
||||||
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
|
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
|
||||||
|
|
||||||
# Check for shield - if active, no penalty
|
# Save drop data for potential undo
|
||||||
shield_used = False
|
consumables_service.save_drop_for_undo(
|
||||||
if consumables_service.consume_shield(participant):
|
participant, penalty, participant.current_streak
|
||||||
penalty = 0
|
)
|
||||||
shield_used = True
|
|
||||||
|
|
||||||
# Update assignment
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.DROPPED.value
|
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,
|
"difficulty": assignment.challenge.difficulty,
|
||||||
"penalty": penalty,
|
"penalty": penalty,
|
||||||
}
|
}
|
||||||
if shield_used:
|
|
||||||
activity_data["shield_used"] = True
|
|
||||||
if active_event:
|
if active_event:
|
||||||
activity_data["event_type"] = active_event.type
|
activity_data["event_type"] = active_event.type
|
||||||
if active_event.type == EventType.DOUBLE_RISK.value:
|
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,
|
penalty=penalty,
|
||||||
total_points=participant.total_points,
|
total_points=participant.total_points,
|
||||||
new_drop_count=participant.drop_count,
|
new_drop_count=participant.drop_count,
|
||||||
shield_used=shield_used,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1076,6 +1117,7 @@ async def get_my_history(
|
|||||||
started_at=a.started_at,
|
started_at=a.started_at,
|
||||||
completed_at=a.completed_at,
|
completed_at=a.completed_at,
|
||||||
bonus_challenges=bonus_responses,
|
bonus_challenges=bonus_responses,
|
||||||
|
tracked_time_minutes=a.tracked_time_minutes,
|
||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
# Regular challenge assignment
|
# Regular challenge assignment
|
||||||
@@ -1108,6 +1150,7 @@ async def get_my_history(
|
|||||||
streak_at_completion=a.streak_at_completion,
|
streak_at_completion=a.streak_at_completion,
|
||||||
started_at=a.started_at,
|
started_at=a.started_at,
|
||||||
completed_at=a.completed_at,
|
completed_at=a.completed_at,
|
||||||
|
tracked_time_minutes=a.tracked_time_minutes,
|
||||||
))
|
))
|
||||||
|
|
||||||
return responses
|
return responses
|
||||||
|
|||||||
423
backend/app/api/v1/widgets.py
Normal file
423
backend/app/api/v1/widgets.py
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
import secrets
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, HTTPException, status, Query
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, CurrentUser, require_participant
|
||||||
|
from app.models import (
|
||||||
|
WidgetToken, Participant, Marathon, Assignment, AssignmentStatus,
|
||||||
|
BonusAssignment, BonusAssignmentStatus,
|
||||||
|
)
|
||||||
|
from app.schemas.widget import (
|
||||||
|
WidgetTokenResponse,
|
||||||
|
WidgetTokenListItem,
|
||||||
|
WidgetLeaderboardEntry,
|
||||||
|
WidgetLeaderboardResponse,
|
||||||
|
WidgetCurrentResponse,
|
||||||
|
WidgetProgressResponse,
|
||||||
|
)
|
||||||
|
from app.schemas.common import MessageResponse
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/widgets", tags=["widgets"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_avatar_url(user) -> str | None:
|
||||||
|
"""Get avatar URL - through backend API if user has avatar, else telegram"""
|
||||||
|
if user.avatar_path:
|
||||||
|
return f"/api/v1/users/{user.id}/avatar"
|
||||||
|
return user.telegram_avatar_url
|
||||||
|
|
||||||
|
|
||||||
|
def generate_widget_token() -> str:
|
||||||
|
"""Generate a secure widget token"""
|
||||||
|
return f"wgt_{secrets.token_urlsafe(32)}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_widget_urls(marathon_id: int, token: str) -> dict[str, str]:
|
||||||
|
"""Build widget URLs for the token"""
|
||||||
|
base_url = settings.FRONTEND_URL or "http://localhost:5173"
|
||||||
|
params = f"marathon={marathon_id}&token={token}"
|
||||||
|
return {
|
||||||
|
"leaderboard": f"{base_url}/widget/leaderboard?{params}",
|
||||||
|
"current": f"{base_url}/widget/current?{params}",
|
||||||
|
"progress": f"{base_url}/widget/progress?{params}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# === Token management (authenticated) ===
|
||||||
|
|
||||||
|
@router.post("/marathons/{marathon_id}/token", response_model=WidgetTokenResponse)
|
||||||
|
async def create_widget_token(
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Create a widget token for the current user in a marathon"""
|
||||||
|
participant = await require_participant(db, current_user.id, marathon_id)
|
||||||
|
|
||||||
|
# Check if user already has an active token
|
||||||
|
existing = await db.scalar(
|
||||||
|
select(WidgetToken).where(
|
||||||
|
WidgetToken.participant_id == participant.id,
|
||||||
|
WidgetToken.marathon_id == marathon_id,
|
||||||
|
WidgetToken.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Return existing token
|
||||||
|
return WidgetTokenResponse(
|
||||||
|
id=existing.id,
|
||||||
|
token=existing.token,
|
||||||
|
created_at=existing.created_at,
|
||||||
|
expires_at=existing.expires_at,
|
||||||
|
is_active=existing.is_active,
|
||||||
|
urls=build_widget_urls(marathon_id, existing.token),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new token
|
||||||
|
token = generate_widget_token()
|
||||||
|
widget_token = WidgetToken(
|
||||||
|
token=token,
|
||||||
|
participant_id=participant.id,
|
||||||
|
marathon_id=marathon_id,
|
||||||
|
)
|
||||||
|
db.add(widget_token)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(widget_token)
|
||||||
|
|
||||||
|
return WidgetTokenResponse(
|
||||||
|
id=widget_token.id,
|
||||||
|
token=widget_token.token,
|
||||||
|
created_at=widget_token.created_at,
|
||||||
|
expires_at=widget_token.expires_at,
|
||||||
|
is_active=widget_token.is_active,
|
||||||
|
urls=build_widget_urls(marathon_id, widget_token.token),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{marathon_id}/tokens", response_model=list[WidgetTokenListItem])
|
||||||
|
async def list_widget_tokens(
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""List all widget tokens for the current user in a marathon"""
|
||||||
|
participant = await require_participant(db, current_user.id, marathon_id)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(WidgetToken)
|
||||||
|
.where(
|
||||||
|
WidgetToken.participant_id == participant.id,
|
||||||
|
WidgetToken.marathon_id == marathon_id,
|
||||||
|
)
|
||||||
|
.order_by(WidgetToken.created_at.desc())
|
||||||
|
)
|
||||||
|
tokens = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
WidgetTokenListItem(
|
||||||
|
id=t.id,
|
||||||
|
token=t.token,
|
||||||
|
created_at=t.created_at,
|
||||||
|
is_active=t.is_active,
|
||||||
|
)
|
||||||
|
for t in tokens
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tokens/{token_id}", response_model=MessageResponse)
|
||||||
|
async def revoke_widget_token(
|
||||||
|
token_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Revoke a widget token"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(WidgetToken)
|
||||||
|
.options(selectinload(WidgetToken.participant))
|
||||||
|
.where(WidgetToken.id == token_id)
|
||||||
|
)
|
||||||
|
widget_token = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not widget_token:
|
||||||
|
raise HTTPException(status_code=404, detail="Token not found")
|
||||||
|
|
||||||
|
if widget_token.participant.user_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to revoke this token")
|
||||||
|
|
||||||
|
widget_token.is_active = False
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return MessageResponse(message="Token revoked")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tokens/{token_id}/regenerate", response_model=WidgetTokenResponse)
|
||||||
|
async def regenerate_widget_token(
|
||||||
|
token_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Regenerate a widget token (deactivates old, creates new)"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(WidgetToken)
|
||||||
|
.options(selectinload(WidgetToken.participant))
|
||||||
|
.where(WidgetToken.id == token_id)
|
||||||
|
)
|
||||||
|
old_token = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not old_token:
|
||||||
|
raise HTTPException(status_code=404, detail="Token not found")
|
||||||
|
|
||||||
|
if old_token.participant.user_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
# Deactivate old token
|
||||||
|
old_token.is_active = False
|
||||||
|
|
||||||
|
# Create new token
|
||||||
|
new_token = WidgetToken(
|
||||||
|
token=generate_widget_token(),
|
||||||
|
participant_id=old_token.participant_id,
|
||||||
|
marathon_id=old_token.marathon_id,
|
||||||
|
)
|
||||||
|
db.add(new_token)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_token)
|
||||||
|
|
||||||
|
return WidgetTokenResponse(
|
||||||
|
id=new_token.id,
|
||||||
|
token=new_token.token,
|
||||||
|
created_at=new_token.created_at,
|
||||||
|
expires_at=new_token.expires_at,
|
||||||
|
is_active=new_token.is_active,
|
||||||
|
urls=build_widget_urls(new_token.marathon_id, new_token.token),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# === Public widget endpoints (authenticated via widget token) ===
|
||||||
|
|
||||||
|
async def validate_widget_token(token: str, marathon_id: int, db) -> WidgetToken:
|
||||||
|
"""Validate widget token and return it"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(WidgetToken)
|
||||||
|
.options(
|
||||||
|
selectinload(WidgetToken.participant).selectinload(Participant.user),
|
||||||
|
selectinload(WidgetToken.marathon),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
WidgetToken.token == token,
|
||||||
|
WidgetToken.marathon_id == marathon_id,
|
||||||
|
WidgetToken.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
widget_token = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not widget_token:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid widget token")
|
||||||
|
|
||||||
|
if widget_token.expires_at and widget_token.expires_at < datetime.utcnow():
|
||||||
|
raise HTTPException(status_code=401, detail="Widget token expired")
|
||||||
|
|
||||||
|
return widget_token
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/data/leaderboard", response_model=WidgetLeaderboardResponse)
|
||||||
|
async def widget_leaderboard(
|
||||||
|
marathon: int = Query(..., description="Marathon ID"),
|
||||||
|
token: str = Query(..., description="Widget token"),
|
||||||
|
count: int = Query(5, ge=1, le=50, description="Number of participants"),
|
||||||
|
db: DbSession = None,
|
||||||
|
):
|
||||||
|
"""Get leaderboard data for widget"""
|
||||||
|
widget_token = await validate_widget_token(token, marathon, db)
|
||||||
|
current_participant = widget_token.participant
|
||||||
|
|
||||||
|
# Get all participants ordered by points
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant)
|
||||||
|
.options(selectinload(Participant.user))
|
||||||
|
.where(Participant.marathon_id == marathon)
|
||||||
|
.order_by(Participant.total_points.desc())
|
||||||
|
)
|
||||||
|
all_participants = result.scalars().all()
|
||||||
|
|
||||||
|
total_participants = len(all_participants)
|
||||||
|
current_user_rank = None
|
||||||
|
|
||||||
|
# Find current user rank and build entries
|
||||||
|
entries = []
|
||||||
|
for rank, p in enumerate(all_participants, 1):
|
||||||
|
if p.id == current_participant.id:
|
||||||
|
current_user_rank = rank
|
||||||
|
|
||||||
|
if rank <= count:
|
||||||
|
user = p.user
|
||||||
|
entries.append(WidgetLeaderboardEntry(
|
||||||
|
rank=rank,
|
||||||
|
nickname=user.nickname,
|
||||||
|
avatar_url=get_avatar_url(user),
|
||||||
|
total_points=p.total_points,
|
||||||
|
current_streak=p.current_streak,
|
||||||
|
is_current_user=(p.id == current_participant.id),
|
||||||
|
))
|
||||||
|
|
||||||
|
return WidgetLeaderboardResponse(
|
||||||
|
entries=entries,
|
||||||
|
current_user_rank=current_user_rank,
|
||||||
|
total_participants=total_participants,
|
||||||
|
marathon_title=widget_token.marathon.title,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/data/current", response_model=WidgetCurrentResponse)
|
||||||
|
async def widget_current_assignment(
|
||||||
|
marathon: int = Query(..., description="Marathon ID"),
|
||||||
|
token: str = Query(..., description="Widget token"),
|
||||||
|
db: DbSession = None,
|
||||||
|
):
|
||||||
|
"""Get current assignment data for widget"""
|
||||||
|
widget_token = await validate_widget_token(token, marathon, db)
|
||||||
|
participant = widget_token.participant
|
||||||
|
|
||||||
|
# Get active assignment
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.challenge),
|
||||||
|
selectinload(Assignment.game),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Assignment.participant_id == participant.id,
|
||||||
|
Assignment.status.in_([
|
||||||
|
AssignmentStatus.ACTIVE.value,
|
||||||
|
AssignmentStatus.RETURNED.value,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.order_by(Assignment.started_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
assignment = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not assignment:
|
||||||
|
return WidgetCurrentResponse(has_assignment=False)
|
||||||
|
|
||||||
|
# Determine assignment type and details
|
||||||
|
if assignment.is_playthrough:
|
||||||
|
game = assignment.game
|
||||||
|
assignment_type = "playthrough"
|
||||||
|
challenge_title = "Прохождение"
|
||||||
|
challenge_description = game.playthrough_description
|
||||||
|
points = game.playthrough_points
|
||||||
|
difficulty = None
|
||||||
|
|
||||||
|
# Count bonus challenges
|
||||||
|
bonus_result = await db.execute(
|
||||||
|
select(func.count()).select_from(BonusAssignment)
|
||||||
|
.where(BonusAssignment.main_assignment_id == assignment.id)
|
||||||
|
)
|
||||||
|
bonus_total = bonus_result.scalar() or 0
|
||||||
|
|
||||||
|
completed_result = await db.execute(
|
||||||
|
select(func.count()).select_from(BonusAssignment)
|
||||||
|
.where(
|
||||||
|
BonusAssignment.main_assignment_id == assignment.id,
|
||||||
|
BonusAssignment.status == BonusAssignmentStatus.COMPLETED.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bonus_completed = completed_result.scalar() or 0
|
||||||
|
|
||||||
|
game_title = game.title
|
||||||
|
game_cover_url = f"/api/v1/games/{game.id}/cover" if game.cover_path else None
|
||||||
|
else:
|
||||||
|
challenge = assignment.challenge
|
||||||
|
assignment_type = "challenge"
|
||||||
|
challenge_title = challenge.title
|
||||||
|
challenge_description = challenge.description
|
||||||
|
points = challenge.points
|
||||||
|
difficulty = challenge.difficulty
|
||||||
|
bonus_completed = None
|
||||||
|
bonus_total = None
|
||||||
|
|
||||||
|
game = challenge.game if hasattr(challenge, 'game') else None
|
||||||
|
if not game:
|
||||||
|
# Load game via challenge
|
||||||
|
from app.models import Game
|
||||||
|
game_result = await db.execute(
|
||||||
|
select(Game).where(Game.id == challenge.game_id)
|
||||||
|
)
|
||||||
|
game = game_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
game_title = game.title if game else None
|
||||||
|
game_cover_url = f"/api/v1/games/{game.id}/cover" if game and game.cover_path else None
|
||||||
|
|
||||||
|
return WidgetCurrentResponse(
|
||||||
|
has_assignment=True,
|
||||||
|
game_title=game_title,
|
||||||
|
game_cover_url=game_cover_url,
|
||||||
|
assignment_type=assignment_type,
|
||||||
|
challenge_title=challenge_title,
|
||||||
|
challenge_description=challenge_description,
|
||||||
|
points=points,
|
||||||
|
difficulty=difficulty,
|
||||||
|
bonus_completed=bonus_completed,
|
||||||
|
bonus_total=bonus_total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/data/progress", response_model=WidgetProgressResponse)
|
||||||
|
async def widget_progress(
|
||||||
|
marathon: int = Query(..., description="Marathon ID"),
|
||||||
|
token: str = Query(..., description="Widget token"),
|
||||||
|
db: DbSession = None,
|
||||||
|
):
|
||||||
|
"""Get participant progress data for widget"""
|
||||||
|
widget_token = await validate_widget_token(token, marathon, db)
|
||||||
|
participant = widget_token.participant
|
||||||
|
user = participant.user
|
||||||
|
|
||||||
|
# Calculate rank
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Participant)
|
||||||
|
.where(
|
||||||
|
Participant.marathon_id == marathon,
|
||||||
|
Participant.total_points > participant.total_points,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
higher_count = result.scalar() or 0
|
||||||
|
rank = higher_count + 1
|
||||||
|
|
||||||
|
# Count completed and dropped assignments
|
||||||
|
completed_result = await db.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Assignment)
|
||||||
|
.where(
|
||||||
|
Assignment.participant_id == participant.id,
|
||||||
|
Assignment.status == AssignmentStatus.COMPLETED.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
completed_count = completed_result.scalar() or 0
|
||||||
|
|
||||||
|
dropped_result = await db.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Assignment)
|
||||||
|
.where(
|
||||||
|
Assignment.participant_id == participant.id,
|
||||||
|
Assignment.status == AssignmentStatus.DROPPED.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
dropped_count = dropped_result.scalar() or 0
|
||||||
|
|
||||||
|
return WidgetProgressResponse(
|
||||||
|
nickname=user.nickname,
|
||||||
|
avatar_url=get_avatar_url(user),
|
||||||
|
rank=rank,
|
||||||
|
total_points=participant.total_points,
|
||||||
|
current_streak=participant.current_streak,
|
||||||
|
completed_count=completed_count,
|
||||||
|
dropped_count=dropped_count,
|
||||||
|
marathon_title=widget_token.marathon.title,
|
||||||
|
)
|
||||||
@@ -60,7 +60,12 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|||||||
# CORS
|
# CORS
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
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_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ from app.models.shop import ShopItem, ShopItemType, ItemRarity, ConsumableType
|
|||||||
from app.models.inventory import UserInventory
|
from app.models.inventory import UserInventory
|
||||||
from app.models.coin_transaction import CoinTransaction, CoinTransactionType
|
from app.models.coin_transaction import CoinTransaction, CoinTransactionType
|
||||||
from app.models.consumable_usage import ConsumableUsage
|
from app.models.consumable_usage import ConsumableUsage
|
||||||
|
from app.models.promo_code import PromoCode, PromoCodeRedemption
|
||||||
|
from app.models.widget_token import WidgetToken
|
||||||
|
from app.models.exiled_game import ExiledGame
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -62,4 +65,8 @@ __all__ = [
|
|||||||
"CoinTransaction",
|
"CoinTransaction",
|
||||||
"CoinTransactionType",
|
"CoinTransactionType",
|
||||||
"ConsumableUsage",
|
"ConsumableUsage",
|
||||||
|
"PromoCode",
|
||||||
|
"PromoCodeRedemption",
|
||||||
|
"WidgetToken",
|
||||||
|
"ExiledGame",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class ActivityType(str, Enum):
|
|||||||
EVENT_END = "event_end"
|
EVENT_END = "event_end"
|
||||||
SWAP = "swap"
|
SWAP = "swap"
|
||||||
GAME_CHOICE = "game_choice"
|
GAME_CHOICE = "game_choice"
|
||||||
|
MODERATION = "moderation"
|
||||||
|
|
||||||
|
|
||||||
class Activity(Base):
|
class Activity(Base):
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class Assignment(Base):
|
|||||||
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
points_earned: Mapped[int] = mapped_column(Integer, default=0)
|
points_earned: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
streak_at_completion: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
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)
|
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class CoinTransactionType(str, Enum):
|
|||||||
REFUND = "refund"
|
REFUND = "refund"
|
||||||
ADMIN_GRANT = "admin_grant"
|
ADMIN_GRANT = "admin_grant"
|
||||||
ADMIN_DEDUCT = "admin_deduct"
|
ADMIN_DEDUCT = "admin_deduct"
|
||||||
|
PROMO_CODE = "promo_code"
|
||||||
|
|
||||||
|
|
||||||
class CoinTransaction(Base):
|
class CoinTransaction(Base):
|
||||||
|
|||||||
37
backend/app/models/exiled_game.py
Normal file
37
backend/app/models/exiled_game.py
Normal 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")
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -32,7 +32,15 @@ class Participant(Base):
|
|||||||
# Shop: consumables state
|
# Shop: consumables state
|
||||||
skips_used: Mapped[int] = mapped_column(Integer, default=0)
|
skips_used: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
has_active_boost: Mapped[bool] = mapped_column(Boolean, default=False)
|
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
|
# Relationships
|
||||||
user: Mapped["User"] = relationship("User", back_populates="participations")
|
user: Mapped["User"] = relationship("User", back_populates="participations")
|
||||||
|
|||||||
67
backend/app/models/promo_code.py
Normal file
67
backend/app/models/promo_code.py
Normal 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")
|
||||||
@@ -28,9 +28,12 @@ class ItemRarity(str, Enum):
|
|||||||
|
|
||||||
class ConsumableType(str, Enum):
|
class ConsumableType(str, Enum):
|
||||||
SKIP = "skip"
|
SKIP = "skip"
|
||||||
SHIELD = "shield"
|
SKIP_EXILE = "skip_exile" # Скип с изгнанием игры из пула
|
||||||
BOOST = "boost"
|
BOOST = "boost"
|
||||||
REROLL = "reroll"
|
WILD_CARD = "wild_card"
|
||||||
|
LUCKY_DICE = "lucky_dice"
|
||||||
|
COPYCAT = "copycat"
|
||||||
|
UNDO = "undo"
|
||||||
|
|
||||||
|
|
||||||
class ShopItem(Base):
|
class ShopItem(Base):
|
||||||
|
|||||||
22
backend/app/models/widget_token.py
Normal file
22
backend/app/models/widget_token.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, Boolean
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetToken(Base):
|
||||||
|
"""Токен для авторизации OBS виджетов"""
|
||||||
|
__tablename__ = "widget_tokens"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"))
|
||||||
|
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
participant: Mapped["Participant"] = relationship("Participant")
|
||||||
|
marathon: Mapped["Marathon"] = relationship("Marathon")
|
||||||
@@ -23,6 +23,8 @@ from app.schemas.marathon import (
|
|||||||
JoinMarathon,
|
JoinMarathon,
|
||||||
LeaderboardEntry,
|
LeaderboardEntry,
|
||||||
SetParticipantRole,
|
SetParticipantRole,
|
||||||
|
OrganizerSkipRequest,
|
||||||
|
ExiledGameResponse,
|
||||||
)
|
)
|
||||||
from app.schemas.game import (
|
from app.schemas.game import (
|
||||||
GameCreate,
|
GameCreate,
|
||||||
@@ -52,6 +54,7 @@ from app.schemas.assignment import (
|
|||||||
CompleteBonusAssignment,
|
CompleteBonusAssignment,
|
||||||
BonusCompleteResult,
|
BonusCompleteResult,
|
||||||
AvailableGamesCount,
|
AvailableGamesCount,
|
||||||
|
TrackTimeRequest,
|
||||||
)
|
)
|
||||||
from app.schemas.activity import (
|
from app.schemas.activity import (
|
||||||
ActivityResponse,
|
ActivityResponse,
|
||||||
@@ -123,8 +126,27 @@ from app.schemas.shop import (
|
|||||||
CertificationReviewRequest,
|
CertificationReviewRequest,
|
||||||
CertificationStatusResponse,
|
CertificationStatusResponse,
|
||||||
ConsumablesStatusResponse,
|
ConsumablesStatusResponse,
|
||||||
|
AdminGrantItemRequest,
|
||||||
|
)
|
||||||
|
from app.schemas.promo_code import (
|
||||||
|
PromoCodeCreate,
|
||||||
|
PromoCodeUpdate,
|
||||||
|
PromoCodeResponse,
|
||||||
|
PromoCodeRedeemRequest,
|
||||||
|
PromoCodeRedeemResponse,
|
||||||
|
PromoCodeRedemptionResponse,
|
||||||
|
PromoCodeRedemptionUser,
|
||||||
)
|
)
|
||||||
from app.schemas.user import ShopItemPublic
|
from app.schemas.user import ShopItemPublic
|
||||||
|
from app.schemas.widget import (
|
||||||
|
WidgetTokenCreate,
|
||||||
|
WidgetTokenResponse,
|
||||||
|
WidgetTokenListItem,
|
||||||
|
WidgetLeaderboardEntry,
|
||||||
|
WidgetLeaderboardResponse,
|
||||||
|
WidgetCurrentResponse,
|
||||||
|
WidgetProgressResponse,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# User
|
# User
|
||||||
@@ -151,6 +173,8 @@ __all__ = [
|
|||||||
"JoinMarathon",
|
"JoinMarathon",
|
||||||
"LeaderboardEntry",
|
"LeaderboardEntry",
|
||||||
"SetParticipantRole",
|
"SetParticipantRole",
|
||||||
|
"OrganizerSkipRequest",
|
||||||
|
"ExiledGameResponse",
|
||||||
# Game
|
# Game
|
||||||
"GameCreate",
|
"GameCreate",
|
||||||
"GameUpdate",
|
"GameUpdate",
|
||||||
@@ -243,4 +267,21 @@ __all__ = [
|
|||||||
"CertificationReviewRequest",
|
"CertificationReviewRequest",
|
||||||
"CertificationStatusResponse",
|
"CertificationStatusResponse",
|
||||||
"ConsumablesStatusResponse",
|
"ConsumablesStatusResponse",
|
||||||
|
"AdminGrantItemRequest",
|
||||||
|
# Promo
|
||||||
|
"PromoCodeCreate",
|
||||||
|
"PromoCodeUpdate",
|
||||||
|
"PromoCodeResponse",
|
||||||
|
"PromoCodeRedeemRequest",
|
||||||
|
"PromoCodeRedeemResponse",
|
||||||
|
"PromoCodeRedemptionResponse",
|
||||||
|
"PromoCodeRedemptionUser",
|
||||||
|
# Widget
|
||||||
|
"WidgetTokenCreate",
|
||||||
|
"WidgetTokenResponse",
|
||||||
|
"WidgetTokenListItem",
|
||||||
|
"WidgetLeaderboardEntry",
|
||||||
|
"WidgetLeaderboardResponse",
|
||||||
|
"WidgetCurrentResponse",
|
||||||
|
"WidgetProgressResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class AssignmentResponse(BaseModel):
|
|||||||
proof_comment: str | None = None
|
proof_comment: str | None = None
|
||||||
points_earned: int
|
points_earned: int
|
||||||
streak_at_completion: int | None = None
|
streak_at_completion: int | None = None
|
||||||
|
tracked_time_minutes: int = 0 # Time tracked by desktop app
|
||||||
started_at: datetime
|
started_at: datetime
|
||||||
completed_at: datetime | None = None
|
completed_at: datetime | None = None
|
||||||
drop_penalty: int = 0 # Calculated penalty if dropped
|
drop_penalty: int = 0 # Calculated penalty if dropped
|
||||||
@@ -62,6 +63,11 @@ class AssignmentResponse(BaseModel):
|
|||||||
from_attributes = True
|
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):
|
class SpinResult(BaseModel):
|
||||||
assignment_id: int
|
assignment_id: int
|
||||||
game: GameResponse
|
game: GameResponse
|
||||||
@@ -86,7 +92,6 @@ class DropResult(BaseModel):
|
|||||||
penalty: int
|
penalty: int
|
||||||
total_points: int
|
total_points: int
|
||||||
new_drop_count: int
|
new_drop_count: int
|
||||||
shield_used: bool = False # Whether shield consumable was used to prevent penalty
|
|
||||||
|
|
||||||
|
|
||||||
class EventAssignmentResponse(BaseModel):
|
class EventAssignmentResponse(BaseModel):
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class ChallengeBase(BaseModel):
|
|||||||
description: str = Field(..., min_length=1)
|
description: str = Field(..., min_length=1)
|
||||||
type: ChallengeType
|
type: ChallengeType
|
||||||
difficulty: Difficulty
|
difficulty: Difficulty
|
||||||
points: int = Field(..., ge=1, le=1000)
|
points: int = Field(..., ge=1)
|
||||||
estimated_time: int | None = Field(None, ge=1) # minutes
|
estimated_time: int | None = Field(None, ge=1) # minutes
|
||||||
proof_type: ProofType
|
proof_type: ProofType
|
||||||
proof_hint: str | None = None
|
proof_hint: str | None = None
|
||||||
@@ -34,7 +34,7 @@ class ChallengeUpdate(BaseModel):
|
|||||||
description: str | None = None
|
description: str | None = None
|
||||||
type: ChallengeType | None = None
|
type: ChallengeType | None = None
|
||||||
difficulty: Difficulty | 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
|
estimated_time: int | None = None
|
||||||
proof_type: ProofType | None = None
|
proof_type: ProofType | None = None
|
||||||
proof_hint: str | None = None
|
proof_hint: str | None = None
|
||||||
|
|||||||
@@ -128,10 +128,16 @@ class SwapCandidate(BaseModel):
|
|||||||
"""Participant available for assignment swap"""
|
"""Participant available for assignment swap"""
|
||||||
participant_id: int
|
participant_id: int
|
||||||
user: UserPublic
|
user: UserPublic
|
||||||
challenge_title: str
|
is_playthrough: bool = False
|
||||||
challenge_description: str
|
# Challenge fields (used when is_playthrough=False)
|
||||||
challenge_points: int
|
challenge_title: str | None = None
|
||||||
challenge_difficulty: str
|
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
|
game_title: str
|
||||||
|
|
||||||
|
|
||||||
@@ -145,11 +151,17 @@ class SwapRequestCreate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SwapRequestChallengeInfo(BaseModel):
|
class SwapRequestChallengeInfo(BaseModel):
|
||||||
"""Challenge info for swap request display"""
|
"""Challenge or playthrough info for swap request display"""
|
||||||
title: str
|
is_playthrough: bool = False
|
||||||
description: str
|
# Challenge fields (used when is_playthrough=False)
|
||||||
points: int
|
title: str | None = None
|
||||||
difficulty: str
|
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
|
game_title: str
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class GameCreate(GameBase):
|
|||||||
game_type: GameType = GameType.CHALLENGES
|
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_description: str | None = None
|
||||||
playthrough_proof_type: ProofType | None = None
|
playthrough_proof_type: ProofType | None = None
|
||||||
playthrough_proof_hint: str | None = None
|
playthrough_proof_hint: str | None = None
|
||||||
@@ -46,7 +46,7 @@ class GameUpdate(BaseModel):
|
|||||||
game_type: GameType | None = None
|
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_description: str | None = None
|
||||||
playthrough_proof_type: ProofType | None = None
|
playthrough_proof_type: ProofType | None = None
|
||||||
playthrough_proof_hint: str | None = None
|
playthrough_proof_hint: str | None = None
|
||||||
@@ -87,7 +87,7 @@ class GameResponse(GameBase):
|
|||||||
|
|
||||||
class PlaythroughInfo(BaseModel):
|
class PlaythroughInfo(BaseModel):
|
||||||
"""Информация о прохождении для игр типа playthrough"""
|
"""Информация о прохождении для игр типа playthrough"""
|
||||||
description: str
|
description: str | None = None
|
||||||
points: int
|
points: int | None = None
|
||||||
proof_type: str
|
proof_type: str | None = None
|
||||||
proof_hint: str | None = None
|
proof_hint: str | None = None
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ class ParticipantInfo(BaseModel):
|
|||||||
# Shop: coins and consumables status
|
# Shop: coins and consumables status
|
||||||
coins_earned: int = 0
|
coins_earned: int = 0
|
||||||
skips_used: int = 0
|
skips_used: int = 0
|
||||||
has_shield: bool = False
|
|
||||||
has_active_boost: bool = False
|
has_active_boost: bool = False
|
||||||
boost_multiplier: float | None = None
|
has_lucky_dice: bool = False
|
||||||
boost_expires_at: datetime | None = None
|
lucky_dice_multiplier: float | None = None
|
||||||
|
can_undo: bool = False
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -128,3 +128,23 @@ class LeaderboardEntry(BaseModel):
|
|||||||
current_streak: int
|
current_streak: int
|
||||||
completed_count: int
|
completed_count: int
|
||||||
dropped_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
|
||||||
|
|||||||
74
backend/app/schemas/promo_code.py
Normal file
74
backend/app/schemas/promo_code.py
Normal 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
|
||||||
@@ -94,9 +94,11 @@ class PurchaseResponse(BaseModel):
|
|||||||
|
|
||||||
class UseConsumableRequest(BaseModel):
|
class UseConsumableRequest(BaseModel):
|
||||||
"""Schema for using a consumable"""
|
"""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
|
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):
|
class UseConsumableResponse(BaseModel):
|
||||||
@@ -190,11 +192,25 @@ class CertificationStatusResponse(BaseModel):
|
|||||||
class ConsumablesStatusResponse(BaseModel):
|
class ConsumablesStatusResponse(BaseModel):
|
||||||
"""Schema for participant's consumables status in a marathon"""
|
"""Schema for participant's consumables status in a marathon"""
|
||||||
skips_available: int # From inventory
|
skips_available: int # From inventory
|
||||||
|
skip_exiles_available: int = 0 # From inventory (skip with exile)
|
||||||
skips_used: int # In this marathon
|
skips_used: int # In this marathon
|
||||||
skips_remaining: int | None # Based on marathon limit
|
skips_remaining: int | None # Based on marathon limit
|
||||||
shields_available: int # From inventory
|
|
||||||
has_shield: bool # Currently activated
|
|
||||||
boosts_available: int # From inventory
|
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
|
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)
|
||||||
|
|||||||
79
backend/app/schemas/widget.py
Normal file
79
backend/app/schemas/widget.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
# === Token schemas ===
|
||||||
|
|
||||||
|
class WidgetTokenCreate(BaseModel):
|
||||||
|
"""Создание токена виджета"""
|
||||||
|
pass # Не требует параметров
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetTokenResponse(BaseModel):
|
||||||
|
"""Ответ с токеном виджета"""
|
||||||
|
id: int
|
||||||
|
token: str
|
||||||
|
created_at: datetime
|
||||||
|
expires_at: datetime | None
|
||||||
|
is_active: bool
|
||||||
|
urls: dict[str, str] # Готовые URL для виджетов
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetTokenListItem(BaseModel):
|
||||||
|
"""Элемент списка токенов"""
|
||||||
|
id: int
|
||||||
|
token: str
|
||||||
|
created_at: datetime
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# === Widget data schemas ===
|
||||||
|
|
||||||
|
class WidgetLeaderboardEntry(BaseModel):
|
||||||
|
"""Запись в лидерборде виджета"""
|
||||||
|
rank: int
|
||||||
|
nickname: str
|
||||||
|
avatar_url: str | None
|
||||||
|
total_points: int
|
||||||
|
current_streak: int
|
||||||
|
is_current_user: bool # Для подсветки
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetLeaderboardResponse(BaseModel):
|
||||||
|
"""Ответ лидерборда для виджета"""
|
||||||
|
entries: list[WidgetLeaderboardEntry]
|
||||||
|
current_user_rank: int | None
|
||||||
|
total_participants: int
|
||||||
|
marathon_title: str
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetCurrentResponse(BaseModel):
|
||||||
|
"""Текущее задание для виджета"""
|
||||||
|
has_assignment: bool
|
||||||
|
game_title: str | None = None
|
||||||
|
game_cover_url: str | None = None
|
||||||
|
assignment_type: str | None = None # "challenge" | "playthrough"
|
||||||
|
challenge_title: str | None = None
|
||||||
|
challenge_description: str | None = None
|
||||||
|
points: int | None = None
|
||||||
|
difficulty: str | None = None # easy, medium, hard
|
||||||
|
bonus_completed: int | None = None # Для прохождений
|
||||||
|
bonus_total: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetProgressResponse(BaseModel):
|
||||||
|
"""Прогресс участника для виджета"""
|
||||||
|
nickname: str
|
||||||
|
avatar_url: str | None
|
||||||
|
rank: int
|
||||||
|
total_points: int
|
||||||
|
current_streak: int
|
||||||
|
completed_count: int
|
||||||
|
dropped_count: int
|
||||||
|
marathon_title: str
|
||||||
@@ -14,19 +14,19 @@ class CoinsService:
|
|||||||
|
|
||||||
# Coins awarded per challenge difficulty (only in certified marathons)
|
# Coins awarded per challenge difficulty (only in certified marathons)
|
||||||
CHALLENGE_COINS = {
|
CHALLENGE_COINS = {
|
||||||
Difficulty.EASY.value: 5,
|
Difficulty.EASY.value: 10,
|
||||||
Difficulty.MEDIUM.value: 12,
|
Difficulty.MEDIUM.value: 20,
|
||||||
Difficulty.HARD.value: 25,
|
Difficulty.HARD.value: 35,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Coins for playthrough = points * this ratio
|
# 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
|
# Coins awarded for marathon placements
|
||||||
MARATHON_PLACE_COINS = {
|
MARATHON_PLACE_COINS = {
|
||||||
1: 100, # 1st place
|
1: 500, # 1st place
|
||||||
2: 50, # 2nd place
|
2: 250, # 2nd place
|
||||||
3: 30, # 3rd place
|
3: 150, # 3rd place
|
||||||
}
|
}
|
||||||
|
|
||||||
# Bonus coins for Common Enemy event winners
|
# Bonus coins for Common Enemy event winners
|
||||||
|
|||||||
@@ -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 datetime import datetime
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
User, Participant, Marathon, Assignment, AssignmentStatus,
|
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 settings
|
||||||
BOOST_MULTIPLIER = 1.5
|
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(
|
async def use_skip(
|
||||||
self,
|
self,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
@@ -85,51 +99,104 @@ class ConsumablesService:
|
|||||||
"streak_preserved": True,
|
"streak_preserved": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def use_shield(
|
async def use_skip_exile(
|
||||||
self,
|
self,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user: User,
|
user: User,
|
||||||
participant: Participant,
|
participant: Participant,
|
||||||
marathon: Marathon,
|
marathon: Marathon,
|
||||||
|
assignment: Assignment,
|
||||||
) -> dict:
|
) -> 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
|
- No streak loss
|
||||||
- Streak is preserved on next drop
|
- No drop penalty
|
||||||
|
- Game is permanently excluded from participant's pool
|
||||||
|
|
||||||
Returns: dict with result info
|
Returns: dict with result info
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: If consumables not allowed or shield already active
|
HTTPException: If skips not allowed or limit reached
|
||||||
"""
|
"""
|
||||||
if not marathon.allow_consumables:
|
# Check marathon settings (same as regular skip)
|
||||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
if not marathon.allow_skips:
|
||||||
|
raise HTTPException(status_code=400, detail="Skips are not allowed in this marathon")
|
||||||
|
|
||||||
if participant.has_shield:
|
if marathon.max_skips_per_participant is not None:
|
||||||
raise HTTPException(status_code=400, detail="Shield is already active")
|
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
|
# Check assignment is active
|
||||||
item = await self._consume_item(db, user, ConsumableType.SHIELD.value)
|
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Can only skip active assignments")
|
||||||
|
|
||||||
# Activate shield
|
# Get game_id (different for playthrough vs challenges)
|
||||||
participant.has_shield = True
|
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
|
# Log usage
|
||||||
usage = ConsumableUsage(
|
usage = ConsumableUsage(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
item_id=item.id,
|
item_id=item.id,
|
||||||
marathon_id=marathon.id,
|
marathon_id=marathon.id,
|
||||||
|
assignment_id=assignment.id,
|
||||||
effect_data={
|
effect_data={
|
||||||
"type": "shield",
|
"type": "skip_exile",
|
||||||
"activated": True,
|
"skipped_without_penalty": True,
|
||||||
|
"game_exiled": True,
|
||||||
|
"game_id": game_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
db.add(usage)
|
db.add(usage)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"shield_activated": True,
|
"skipped": True,
|
||||||
|
"exiled": True,
|
||||||
|
"game_id": game_id,
|
||||||
|
"penalty": 0,
|
||||||
|
"streak_preserved": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def use_boost(
|
async def use_boost(
|
||||||
@@ -140,10 +207,10 @@ class ConsumablesService:
|
|||||||
marathon: Marathon,
|
marathon: Marathon,
|
||||||
) -> dict:
|
) -> 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
|
- Points for completed challenge are multiplied by BOOST_MULTIPLIER
|
||||||
- One-time use (consumed on next complete)
|
- One-time use (consumed on complete)
|
||||||
|
|
||||||
Returns: dict with result info
|
Returns: dict with result info
|
||||||
|
|
||||||
@@ -181,41 +248,97 @@ class ConsumablesService:
|
|||||||
"multiplier": self.BOOST_MULTIPLIER,
|
"multiplier": self.BOOST_MULTIPLIER,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def use_reroll(
|
async def use_wild_card(
|
||||||
self,
|
self,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user: User,
|
user: User,
|
||||||
participant: Participant,
|
participant: Participant,
|
||||||
marathon: Marathon,
|
marathon: Marathon,
|
||||||
assignment: Assignment,
|
assignment: Assignment,
|
||||||
|
game_id: int,
|
||||||
) -> dict:
|
) -> 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)
|
For challenges game type:
|
||||||
- User can spin the wheel again
|
- New challenge is randomly selected from the chosen game
|
||||||
- No penalty
|
- 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:
|
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:
|
if not marathon.allow_consumables:
|
||||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||||
|
|
||||||
if assignment.status != AssignmentStatus.ACTIVE.value:
|
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
|
# Verify game is in this marathon and load challenges
|
||||||
item = await self._consume_item(db, user, ConsumableType.REROLL.value)
|
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
|
if not game:
|
||||||
old_challenge_id = assignment.challenge_id
|
raise HTTPException(status_code=400, detail="Game not found in this marathon")
|
||||||
|
|
||||||
|
# Store old assignment info for logging
|
||||||
old_game_id = assignment.game_id
|
old_game_id = assignment.game_id
|
||||||
assignment.status = AssignmentStatus.DROPPED.value
|
old_challenge_id = assignment.challenge_id
|
||||||
assignment.completed_at = datetime.utcnow()
|
old_is_playthrough = assignment.is_playthrough
|
||||||
# Note: We do NOT increase drop_count (this is a reroll, not a real drop)
|
|
||||||
|
# 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
|
# Log usage
|
||||||
usage = ConsumableUsage(
|
usage = ConsumableUsage(
|
||||||
@@ -224,17 +347,279 @@ class ConsumablesService:
|
|||||||
marathon_id=marathon.id,
|
marathon_id=marathon.id,
|
||||||
assignment_id=assignment.id,
|
assignment_id=assignment.id,
|
||||||
effect_data={
|
effect_data={
|
||||||
"type": "reroll",
|
"type": "wild_card",
|
||||||
"rerolled_from_challenge_id": old_challenge_id,
|
"old_game_id": old_game_id,
|
||||||
"rerolled_from_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)
|
db.add(usage)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"rerolled": True,
|
"game_id": game_id,
|
||||||
"can_spin_again": True,
|
"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(
|
async def _consume_item(
|
||||||
@@ -292,17 +677,6 @@ class ConsumablesService:
|
|||||||
quantity = result.scalar_one_or_none()
|
quantity = result.scalar_one_or_none()
|
||||||
return quantity or 0
|
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:
|
def consume_boost_on_complete(self, participant: Participant) -> float:
|
||||||
"""
|
"""
|
||||||
Consume boost when completing assignment (called from wheel.py).
|
Consume boost when completing assignment (called from wheel.py).
|
||||||
@@ -315,6 +689,33 @@ class ConsumablesService:
|
|||||||
return self.BOOST_MULTIPLIER
|
return self.BOOST_MULTIPLIER
|
||||||
return 1.0
|
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
|
# Singleton instance
|
||||||
consumables_service = ConsumablesService()
|
consumables_service = ConsumablesService()
|
||||||
|
|||||||
@@ -124,12 +124,6 @@ points: easy=20-40, medium=45-75, hard=90-150
|
|||||||
points = ch.get("points", 30)
|
points = ch.get("points", 30)
|
||||||
if not isinstance(points, int) or points < 1:
|
if not isinstance(points, int) or points < 1:
|
||||||
points = 30
|
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(
|
return ChallengeGenerated(
|
||||||
title=ch.get("title", "Unnamed Challenge")[:100],
|
title=ch.get("title", "Unnamed Challenge")[:100],
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class PointsService:
|
|||||||
def calculate_drop_penalty(
|
def calculate_drop_penalty(
|
||||||
self,
|
self,
|
||||||
consecutive_drops: int,
|
consecutive_drops: int,
|
||||||
challenge_points: int,
|
challenge_points: int | None,
|
||||||
event: Event | None = None
|
event: Event | None = None
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -80,6 +80,10 @@ class PointsService:
|
|||||||
Returns:
|
Returns:
|
||||||
Penalty points to subtract
|
Penalty points to subtract
|
||||||
"""
|
"""
|
||||||
|
# No penalty if no points defined
|
||||||
|
if challenge_points is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
# Double risk event = free drops
|
# Double risk event = free drops
|
||||||
if event and event.type == EventType.DOUBLE_RISK.value:
|
if event and event.type == EventType.DOUBLE_RISK.value:
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -608,6 +608,57 @@ class TelegramNotifier:
|
|||||||
reply_markup=reply_markup
|
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
|
# Global instance
|
||||||
telegram_notifier = TelegramNotifier()
|
telegram_notifier = TelegramNotifier()
|
||||||
|
|||||||
32
desktop/.gitignore
vendored
Normal file
32
desktop/.gitignore
vendored
Normal 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
6893
desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
89
desktop/package.json
Normal file
89
desktop/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
desktop/postcss.config.js
Normal file
6
desktop/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
2
desktop/resources/.gitkeep
Normal file
2
desktop/resources/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Resources placeholder
|
||||||
|
# Add icon.ico and tray-icon.png here
|
||||||
BIN
desktop/resources/icon.ico
Normal file
BIN
desktop/resources/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
BIN
desktop/resources/logo.jpg
Normal file
BIN
desktop/resources/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
145
desktop/src/main/apiClient.ts
Normal file
145
desktop/src/main/apiClient.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
desktop/src/main/autolaunch.ts
Normal file
42
desktop/src/main/autolaunch.ts
Normal 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
197
desktop/src/main/index.ts
Normal 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
174
desktop/src/main/ipc.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
28
desktop/src/main/storeTypes.ts
Normal file
28
desktop/src/main/storeTypes.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
284
desktop/src/main/tracking/processTracker.ts
Normal file
284
desktop/src/main/tracking/processTracker.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
215
desktop/src/main/tracking/steamIntegration.ts
Normal file
215
desktop/src/main/tracking/steamIntegration.ts
Normal 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()
|
||||||
155
desktop/src/main/tracking/timeStorage.ts
Normal file
155
desktop/src/main/tracking/timeStorage.ts
Normal 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
115
desktop/src/main/tray.ts
Normal 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
184
desktop/src/main/updater.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
97
desktop/src/preload/index.ts
Normal file
97
desktop/src/preload/index.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
65
desktop/src/renderer/App.tsx
Normal file
65
desktop/src/renderer/App.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
70
desktop/src/renderer/components/Layout.tsx
Normal file
70
desktop/src/renderer/components/Layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
desktop/src/renderer/components/ui/GlassCard.tsx
Normal file
40
desktop/src/renderer/components/ui/GlassCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
desktop/src/renderer/components/ui/Input.tsx
Normal file
48
desktop/src/renderer/components/ui/Input.tsx
Normal 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'
|
||||||
118
desktop/src/renderer/components/ui/NeonButton.tsx
Normal file
118
desktop/src/renderer/components/ui/NeonButton.tsx
Normal 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'
|
||||||
134
desktop/src/renderer/index.css
Normal file
134
desktop/src/renderer/index.css
Normal 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;
|
||||||
|
}
|
||||||
16
desktop/src/renderer/index.html
Normal file
16
desktop/src/renderer/index.html
Normal 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>
|
||||||
BIN
desktop/src/renderer/logo.jpg
Normal file
BIN
desktop/src/renderer/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
13
desktop/src/renderer/main.tsx
Normal file
13
desktop/src/renderer/main.tsx
Normal 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>
|
||||||
|
)
|
||||||
491
desktop/src/renderer/pages/DashboardPage.tsx
Normal file
491
desktop/src/renderer/pages/DashboardPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
298
desktop/src/renderer/pages/GamesPage.tsx
Normal file
298
desktop/src/renderer/pages/GamesPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
183
desktop/src/renderer/pages/LoginPage.tsx
Normal file
183
desktop/src/renderer/pages/LoginPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
267
desktop/src/renderer/pages/SettingsPage.tsx
Normal file
267
desktop/src/renderer/pages/SettingsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
104
desktop/src/renderer/splash.html
Normal file
104
desktop/src/renderer/splash.html
Normal 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>
|
||||||
149
desktop/src/renderer/store/auth.ts
Normal file
149
desktop/src/renderer/store/auth.ts
Normal 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 }),
|
||||||
|
}))
|
||||||
123
desktop/src/renderer/store/marathon.ts
Normal file
123
desktop/src/renderer/store/marathon.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
81
desktop/src/renderer/store/tracking.ts
Normal file
81
desktop/src/renderer/store/tracking.ts
Normal 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
54
desktop/src/renderer/vite-env.d.ts
vendored
Normal 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
226
desktop/src/shared/types.ts
Normal 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
147
desktop/tailwind.config.js
Normal 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
26
desktop/tsconfig.json
Normal 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" }]
|
||||||
|
}
|
||||||
18
desktop/tsconfig.main.json
Normal file
18
desktop/tsconfig.main.json
Normal 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
22
desktop/vite.config.ts
Normal 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
669
docs/tz-obs-widget.md
Normal 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
|
||||||
|
- **Виджет событий** — показ активных событий марафона
|
||||||
|
- **Виджет колеса** — мини-версия колеса фортуны
|
||||||
789
docs/tz-skip-exile-moderation.md
Normal file
789
docs/tz-skip-exile-moderation.md
Normal 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. **Телеграм уведомления**: Нужны ли уведомления участнику при модераторском скипе?
|
||||||
@@ -28,6 +28,12 @@ import { ServerErrorPage } from '@/pages/ServerErrorPage'
|
|||||||
import { ShopPage } from '@/pages/ShopPage'
|
import { ShopPage } from '@/pages/ShopPage'
|
||||||
import { InventoryPage } from '@/pages/InventoryPage'
|
import { InventoryPage } from '@/pages/InventoryPage'
|
||||||
|
|
||||||
|
// Widget Pages (for OBS)
|
||||||
|
import LeaderboardWidget from '@/pages/widget/LeaderboardWidget'
|
||||||
|
import CurrentWidget from '@/pages/widget/CurrentWidget'
|
||||||
|
import ProgressWidget from '@/pages/widget/ProgressWidget'
|
||||||
|
import CombinedWidget from '@/pages/widget/CombinedWidget'
|
||||||
|
|
||||||
// Admin Pages
|
// Admin Pages
|
||||||
import {
|
import {
|
||||||
AdminLayout,
|
AdminLayout,
|
||||||
@@ -37,6 +43,8 @@ import {
|
|||||||
AdminLogsPage,
|
AdminLogsPage,
|
||||||
AdminBroadcastPage,
|
AdminBroadcastPage,
|
||||||
AdminContentPage,
|
AdminContentPage,
|
||||||
|
AdminPromoCodesPage,
|
||||||
|
AdminGrantItemPage,
|
||||||
} from '@/pages/admin'
|
} from '@/pages/admin'
|
||||||
|
|
||||||
// Protected route wrapper
|
// Protected route wrapper
|
||||||
@@ -85,6 +93,12 @@ function App() {
|
|||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<ConfirmModal />
|
<ConfirmModal />
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* Widget routes (no layout, for OBS browser source) */}
|
||||||
|
<Route path="/widget/leaderboard" element={<LeaderboardWidget />} />
|
||||||
|
<Route path="/widget/current" element={<CurrentWidget />} />
|
||||||
|
<Route path="/widget/progress" element={<ProgressWidget />} />
|
||||||
|
<Route path="/widget/combined" element={<CombinedWidget />} />
|
||||||
|
|
||||||
<Route path="/" element={<Layout />}>
|
<Route path="/" element={<Layout />}>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
|
|
||||||
@@ -228,7 +242,9 @@ function App() {
|
|||||||
>
|
>
|
||||||
<Route index element={<AdminDashboardPage />} />
|
<Route index element={<AdminDashboardPage />} />
|
||||||
<Route path="users" element={<AdminUsersPage />} />
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
|
<Route path="users/:userId/grant-item" element={<AdminGrantItemPage />} />
|
||||||
<Route path="marathons" element={<AdminMarathonsPage />} />
|
<Route path="marathons" element={<AdminMarathonsPage />} />
|
||||||
|
<Route path="promo" element={<AdminPromoCodesPage />} />
|
||||||
<Route path="logs" element={<AdminLogsPage />} />
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
<Route path="broadcast" element={<AdminBroadcastPage />} />
|
<Route path="broadcast" element={<AdminBroadcastPage />} />
|
||||||
<Route path="content" element={<AdminContentPage />} />
|
<Route path="content" element={<AdminContentPage />} />
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ export { assignmentsApi } from './assignments'
|
|||||||
export { usersApi } from './users'
|
export { usersApi } from './users'
|
||||||
export { telegramApi } from './telegram'
|
export { telegramApi } from './telegram'
|
||||||
export { shopApi } from './shop'
|
export { shopApi } from './shop'
|
||||||
|
export { promoApi } from './promo'
|
||||||
|
export { widgetsApi } from './widgets'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from './client'
|
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 {
|
export interface CreateMarathonData {
|
||||||
title: string
|
title: string
|
||||||
@@ -112,4 +112,36 @@ export const marathonsApi = {
|
|||||||
)
|
)
|
||||||
return response.data
|
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
34
frontend/src/api/promo.ts
Normal 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`),
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
CoinTransaction,
|
CoinTransaction,
|
||||||
ConsumablesStatus,
|
ConsumablesStatus,
|
||||||
UserCosmetics,
|
UserCosmetics,
|
||||||
|
SwapCandidate,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
export const shopApi = {
|
export const shopApi = {
|
||||||
@@ -84,6 +85,12 @@ export const shopApi = {
|
|||||||
return response.data
|
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
|
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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
52
frontend/src/api/widgets.ts
Normal file
52
frontend/src/api/widgets.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import client from './client'
|
||||||
|
import type {
|
||||||
|
WidgetToken,
|
||||||
|
WidgetLeaderboardData,
|
||||||
|
WidgetCurrentData,
|
||||||
|
WidgetProgressData,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
export const widgetsApi = {
|
||||||
|
// Authenticated endpoints (for managing tokens)
|
||||||
|
createToken: async (marathonId: number): Promise<WidgetToken> => {
|
||||||
|
const response = await client.post<WidgetToken>(`/widgets/marathons/${marathonId}/token`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
listTokens: async (marathonId: number): Promise<WidgetToken[]> => {
|
||||||
|
const response = await client.get<WidgetToken[]>(`/widgets/marathons/${marathonId}/tokens`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
revokeToken: async (tokenId: number): Promise<{ message: string }> => {
|
||||||
|
const response = await client.delete<{ message: string }>(`/widgets/tokens/${tokenId}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
regenerateToken: async (tokenId: number): Promise<WidgetToken> => {
|
||||||
|
const response = await client.post<WidgetToken>(`/widgets/tokens/${tokenId}/regenerate`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Public widget data endpoints (authenticated via widget token)
|
||||||
|
getLeaderboard: async (marathonId: number, token: string, count: number = 5): Promise<WidgetLeaderboardData> => {
|
||||||
|
const response = await client.get<WidgetLeaderboardData>(
|
||||||
|
`/widgets/data/leaderboard?marathon=${marathonId}&token=${token}&count=${count}`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrent: async (marathonId: number, token: string): Promise<WidgetCurrentData> => {
|
||||||
|
const response = await client.get<WidgetCurrentData>(
|
||||||
|
`/widgets/data/current?marathon=${marathonId}&token=${token}`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getProgress: async (marathonId: number, token: string): Promise<WidgetProgressData> => {
|
||||||
|
const response = await client.get<WidgetProgressData>(
|
||||||
|
`/widgets/data/progress?marathon=${marathonId}&token=${token}`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
273
frontend/src/components/WidgetSettingsModal.tsx
Normal file
273
frontend/src/components/WidgetSettingsModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { useToast } from '@/store/toast'
|
|||||||
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
|
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
|
||||||
import {
|
import {
|
||||||
Loader2, Package, ShoppingBag, Coins, Check,
|
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'
|
} from 'lucide-react'
|
||||||
import type { InventoryItem, ShopItemType } from '@/types'
|
import type { InventoryItem, ShopItemType } from '@/types'
|
||||||
import { RARITY_COLORS, RARITY_NAMES, ITEM_TYPE_NAMES } 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> = {
|
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
|
||||||
skip: <SkipForward className="w-8 h-8" />,
|
skip: <SkipForward className="w-8 h-8" />,
|
||||||
shield: <Shield className="w-8 h-8" />,
|
|
||||||
boost: <Zap 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 {
|
interface InventoryItemCardProps {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { marathonsApi } from '@/api'
|
import { marathonsApi } from '@/api'
|
||||||
import type { LeaderboardEntry, ShopItemPublic, User } from '@/types'
|
import type { LeaderboardEntry, ShopItemPublic, User, Marathon } from '@/types'
|
||||||
import { GlassCard, UserAvatar } from '@/components/ui'
|
import { GlassCard, UserAvatar, NeonButton } from '@/components/ui'
|
||||||
import { useAuthStore } from '@/store/auth'
|
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
|
// Helper to get name color styles and animation class
|
||||||
function getNameColorData(nameColor: ShopItemPublic | null | undefined): { styles: React.CSSProperties; className: string } {
|
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() {
|
export function LeaderboardPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const user = useAuthStore((state) => state.user)
|
const user = useAuthStore((state) => state.user)
|
||||||
|
const toast = useToast()
|
||||||
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([])
|
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([])
|
||||||
|
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
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(() => {
|
useEffect(() => {
|
||||||
loadLeaderboard()
|
loadData()
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
const loadLeaderboard = async () => {
|
const loadData = async () => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
try {
|
try {
|
||||||
const data = await marathonsApi.getLeaderboard(parseInt(id))
|
const [leaderboardData, marathonData] = await Promise.all([
|
||||||
setLeaderboard(data)
|
marathonsApi.getLeaderboard(parseInt(id)),
|
||||||
|
marathonsApi.get(parseInt(id)),
|
||||||
|
])
|
||||||
|
setLeaderboard(leaderboardData)
|
||||||
|
setMarathon(marathonData)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load leaderboard:', error)
|
console.error('Failed to load data:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
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) => {
|
const getRankConfig = (rank: number) => {
|
||||||
switch (rank) {
|
switch (rank) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -366,6 +409,20 @@ export function LeaderboardPage() {
|
|||||||
</div>
|
</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 */}
|
{/* Points */}
|
||||||
<div className="relative text-right">
|
<div className="relative text-right">
|
||||||
<div className={`text-xl font-bold ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}>
|
<div className={`text-xl font-bold ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}>
|
||||||
@@ -380,6 +437,104 @@ export function LeaderboardPage() {
|
|||||||
</GlassCard>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -949,7 +949,6 @@ export function LobbyPage() {
|
|||||||
value={editChallenge.points}
|
value={editChallenge.points}
|
||||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
||||||
min={1}
|
min={1}
|
||||||
max={1000}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -1109,7 +1108,6 @@ export function LobbyPage() {
|
|||||||
value={newChallenge.points}
|
value={newChallenge.points}
|
||||||
onChange={(e) => setNewChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
onChange={(e) => setNewChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
||||||
min={1}
|
min={1}
|
||||||
max={1000}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -1351,7 +1349,6 @@ export function LobbyPage() {
|
|||||||
value={editChallenge.points}
|
value={editChallenge.points}
|
||||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
||||||
min={1}
|
min={1}
|
||||||
max={1000}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -1974,7 +1971,6 @@ export function LobbyPage() {
|
|||||||
value={playthroughPoints}
|
value={playthroughPoints}
|
||||||
onChange={(e) => setPlaythroughPoints(parseInt(e.target.value) || 50)}
|
onChange={(e) => setPlaythroughPoints(parseInt(e.target.value) || 50)}
|
||||||
min={1}
|
min={1}
|
||||||
max={1000}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -2151,7 +2147,6 @@ export function LobbyPage() {
|
|||||||
value={editPlaythroughPoints}
|
value={editPlaythroughPoints}
|
||||||
onChange={(e) => setEditPlaythroughPoints(parseInt(e.target.value) || 50)}
|
onChange={(e) => setEditPlaythroughPoints(parseInt(e.target.value) || 50)}
|
||||||
min={1}
|
min={1}
|
||||||
max={1000}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import { EventBanner } from '@/components/EventBanner'
|
|||||||
import { EventControl } from '@/components/EventControl'
|
import { EventControl } from '@/components/EventControl'
|
||||||
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
|
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
|
||||||
import { MarathonSettingsModal } from '@/components/MarathonSettingsModal'
|
import { MarathonSettingsModal } from '@/components/MarathonSettingsModal'
|
||||||
|
import { WidgetSettingsModal } from '@/components/WidgetSettingsModal'
|
||||||
import {
|
import {
|
||||||
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
|
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
|
||||||
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
|
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
|
||||||
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles,
|
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles,
|
||||||
AlertTriangle, Clock, CheckCircle, XCircle, ThumbsUp, ThumbsDown, ExternalLink, User
|
AlertTriangle, Clock, CheckCircle, XCircle, ThumbsUp, ThumbsDown, ExternalLink, User, Monitor
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { ru } from 'date-fns/locale'
|
import { ru } from 'date-fns/locale'
|
||||||
@@ -38,6 +39,7 @@ export function MarathonPage() {
|
|||||||
const [showChallenges, setShowChallenges] = useState(false)
|
const [showChallenges, setShowChallenges] = useState(false)
|
||||||
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
|
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
const [showWidgets, setShowWidgets] = useState(false)
|
||||||
const activityFeedRef = useRef<ActivityFeedRef>(null)
|
const activityFeedRef = useRef<ActivityFeedRef>(null)
|
||||||
|
|
||||||
// Disputes for organizers
|
// Disputes for organizers
|
||||||
@@ -663,6 +665,30 @@ export function MarathonPage() {
|
|||||||
</GlassCard>
|
</GlassCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Widgets for OBS */}
|
||||||
|
{marathon.status === 'active' && isParticipant && (
|
||||||
|
<GlassCard>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-purple-500/20 flex items-center justify-center">
|
||||||
|
<Monitor className="w-5 h-5 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">Виджеты для стрима</h3>
|
||||||
|
<p className="text-sm text-gray-400">Добавьте виджеты в OBS</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NeonButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowWidgets(true)}
|
||||||
|
icon={<Settings className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Настроить
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* My stats */}
|
{/* My stats */}
|
||||||
{marathon.my_participation && (
|
{marathon.my_participation && (
|
||||||
<GlassCard variant="neon">
|
<GlassCard variant="neon">
|
||||||
@@ -821,6 +847,13 @@ export function MarathonPage() {
|
|||||||
onClose={() => setShowSettings(false)}
|
onClose={() => setShowSettings(false)}
|
||||||
onUpdate={setMarathon}
|
onUpdate={setMarathon}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Widgets Modal */}
|
||||||
|
<WidgetSettingsModal
|
||||||
|
marathonId={marathon.id}
|
||||||
|
isOpen={showWidgets}
|
||||||
|
onClose={() => setShowWidgets(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequ
|
|||||||
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
||||||
import { SpinWheel } from '@/components/SpinWheel'
|
import { SpinWheel } from '@/components/SpinWheel'
|
||||||
import { EventBanner } from '@/components/EventBanner'
|
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 { useToast } from '@/store/toast'
|
||||||
import { useConfirm } from '@/store/confirm'
|
import { useConfirm } from '@/store/confirm'
|
||||||
import { useShopStore } from '@/store/shop'
|
import { useShopStore } from '@/store/shop'
|
||||||
@@ -494,40 +494,30 @@ export function PlayPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUseReroll = async () => {
|
const handleUseSkipExile = async () => {
|
||||||
if (!currentAssignment || !id) return
|
if (!currentAssignment || !id) return
|
||||||
setIsUsingConsumable('reroll')
|
const confirmed = await confirm({
|
||||||
|
title: 'Скип с изгнанием?',
|
||||||
|
message: 'Задание будет пропущено без штрафа, а игра навсегда удалена из вашего пула.',
|
||||||
|
confirmText: 'Использовать',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'warning',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setIsUsingConsumable('skip_exile')
|
||||||
try {
|
try {
|
||||||
await shopApi.useConsumable({
|
await shopApi.useConsumable({
|
||||||
item_code: 'reroll',
|
item_code: 'skip_exile',
|
||||||
marathon_id: parseInt(id),
|
marathon_id: parseInt(id),
|
||||||
assignment_id: currentAssignment.id,
|
assignment_id: currentAssignment.id,
|
||||||
})
|
})
|
||||||
toast.success('Задание отменено! Можно крутить заново.')
|
toast.success('Задание пропущено, игра изгнана из пула!')
|
||||||
await loadData()
|
await loadData()
|
||||||
useShopStore.getState().loadBalance()
|
useShopStore.getState().loadBalance()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
toast.error(error.response?.data?.detail || 'Не удалось использовать Reroll')
|
toast.error(error.response?.data?.detail || 'Не удалось использовать Skip с изгнанием')
|
||||||
} 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')
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsUsingConsumable(null)
|
setIsUsingConsumable(null)
|
||||||
}
|
}
|
||||||
@@ -541,7 +531,7 @@ export function PlayPage() {
|
|||||||
item_code: 'boost',
|
item_code: 'boost',
|
||||||
marathon_id: parseInt(id),
|
marathon_id: parseInt(id),
|
||||||
})
|
})
|
||||||
toast.success('Boost активирован! x1.5 очков за следующее выполнение.')
|
toast.success('Boost активирован! x1.5 очков за текущее задание.')
|
||||||
await loadData()
|
await loadData()
|
||||||
useShopStore.getState().loadBalance()
|
useShopStore.getState().loadBalance()
|
||||||
} catch (err: unknown) {
|
} 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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-24">
|
<div className="flex flex-col items-center justify-center py-24">
|
||||||
@@ -710,18 +813,18 @@ export function PlayPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active effects */}
|
{/* 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">
|
<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>
|
<p className="text-green-400 text-sm font-medium mb-2">Активные эффекты:</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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 && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -752,52 +855,28 @@ export function PlayPage() {
|
|||||||
</NeonButton>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reroll */}
|
{/* Skip with Exile */}
|
||||||
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
<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 justify-between mb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RefreshCw className="w-4 h-4 text-cyan-400" />
|
<XCircle className="w-4 h-4 text-red-400" />
|
||||||
<span className="text-white font-medium">Reroll</span>
|
<span className="text-white font-medium">Skip + Изгнание</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<p className="text-gray-500 text-xs mb-2">Переспинить задание</p>
|
<p className="text-gray-500 text-xs mb-2">Скип + убрать игру из пула</p>
|
||||||
<NeonButton
|
<NeonButton
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleUseReroll}
|
onClick={handleUseSkipExile}
|
||||||
disabled={consumablesStatus.rerolls_available === 0 || !currentAssignment || isUsingConsumable !== null}
|
disabled={consumablesStatus.skip_exiles_available === 0 || !currentAssignment || isUsingConsumable !== null}
|
||||||
isLoading={isUsingConsumable === 'reroll'}
|
isLoading={isUsingConsumable === 'skip_exile'}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
Использовать
|
Использовать
|
||||||
</NeonButton>
|
</NeonButton>
|
||||||
</div>
|
</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 */}
|
{/* Boost */}
|
||||||
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
<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 justify-between mb-2">
|
||||||
@@ -821,10 +900,188 @@ export function PlayPage() {
|
|||||||
{consumablesStatus.has_active_boost ? 'Активен' : 'Активировать'}
|
{consumablesStatus.has_active_boost ? 'Активен' : 'Активировать'}
|
||||||
</NeonButton>
|
</NeonButton>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</GlassCard>
|
</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 */}
|
{/* Tabs for Common Enemy event */}
|
||||||
{activeEvent?.event?.type === 'common_enemy' && (
|
{activeEvent?.event?.type === 'common_enemy' && (
|
||||||
<div className="flex gap-2 mb-6">
|
<div className="flex gap-2 mb-6">
|
||||||
@@ -1220,9 +1477,28 @@ export function PlayPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-6 flex-wrap">
|
<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">
|
<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} очков
|
+{currentAssignment.playthrough_info?.points} очков
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{currentAssignment.playthrough_info?.proof_hint && (
|
{currentAssignment.playthrough_info?.proof_hint && (
|
||||||
@@ -1564,7 +1840,14 @@ export function PlayPage() {
|
|||||||
Входящие запросы ({swapRequests.incoming.length})
|
Входящие запросы ({swapRequests.incoming.length})
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-3">
|
<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
|
<div
|
||||||
key={request.id}
|
key={request.id}
|
||||||
className="p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-xl"
|
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} предлагает обмен
|
{request.from_user.nickname} предлагает обмен
|
||||||
</p>
|
</p>
|
||||||
<p className="text-yellow-400 text-sm mt-1">
|
<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>
|
||||||
<p className="text-gray-400 text-xs">
|
<p className="text-gray-400 text-xs">
|
||||||
{request.from_challenge.game_title} • {request.from_challenge.points} очков
|
{challengeDetails}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -1605,7 +1888,8 @@ export function PlayPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1618,7 +1902,11 @@ export function PlayPage() {
|
|||||||
Отправленные запросы ({swapRequests.outgoing.length})
|
Отправленные запросы ({swapRequests.outgoing.length})
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-3">
|
<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
|
<div
|
||||||
key={request.id}
|
key={request.id}
|
||||||
className="p-4 bg-accent-500/10 border border-accent-500/30 rounded-xl"
|
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}
|
Запрос к {request.to_user.nickname}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-accent-400 text-sm mt-1">
|
<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>
|
||||||
<p className="text-gray-500 text-xs mt-1">
|
<p className="text-gray-500 text-xs mt-1">
|
||||||
Ожидание подтверждения...
|
Ожидание подтверждения...
|
||||||
@@ -1647,7 +1935,8 @@ export function PlayPage() {
|
|||||||
</NeonButton>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1675,7 +1964,14 @@ export function PlayPage() {
|
|||||||
!swapRequests.outgoing.some(r => r.to_user.id === c.user.id) &&
|
!swapRequests.outgoing.some(r => r.to_user.id === c.user.id) &&
|
||||||
!swapRequests.incoming.some(r => r.from_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
|
<div
|
||||||
key={candidate.participant_id}
|
key={candidate.participant_id}
|
||||||
className="p-4 bg-dark-700/50 rounded-xl border border-dark-600"
|
className="p-4 bg-dark-700/50 rounded-xl border border-dark-600"
|
||||||
@@ -1686,10 +1982,10 @@ export function PlayPage() {
|
|||||||
{candidate.user.nickname}
|
{candidate.user.nickname}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-neon-400 text-sm font-medium truncate">
|
<p className="text-neon-400 text-sm font-medium truncate">
|
||||||
{candidate.challenge_title}
|
{displayTitle}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-400 text-xs mt-1">
|
<p className="text-gray-400 text-xs mt-1">
|
||||||
{candidate.game_title} • {candidate.challenge_points} очков • {candidate.challenge_difficulty}
|
{displayDetails}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NeonButton
|
<NeonButton
|
||||||
@@ -1698,7 +1994,7 @@ export function PlayPage() {
|
|||||||
onClick={() => handleSendSwapRequest(
|
onClick={() => handleSendSwapRequest(
|
||||||
candidate.participant_id,
|
candidate.participant_id,
|
||||||
candidate.user.nickname,
|
candidate.user.nickname,
|
||||||
candidate.challenge_title
|
displayTitle
|
||||||
)}
|
)}
|
||||||
isLoading={sendingRequestTo === candidate.participant_id}
|
isLoading={sendingRequestTo === candidate.participant_id}
|
||||||
disabled={sendingRequestTo !== null}
|
disabled={sendingRequestTo !== null}
|
||||||
@@ -1708,7 +2004,8 @@ export function PlayPage() {
|
|||||||
</NeonButton>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { zodResolver } from '@hookform/resolvers/zod'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/auth'
|
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 type { UserStats, ShopItemPublic } from '@/types'
|
||||||
import { useToast } from '@/store/toast'
|
import { useToast } from '@/store/toast'
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +15,7 @@ import {
|
|||||||
User, Camera, Trophy, Target, CheckCircle, Flame,
|
User, Camera, Trophy, Target, CheckCircle, Flame,
|
||||||
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
|
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
|
||||||
Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles,
|
Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles,
|
||||||
AlertTriangle, FileCheck, Backpack, Edit3
|
AlertTriangle, FileCheck, Backpack, Edit3, Gift
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
@@ -289,6 +290,10 @@ export function ProfilePage() {
|
|||||||
const [notifyModeration, setNotifyModeration] = useState(user?.notify_moderation ?? true)
|
const [notifyModeration, setNotifyModeration] = useState(user?.notify_moderation ?? true)
|
||||||
const [notificationUpdating, setNotificationUpdating] = useState<string | null>(null)
|
const [notificationUpdating, setNotificationUpdating] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Promo code state
|
||||||
|
const [promoCode, setPromoCode] = useState('')
|
||||||
|
const [isRedeemingPromo, setIsRedeemingPromo] = useState(false)
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Forms
|
// 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 isLinked = !!user?.telegram_id
|
||||||
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
|
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
|
||||||
|
|
||||||
@@ -773,6 +799,37 @@ export function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Telegram */}
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { useConfirm } from '@/store/confirm'
|
|||||||
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
|
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
|
||||||
import {
|
import {
|
||||||
Loader2, Coins, ShoppingBag, Package, Sparkles,
|
Loader2, Coins, ShoppingBag, Package, Sparkles,
|
||||||
Frame, Type, Palette, Image, Zap, Shield, RefreshCw, SkipForward,
|
Frame, Type, Palette, Image, Zap, SkipForward,
|
||||||
Minus, Plus
|
Minus, Plus, Shuffle, Dice5, Copy, Undo2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { ShopItem, ShopItemType, ShopItemPublic } from '@/types'
|
import type { ShopItem, ShopItemType, ShopItemPublic } from '@/types'
|
||||||
import { RARITY_COLORS, RARITY_NAMES } 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> = {
|
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
|
||||||
skip: <SkipForward className="w-8 h-8" />,
|
skip: <SkipForward className="w-8 h-8" />,
|
||||||
shield: <Shield className="w-8 h-8" />,
|
|
||||||
boost: <Zap 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 {
|
interface ShopItemCardProps {
|
||||||
@@ -176,7 +178,7 @@ function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
'p-4 border transition-all duration-300',
|
'p-4 border transition-all duration-300',
|
||||||
rarityColors.border,
|
rarityColors.border,
|
||||||
item.is_owned && 'opacity-60'
|
item.is_owned && !isConsumable && 'opacity-60'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Rarity badge */}
|
{/* Rarity badge */}
|
||||||
@@ -196,7 +198,7 @@ function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Quantity selector for consumables */}
|
{/* 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">
|
<div className="flex items-center justify-center gap-2 mb-3">
|
||||||
<button
|
<button
|
||||||
onClick={decrementQuantity}
|
onClick={decrementQuantity}
|
||||||
@@ -236,7 +238,7 @@ function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
|
|||||||
) : (
|
) : (
|
||||||
<NeonButton
|
<NeonButton
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onPurchase(item, quantity)}
|
onClick={() => onPurchase(item, isConsumable ? quantity : 1)}
|
||||||
disabled={isPurchasing || !item.is_available}
|
disabled={isPurchasing || !item.is_available}
|
||||||
>
|
>
|
||||||
{isPurchasing ? (
|
{isPurchasing ? (
|
||||||
@@ -460,9 +462,9 @@ export function ShopPage() {
|
|||||||
</h3>
|
</h3>
|
||||||
<ul className="text-gray-400 text-sm space-y-1">
|
<ul className="text-gray-400 text-sm space-y-1">
|
||||||
<li>• Выполняй задания в <span className="text-neon-400">сертифицированных</span> марафонах</li>
|
<li>• Выполняй задания в <span className="text-neon-400">сертифицированных</span> марафонах</li>
|
||||||
<li>• Easy задание — 5 монет, Medium — 12 монет, Hard — 25 монет</li>
|
<li>• Easy задание — 10 монет, Medium — 20 монет, Hard — 35 монет</li>
|
||||||
<li>• Playthrough — ~5% от заработанных очков</li>
|
<li>• Playthrough — ~10% от заработанных очков</li>
|
||||||
<li>• Топ-3 места в марафоне: 1-е — 100, 2-е — 50, 3-е — 30 монет</li>
|
<li>• Топ-3 места в марафоне: 1-е — 500, 2-е — 250, 3-е — 150 монет</li>
|
||||||
</ul>
|
</ul>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
404
frontend/src/pages/admin/AdminGrantItemPage.tsx
Normal file
404
frontend/src/pages/admin/AdminGrantItemPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,13 +12,15 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Lock
|
Lock,
|
||||||
|
Gift
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
|
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
|
||||||
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
||||||
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
|
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
|
||||||
|
{ to: '/admin/promo', icon: Gift, label: 'Промокоды' },
|
||||||
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
|
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
|
||||||
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
|
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
|
||||||
{ to: '/admin/content', icon: FileText, label: 'Контент' },
|
{ to: '/admin/content', icon: FileText, label: 'Контент' },
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user