Compare commits
36
Commits
754b0b0803
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e91ef77b6
|
||
|
|
215d68a0e1
|
||
|
|
47812ffd09
|
||
|
|
a5fe8aacaf
|
||
|
|
b437210eb3
|
||
|
|
5584e54b58
|
||
|
|
35ea54ecea
|
||
|
|
821e453bc0
|
||
|
|
9fd0122a67
|
||
|
|
e557fe2cda
|
||
|
|
79e7cef3ba
|
||
|
|
a264336bd8
|
||
|
|
42e3571fab
|
||
|
|
43b314bb20
|
||
|
|
f1afb2096f
|
||
|
|
62a81e57ad
|
||
|
|
303b9e1c8a
|
||
|
|
f7c7eba4da
|
||
|
|
ce30539808
|
||
|
|
544112b204
|
||
|
|
9f94fb3974
|
||
|
|
25072e8eb4
|
||
|
|
9e5ab8539a
|
||
|
|
4ba6a48246
|
||
|
|
92dddca964
|
||
|
|
bf21a5eae6
|
||
|
|
89faa7cd40
|
||
|
|
a800c7fff7
|
||
|
|
2ee8cd6be3
|
||
|
|
35824de310
|
||
|
|
65cc2e555f
|
||
|
|
6d996a4e2f
|
||
|
|
f774ff3340
|
||
|
|
779393106d
|
||
|
|
3e04f8312a
|
||
|
|
7cfab20826
|
@@ -34,3 +34,6 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
return obj.get_cas_groups_display()
|
return obj.get_cas_groups_display()
|
||||||
|
|
||||||
get_cas_groups_display.short_description = "CAS Groups"
|
get_cas_groups_display.short_description = "CAS Groups"
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 18:11
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="customuser",
|
||||||
|
name="points",
|
||||||
|
field=models.IntegerField(default=1000),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -14,6 +14,9 @@ class CustomUser(AbstractUser):
|
|||||||
# Additional fields that might come from CAS
|
# Additional fields that might come from CAS
|
||||||
cas_attributes = models.JSONField(default=dict, blank=True)
|
cas_attributes = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
# Market points balance
|
||||||
|
points = models.IntegerField(default=1000)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.username} ({self.cas_user_id})"
|
return f"{self.username} ({self.cas_user_id})"
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from ninja import Router
|
from ninja import Router
|
||||||
|
from ninja.errors import HttpError
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
from accounts.models import CustomUser
|
from accounts.models import CustomUser
|
||||||
from animations.schemas import RankingSchema
|
from animations.schemas import (
|
||||||
from submissions.models import PuzzleResponse, SteamCollectionItem
|
RankingSchema,
|
||||||
|
TournamentSubmissionsOut,
|
||||||
|
TournamentPuzzleResultsOut,
|
||||||
|
PuzzleSubmissionsOut,
|
||||||
|
PuzzleResultsOut,
|
||||||
|
PuzzleSubmissionWithRankOut,
|
||||||
|
PuzzlePointsFactorOut,
|
||||||
|
WinnerResponseOut,
|
||||||
|
WinnerFileOut,
|
||||||
|
)
|
||||||
|
from opus_magnum.models import PuzzleResponse, SteamCollectionItem, SteamCollection
|
||||||
|
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
@@ -46,3 +58,184 @@ def results(request: HttpRequest) -> dict:
|
|||||||
|
|
||||||
cache.set("api:results:results", data, 300)
|
cache.set("api:results:results", data, 300)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("top-submissions", response=TournamentSubmissionsOut)
|
||||||
|
def top_submissions(request: HttpRequest, limit: int = 5) -> dict:
|
||||||
|
"""Get tournament top submissions for each puzzle. Only available when tournament is closed."""
|
||||||
|
cache_key = f"api:results:top_submissions:{limit}"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
collection = get_object_or_404(SteamCollection, is_active=True)
|
||||||
|
|
||||||
|
# Only allow access when tournament is closed
|
||||||
|
if collection.accepting_submissions:
|
||||||
|
raise HttpError(403, "Tournament is still accepting submissions")
|
||||||
|
|
||||||
|
# Get all puzzles
|
||||||
|
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by(
|
||||||
|
"order_index"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
submissions_list = []
|
||||||
|
for puzzle in puzzles:
|
||||||
|
# Get the top N responses for this puzzle (ranked by points, highest first)
|
||||||
|
top_responses = (
|
||||||
|
PuzzleResponse.objects.filter(puzzle=puzzle, needs_manual_validation=False)
|
||||||
|
.filter_user_best_response()
|
||||||
|
.annotate_rank_points()
|
||||||
|
.order_by("-rank_points")[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build submission list for this puzzle
|
||||||
|
puzzle_submissions = []
|
||||||
|
for response in top_responses:
|
||||||
|
# Get submission files
|
||||||
|
files = response.files.all()
|
||||||
|
response_files = [
|
||||||
|
WinnerFileOut(
|
||||||
|
file_url=file.file_url or "",
|
||||||
|
original_filename=file.original_filename,
|
||||||
|
)
|
||||||
|
for file in files
|
||||||
|
]
|
||||||
|
|
||||||
|
# Calculate total coefficient
|
||||||
|
total_coef = None
|
||||||
|
if (
|
||||||
|
puzzle.points_factor
|
||||||
|
and response.final_cost is not None
|
||||||
|
and response.final_cycles is not None
|
||||||
|
and response.final_area is not None
|
||||||
|
):
|
||||||
|
total_coef = (
|
||||||
|
puzzle.points_factor.cost * response.final_cost
|
||||||
|
+ puzzle.points_factor.cycles * response.final_cycles
|
||||||
|
+ puzzle.points_factor.area * response.final_area
|
||||||
|
)
|
||||||
|
|
||||||
|
submission_data = WinnerResponseOut(
|
||||||
|
user_id=response.submission.user.id if response.submission.user else 0,
|
||||||
|
username=response.submission.user.username
|
||||||
|
if response.submission.user
|
||||||
|
else "Anonymous",
|
||||||
|
final_cost=response.final_cost,
|
||||||
|
final_cycles=response.final_cycles,
|
||||||
|
final_area=response.final_area,
|
||||||
|
rank_points=response.rank_points,
|
||||||
|
total_coef=total_coef,
|
||||||
|
files=response_files,
|
||||||
|
)
|
||||||
|
puzzle_submissions.append(submission_data)
|
||||||
|
|
||||||
|
submissions_list.append(
|
||||||
|
PuzzleSubmissionsOut(
|
||||||
|
puzzle_id=puzzle.id,
|
||||||
|
puzzle_title=puzzle.title,
|
||||||
|
submissions=puzzle_submissions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {"submissions": submissions_list}
|
||||||
|
cache.set(f"api:results:top_submissions:{limit}", data, 300)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("puzzle-results", response=TournamentPuzzleResultsOut)
|
||||||
|
def puzzle_results(request: HttpRequest, limit: int = 5) -> dict:
|
||||||
|
"""Get tournament results organized by puzzle with coefficients. Only available when tournament is closed."""
|
||||||
|
cache_key = f"api:results:puzzle_results:{limit}"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
collection = get_object_or_404(SteamCollection, is_active=True)
|
||||||
|
|
||||||
|
# Only allow access when tournament is closed
|
||||||
|
if collection.accepting_submissions:
|
||||||
|
raise HttpError(403, "Tournament is still accepting submissions")
|
||||||
|
|
||||||
|
# Get all puzzles
|
||||||
|
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by(
|
||||||
|
"order_index"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
results_list = []
|
||||||
|
for puzzle in puzzles:
|
||||||
|
# Get the top N responses for this puzzle (ranked by points)
|
||||||
|
top_responses = (
|
||||||
|
PuzzleResponse.objects.filter(puzzle=puzzle, needs_manual_validation=False)
|
||||||
|
.filter_user_best_response()
|
||||||
|
.annotate_rank_points()
|
||||||
|
.order_by("-rank_points")[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build submission list for this puzzle with rank
|
||||||
|
puzzle_submissions = []
|
||||||
|
for rank, response in enumerate(top_responses, 1):
|
||||||
|
# Get submission files
|
||||||
|
files = response.files.all()
|
||||||
|
response_files = [
|
||||||
|
WinnerFileOut(
|
||||||
|
file_url=file.file_url or "",
|
||||||
|
original_filename=file.original_filename,
|
||||||
|
)
|
||||||
|
for file in files
|
||||||
|
]
|
||||||
|
|
||||||
|
# Calculate total coefficient
|
||||||
|
total_coef = None
|
||||||
|
if (
|
||||||
|
puzzle.points_factor
|
||||||
|
and response.final_cost is not None
|
||||||
|
and response.final_cycles is not None
|
||||||
|
and response.final_area is not None
|
||||||
|
):
|
||||||
|
total_coef = (
|
||||||
|
puzzle.points_factor.cost * response.final_cost
|
||||||
|
+ puzzle.points_factor.cycles * response.final_cycles
|
||||||
|
+ puzzle.points_factor.area * response.final_area
|
||||||
|
)
|
||||||
|
|
||||||
|
submission_data = PuzzleSubmissionWithRankOut(
|
||||||
|
rank=rank,
|
||||||
|
user_id=response.submission.user.id if response.submission.user else 0,
|
||||||
|
username=response.submission.user.username
|
||||||
|
if response.submission.user
|
||||||
|
else "Anonymous",
|
||||||
|
final_cost=response.final_cost,
|
||||||
|
final_cycles=response.final_cycles,
|
||||||
|
final_area=response.final_area,
|
||||||
|
rank_points=response.rank_points,
|
||||||
|
total_coef=total_coef,
|
||||||
|
files=response_files,
|
||||||
|
)
|
||||||
|
puzzle_submissions.append(submission_data)
|
||||||
|
|
||||||
|
# Get points factor if available
|
||||||
|
points_factor = None
|
||||||
|
if puzzle.points_factor:
|
||||||
|
points_factor = PuzzlePointsFactorOut(
|
||||||
|
cost=puzzle.points_factor.cost,
|
||||||
|
cycles=puzzle.points_factor.cycles,
|
||||||
|
area=puzzle.points_factor.area,
|
||||||
|
)
|
||||||
|
|
||||||
|
results_list.append(
|
||||||
|
PuzzleResultsOut(
|
||||||
|
puzzle_id=puzzle.id,
|
||||||
|
puzzle_title=puzzle.title,
|
||||||
|
points_factor=points_factor,
|
||||||
|
submissions=puzzle_submissions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {"results": results_list}
|
||||||
|
cache.set(f"api:results:puzzle_results:{limit}", data, 300)
|
||||||
|
return data
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from ninja import ModelSchema, Schema
|
from ninja import ModelSchema, Schema
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
from submissions.models import PuzzleResponse
|
from opus_magnum.models import PuzzleResponse
|
||||||
from submissions.schemas import SteamCollectionItemOut
|
from opus_magnum.schemas import SteamCollectionItemOut
|
||||||
|
|
||||||
|
|
||||||
class PuzzleResponseRankingOut(ModelSchema):
|
class PuzzleResponseRankingOut(ModelSchema):
|
||||||
@@ -33,6 +34,7 @@ class PuzzleResponseRankingOut(ModelSchema):
|
|||||||
class UserDisplayOut(Schema):
|
class UserDisplayOut(Schema):
|
||||||
id: int
|
id: int
|
||||||
username: str
|
username: str
|
||||||
|
is_staff: bool
|
||||||
|
|
||||||
|
|
||||||
class RankingSchema(Schema):
|
class RankingSchema(Schema):
|
||||||
@@ -40,3 +42,74 @@ class RankingSchema(Schema):
|
|||||||
puzzles: list[SteamCollectionItemOut]
|
puzzles: list[SteamCollectionItemOut]
|
||||||
responses_by_userid: dict[int, list[PuzzleResponseRankingOut]]
|
responses_by_userid: dict[int, list[PuzzleResponseRankingOut]]
|
||||||
ranking_by_puzzle: dict[int, list[PuzzleResponseRankingOut]]
|
ranking_by_puzzle: dict[int, list[PuzzleResponseRankingOut]]
|
||||||
|
|
||||||
|
|
||||||
|
class WinnerFileOut(Schema):
|
||||||
|
"""Schema for winner submission file"""
|
||||||
|
|
||||||
|
file_url: str
|
||||||
|
original_filename: str
|
||||||
|
|
||||||
|
|
||||||
|
class WinnerResponseOut(Schema):
|
||||||
|
"""Schema for winner response with files"""
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
final_cost: Optional[int]
|
||||||
|
final_cycles: Optional[int]
|
||||||
|
final_area: Optional[int]
|
||||||
|
rank_points: Optional[int]
|
||||||
|
total_coef: Optional[int]
|
||||||
|
files: List[WinnerFileOut]
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleSubmissionsOut(Schema):
|
||||||
|
"""Schema for puzzle with all top submissions"""
|
||||||
|
|
||||||
|
puzzle_id: int
|
||||||
|
puzzle_title: str
|
||||||
|
submissions: List[WinnerResponseOut]
|
||||||
|
|
||||||
|
|
||||||
|
class TournamentSubmissionsOut(Schema):
|
||||||
|
"""Schema for tournament top submissions results"""
|
||||||
|
|
||||||
|
submissions: List[PuzzleSubmissionsOut]
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzlePointsFactorOut(Schema):
|
||||||
|
"""Schema for puzzle points factor"""
|
||||||
|
|
||||||
|
cost: int
|
||||||
|
cycles: int
|
||||||
|
area: int
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleSubmissionWithRankOut(Schema):
|
||||||
|
"""Schema for puzzle submission with rank"""
|
||||||
|
|
||||||
|
rank: int
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
final_cost: Optional[int]
|
||||||
|
final_cycles: Optional[int]
|
||||||
|
final_area: Optional[int]
|
||||||
|
rank_points: Optional[int]
|
||||||
|
total_coef: Optional[int]
|
||||||
|
files: List[WinnerFileOut]
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleResultsOut(Schema):
|
||||||
|
"""Schema for puzzle-specific results with coefficients"""
|
||||||
|
|
||||||
|
puzzle_id: int
|
||||||
|
puzzle_title: str
|
||||||
|
points_factor: Optional[PuzzlePointsFactorOut]
|
||||||
|
submissions: List[PuzzleSubmissionWithRankOut]
|
||||||
|
|
||||||
|
|
||||||
|
class TournamentPuzzleResultsOut(Schema):
|
||||||
|
"""Schema for tournament puzzle-specific results"""
|
||||||
|
|
||||||
|
results: List[PuzzleResultsOut]
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Game
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Game)
|
||||||
|
class GameAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "steam_app_id", "enabled", "updated_at"]
|
||||||
|
list_filter = ["enabled"]
|
||||||
|
search_fields = ["name", "steam_app_id"]
|
||||||
|
readonly_fields = ["created_at", "updated_at"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from ninja import Router
|
||||||
|
|
||||||
|
from .models import Game
|
||||||
|
from .schemas import GameOut
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response=List[GameOut])
|
||||||
|
def list_games(request):
|
||||||
|
return Game.objects.filter(enabled=True)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class SubmissionsConfig(AppConfig):
|
class GamesConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "submissions"
|
name = "games"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
from .models import Game
|
||||||
|
|
||||||
|
|
||||||
|
def require_game_enabled(steam_app_id: int):
|
||||||
|
def decorator(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(request, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
game = Game.objects.get(steam_app_id=steam_app_id)
|
||||||
|
except Game.DoesNotExist:
|
||||||
|
raise PermissionDenied
|
||||||
|
if not game.enabled:
|
||||||
|
raise PermissionDenied
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Game",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("steam_app_id", models.PositiveIntegerField(unique=True)),
|
||||||
|
("name", models.CharField(max_length=255)),
|
||||||
|
("enabled", models.BooleanField(default=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["name"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
NOITA_APP_ID = 881100
|
||||||
|
OPUS_MAGNUM_APP_ID = 558990
|
||||||
|
|
||||||
|
|
||||||
|
def seed_games(apps, schema_editor):
|
||||||
|
Game = apps.get_model("games", "Game")
|
||||||
|
Game.objects.get_or_create(
|
||||||
|
steam_app_id=NOITA_APP_ID,
|
||||||
|
defaults={"name": "Noita", "enabled": True},
|
||||||
|
)
|
||||||
|
Game.objects.get_or_create(
|
||||||
|
steam_app_id=OPUS_MAGNUM_APP_ID,
|
||||||
|
defaults={"name": "Opus Magnum", "enabled": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unseed_games(apps, schema_editor):
|
||||||
|
Game = apps.get_model("games", "Game")
|
||||||
|
Game.objects.filter(steam_app_id__in=[NOITA_APP_ID, OPUS_MAGNUM_APP_ID]).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_games, reverse_code=unseed_games),
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
NOITA_APP_ID = 881100
|
||||||
|
OPUS_MAGNUM_APP_ID = 558990
|
||||||
|
|
||||||
|
PATHS = {
|
||||||
|
NOITA_APP_ID: "/noita",
|
||||||
|
OPUS_MAGNUM_APP_ID: "/opus-magnum",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_paths(apps, schema_editor):
|
||||||
|
Game = apps.get_model("games", "Game")
|
||||||
|
for app_id, path in PATHS.items():
|
||||||
|
Game.objects.filter(steam_app_id=app_id).update(path=path)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0002_seed_noita_and_opus_magnum"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="path",
|
||||||
|
field=models.CharField(default="", max_length=100),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.RunPython(set_paths, reverse_code=migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Game(models.Model):
|
||||||
|
steam_app_id = models.PositiveIntegerField(unique=True)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
path = models.CharField(max_length=100)
|
||||||
|
enabled = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.name} ({self.steam_app_id})"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from ninja import Schema
|
||||||
|
|
||||||
|
|
||||||
|
class GameOut(Schema):
|
||||||
|
steam_app_id: int
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from market.models import Market, MarketOption, UserBet, UserPointChange
|
||||||
|
|
||||||
|
|
||||||
|
class MarketOptionInline(admin.TabularInline):
|
||||||
|
model = MarketOption
|
||||||
|
extra = 1
|
||||||
|
fields = ["text"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Market)
|
||||||
|
class MarketAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["title", "status", "end_date", "created_by", "created_at"]
|
||||||
|
list_filter = ["status", "created_at"]
|
||||||
|
search_fields = ["uuid", "title"]
|
||||||
|
readonly_fields = [
|
||||||
|
"uuid",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_by",
|
||||||
|
"winning_option",
|
||||||
|
]
|
||||||
|
inlines = [MarketOptionInline]
|
||||||
|
fieldsets = (
|
||||||
|
("Info", {"fields": ["uuid", "title", "description"]}),
|
||||||
|
("Configuration", {"fields": ["end_date", "multiplier"]}),
|
||||||
|
("Status", {"fields": ["status", "winning_option"]}),
|
||||||
|
("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
# Prevent any changes to resolved markets
|
||||||
|
if obj and obj.status == Market.Status.RESOLVED:
|
||||||
|
return False
|
||||||
|
return super().has_change_permission(request, obj)
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
if not change: # Creating new market
|
||||||
|
obj.created_by = request.user
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
@admin.action(description="Publish selected draft markets")
|
||||||
|
def publish_markets(self, request, queryset):
|
||||||
|
updated = queryset.filter(status=Market.Status.DRAFT).update(
|
||||||
|
status=Market.Status.OPEN
|
||||||
|
)
|
||||||
|
self.message_user(request, f"Published {updated} market(s).")
|
||||||
|
|
||||||
|
@admin.action(description="Close selected markets")
|
||||||
|
def close_markets(self, request, queryset):
|
||||||
|
updated = queryset.filter(status=Market.Status.OPEN).update(
|
||||||
|
status=Market.Status.CLOSED
|
||||||
|
)
|
||||||
|
self.message_user(request, f"Closed {updated} market(s).")
|
||||||
|
|
||||||
|
actions = ["publish_markets", "close_markets"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MarketOption)
|
||||||
|
class MarketOptionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["text", "market"]
|
||||||
|
list_filter = ["market"]
|
||||||
|
search_fields = ["uuid", "text", "market__title"]
|
||||||
|
readonly_fields = ["uuid"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserBet)
|
||||||
|
class UserBetAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["user", "option", "amount", "created_at"]
|
||||||
|
list_filter = ["user", "created_at", "option__market"]
|
||||||
|
search_fields = ["uuid", "user__username", "option__text"]
|
||||||
|
readonly_fields = ["uuid", "user", "option", "amount", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserPointChange)
|
||||||
|
class UserPointChangeAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["user", "market", "amount", "reason", "created_at"]
|
||||||
|
list_filter = ["user", "reason", "created_at", "market"]
|
||||||
|
search_fields = ["uuid", "user__username", "market__title"]
|
||||||
|
readonly_fields = ["uuid", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
from typing import List
|
||||||
|
from ninja import Router
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.db.models import Sum, Prefetch
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from market.models import Market, MarketOption, UserBet, UserPointChange
|
||||||
|
from market.schemas import (
|
||||||
|
MarketListSchema,
|
||||||
|
ResolveMarketSchema,
|
||||||
|
UserBetCreateSchema,
|
||||||
|
UserBetSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = Router(tags=["market"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response=List[MarketListSchema])
|
||||||
|
def list_markets(request):
|
||||||
|
"""List all markets (excludes draft markets)."""
|
||||||
|
markets = Market.objects.exclude(status=Market.Status.DRAFT)
|
||||||
|
# Prefetch options with total_bets annotation sorted by total_bets desc, then text asc
|
||||||
|
options_queryset = MarketOption.objects.annotate(
|
||||||
|
total_bets=Coalesce(Sum("user_bets__amount"), 0)
|
||||||
|
).order_by("-total_bets", "text")
|
||||||
|
|
||||||
|
return markets.prefetch_related(Prefetch("options", queryset=options_queryset))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/bets", response=List[UserBetSchema])
|
||||||
|
def list_user_bets(request):
|
||||||
|
"""List all bets placed by the current user."""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
raise HttpError(401, "Authentication required")
|
||||||
|
|
||||||
|
return (
|
||||||
|
UserBet.objects.filter(user=request.user)
|
||||||
|
.select_related("option__market")
|
||||||
|
.prefetch_related("option__market__options")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{market_uuid}/actions/close")
|
||||||
|
def close_market(request, market_uuid: str):
|
||||||
|
"""Close a market. Admin only."""
|
||||||
|
if not request.user.is_staff:
|
||||||
|
raise HttpError(403, "Permission denied")
|
||||||
|
|
||||||
|
market = get_object_or_404(Market, uuid=market_uuid)
|
||||||
|
market.status = Market.Status.CLOSED
|
||||||
|
market.save(update_fields=["status", "updated_at"])
|
||||||
|
return {"status": Market.Status.CLOSED}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{market_uuid}/actions/resolve", response=MarketListSchema)
|
||||||
|
def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema):
|
||||||
|
"""Resolve a market with a winning option. Admin only."""
|
||||||
|
if not request.user.is_staff:
|
||||||
|
raise HttpError(403, "Permission denied")
|
||||||
|
|
||||||
|
market = get_object_or_404(Market, uuid=market_uuid)
|
||||||
|
winning_option = get_object_or_404(MarketOption, uuid=payload.winning_option_uuid)
|
||||||
|
|
||||||
|
if winning_option.market_id != market.id:
|
||||||
|
raise HttpError(400, "Option does not belong to this market")
|
||||||
|
|
||||||
|
market.winning_option = winning_option
|
||||||
|
market.status = Market.Status.RESOLVED
|
||||||
|
market.save(update_fields=["winning_option", "status", "updated_at"])
|
||||||
|
|
||||||
|
# Calculate and distribute winnings
|
||||||
|
all_bets = list(
|
||||||
|
UserBet.objects.filter(option__market=market).select_related("user")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate total pot
|
||||||
|
total_pot = sum(bet.amount for bet in all_bets)
|
||||||
|
if total_pot == 0:
|
||||||
|
return market
|
||||||
|
|
||||||
|
# Separate winning and losing bets
|
||||||
|
winning_bets = [bet for bet in all_bets if bet.option_id == winning_option.id]
|
||||||
|
losing_bets = [bet for bet in all_bets if bet.option_id != winning_option.id]
|
||||||
|
|
||||||
|
total_winning = sum(bet.amount for bet in winning_bets)
|
||||||
|
|
||||||
|
point_changes = []
|
||||||
|
users_to_update = []
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# Award payouts to winners with multiplier
|
||||||
|
if total_winning > 0:
|
||||||
|
for bet in winning_bets:
|
||||||
|
payout = round(
|
||||||
|
bet.amount / total_winning * total_pot * market.multiplier
|
||||||
|
)
|
||||||
|
bet.user.points += payout
|
||||||
|
users_to_update.append(bet.user)
|
||||||
|
point_changes.append(
|
||||||
|
UserPointChange(
|
||||||
|
user=bet.user,
|
||||||
|
market=market,
|
||||||
|
amount=payout,
|
||||||
|
reason=UserPointChange.Reason.BET_WON,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Record losing bets (points already deducted)
|
||||||
|
for bet in losing_bets:
|
||||||
|
point_changes.append(
|
||||||
|
UserPointChange(
|
||||||
|
user=bet.user,
|
||||||
|
market=market,
|
||||||
|
amount=-bet.amount,
|
||||||
|
reason=UserPointChange.Reason.BET_LOST,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bulk update users
|
||||||
|
for user in users_to_update:
|
||||||
|
user.save(update_fields=["points"])
|
||||||
|
|
||||||
|
# Bulk create point changes
|
||||||
|
UserPointChange.objects.bulk_create(point_changes)
|
||||||
|
|
||||||
|
return market
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{market_uuid}/bets", response=UserBetSchema)
|
||||||
|
def create_bet(request, market_uuid: str, payload: UserBetCreateSchema):
|
||||||
|
"""Place a bet on a market option."""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
raise HttpError(401, "Authentication required")
|
||||||
|
|
||||||
|
market = get_object_or_404(Market, uuid=market_uuid)
|
||||||
|
option = get_object_or_404(MarketOption, uuid=payload.option_uuid)
|
||||||
|
|
||||||
|
if option.market_id != market.id:
|
||||||
|
raise HttpError(400, "Option does not belong to this market")
|
||||||
|
|
||||||
|
if market.status != Market.Status.OPEN:
|
||||||
|
raise HttpError(400, "Market is not open for betting")
|
||||||
|
|
||||||
|
# Check if user already has a bet on a different option in this market
|
||||||
|
existing_bet_on_market = (
|
||||||
|
UserBet.objects.filter(user=request.user, option__market=market)
|
||||||
|
.exclude(option=option)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing_bet_on_market:
|
||||||
|
raise HttpError(400, "You can only bet on one option per market")
|
||||||
|
|
||||||
|
# Check if user already has a bet on this option
|
||||||
|
existing_bet = UserBet.objects.filter(user=request.user, option=option).first()
|
||||||
|
if existing_bet and payload.amount < existing_bet.amount:
|
||||||
|
raise HttpError(400, "Cannot decrease bet amount. You can only increase it.")
|
||||||
|
|
||||||
|
# Calculate delta (amount to deduct from user's points)
|
||||||
|
delta = payload.amount - (existing_bet.amount if existing_bet else 0)
|
||||||
|
|
||||||
|
# Check if user has enough points
|
||||||
|
if request.user.points < delta:
|
||||||
|
raise HttpError(400, "Insufficient points for this bet")
|
||||||
|
|
||||||
|
user_bet, created = UserBet.objects.update_or_create(
|
||||||
|
user=request.user,
|
||||||
|
option=option,
|
||||||
|
defaults={"amount": payload.amount},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deduct points and record the change
|
||||||
|
request.user.points -= delta
|
||||||
|
request.user.save(update_fields=["points"])
|
||||||
|
UserPointChange.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
market=market,
|
||||||
|
amount=-delta,
|
||||||
|
reason=UserPointChange.Reason.BET_PLACED,
|
||||||
|
)
|
||||||
|
|
||||||
|
return user_bet
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MarketConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "market"
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 15:45
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Market",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("title", models.CharField(max_length=255)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("yes_no", "Yes/No"), ("multiple", "Multiple Choice")],
|
||||||
|
default="yes_no",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("open", "Open"),
|
||||||
|
("closed", "Closed"),
|
||||||
|
("resolved", "Resolved"),
|
||||||
|
],
|
||||||
|
default="open",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("end_date", models.DateTimeField()),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="MarketOption",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("text", models.CharField(max_length=255)),
|
||||||
|
("position", models.PositiveIntegerField(default=0)),
|
||||||
|
(
|
||||||
|
"market",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="options",
|
||||||
|
to="market.market",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["position"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="market",
|
||||||
|
name="winning_option",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="market_won",
|
||||||
|
to="market.marketoption",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="UserBet",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("amount", models.PositiveIntegerField()),
|
||||||
|
(
|
||||||
|
"option",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="user_bets",
|
||||||
|
to="market.marketoption",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="bets",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="marketoption",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["market", "position"], name="market_mark_market__8679ce_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="marketoption",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("market", "position"), name="unique_market_option_position"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="market",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["status", "-created_at"], name="market_mark_status_1ef6c3_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="market",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["end_date"], name="market_mark_end_dat_26bec0_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="userbet",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["user", "option"], name="market_user_user_id_5e43d9_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="userbet",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("user", "option"), name="unique_user_bet_per_option"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 18:11
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("market", "0001_initial"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="UserPointChange",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("amount", models.IntegerField()),
|
||||||
|
(
|
||||||
|
"reason",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("bet_placed", "Bet Placed"),
|
||||||
|
("bet_won", "Bet Won"),
|
||||||
|
("bet_lost", "Bet Lost"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"market",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="point_changes",
|
||||||
|
to="market.market",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="point_changes",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(
|
||||||
|
fields=["user", "-created_at"],
|
||||||
|
name="market_user_user_id_631ba9_idx",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 18:19
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("market", "0002_userpointchange"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="market",
|
||||||
|
name="type",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 18:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("market", "0003_remove_market_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="marketoption",
|
||||||
|
options={"ordering": ["text"]},
|
||||||
|
),
|
||||||
|
migrations.RemoveConstraint(
|
||||||
|
model_name="marketoption",
|
||||||
|
name="unique_market_option_position",
|
||||||
|
),
|
||||||
|
migrations.RemoveIndex(
|
||||||
|
model_name="marketoption",
|
||||||
|
name="market_mark_market__8679ce_idx",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="marketoption",
|
||||||
|
name="position",
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="marketoption",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["market"], name="market_mark_market__67f63b_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="marketoption",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("market", "text"), name="unique_market_option_text"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 18:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("market", "0004_alter_marketoption_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="market",
|
||||||
|
name="multiplier",
|
||||||
|
field=models.FloatField(default=1.0),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 18:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("market", "0005_market_multiplier"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="market",
|
||||||
|
name="status",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("draft", "Draft"),
|
||||||
|
("open", "Open"),
|
||||||
|
("closed", "Closed"),
|
||||||
|
("resolved", "Resolved"),
|
||||||
|
],
|
||||||
|
default="draft",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class BaseModel(models.Model):
|
||||||
|
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class Market(BaseModel):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
DRAFT = "draft", "Draft"
|
||||||
|
OPEN = "open", "Open"
|
||||||
|
CLOSED = "closed", "Closed"
|
||||||
|
RESOLVED = "resolved", "Resolved"
|
||||||
|
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=10, choices=Status.choices, default=Status.DRAFT
|
||||||
|
)
|
||||||
|
end_date = models.DateTimeField()
|
||||||
|
multiplier = models.FloatField(default=1.0)
|
||||||
|
created_by = models.ForeignKey("accounts.CustomUser", on_delete=models.PROTECT)
|
||||||
|
winning_option = models.ForeignKey(
|
||||||
|
"MarketOption",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="market_won",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["status", "-created_at"]),
|
||||||
|
models.Index(fields=["end_date"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class MarketOption(BaseModel):
|
||||||
|
market = models.ForeignKey(Market, on_delete=models.CASCADE, related_name="options")
|
||||||
|
text = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["text"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["market", "text"],
|
||||||
|
name="unique_market_option_text",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["market"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.market.title} - {self.text}"
|
||||||
|
|
||||||
|
|
||||||
|
class UserBet(BaseModel):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"accounts.CustomUser", on_delete=models.CASCADE, related_name="bets"
|
||||||
|
)
|
||||||
|
option = models.ForeignKey(
|
||||||
|
MarketOption, on_delete=models.CASCADE, related_name="user_bets"
|
||||||
|
)
|
||||||
|
amount = models.PositiveIntegerField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "option"],
|
||||||
|
name="unique_user_bet_per_option",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "option"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} bet {self.amount} on {self.option.text}"
|
||||||
|
|
||||||
|
|
||||||
|
class UserPointChange(BaseModel):
|
||||||
|
class Reason(models.TextChoices):
|
||||||
|
BET_PLACED = "bet_placed", "Bet Placed"
|
||||||
|
BET_WON = "bet_won", "Bet Won"
|
||||||
|
BET_LOST = "bet_lost", "Bet Lost"
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"accounts.CustomUser", on_delete=models.CASCADE, related_name="point_changes"
|
||||||
|
)
|
||||||
|
market = models.ForeignKey(
|
||||||
|
Market, on_delete=models.CASCADE, related_name="point_changes"
|
||||||
|
)
|
||||||
|
amount = models.IntegerField()
|
||||||
|
reason = models.CharField(max_length=20, choices=Reason.choices)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "-created_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} {self.reason}: {self.amount} pts on {self.market.title}"
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional, Any
|
||||||
|
from uuid import UUID
|
||||||
|
from ninja import Schema
|
||||||
|
from pydantic import field_serializer, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class MarketOptionSchema(Schema):
|
||||||
|
uuid: UUID
|
||||||
|
text: str
|
||||||
|
total_bets: int = 0
|
||||||
|
|
||||||
|
@field_serializer("uuid")
|
||||||
|
def serialize_uuid(self, value: UUID) -> str:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class MarketListSchema(Schema):
|
||||||
|
uuid: UUID
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
status: str
|
||||||
|
end_date: datetime
|
||||||
|
multiplier: float = 1.0
|
||||||
|
created_at: datetime
|
||||||
|
options: List[MarketOptionSchema]
|
||||||
|
winning_option: Optional[MarketOptionSchema] = None
|
||||||
|
|
||||||
|
@field_serializer("uuid")
|
||||||
|
def serialize_uuid(self, value: UUID) -> str:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveMarketSchema(Schema):
|
||||||
|
winning_option_uuid: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserBetCreateSchema(Schema):
|
||||||
|
option_uuid: str
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
|
||||||
|
class UserBetSchema(Schema):
|
||||||
|
uuid: UUID
|
||||||
|
amount: int
|
||||||
|
created_at: datetime
|
||||||
|
option: MarketOptionSchema
|
||||||
|
market: Optional[MarketListSchema] = None
|
||||||
|
|
||||||
|
@field_serializer("uuid")
|
||||||
|
def serialize_uuid(self, value: UUID) -> str:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def resolve_market_from_option(cls, data: Any) -> Any:
|
||||||
|
if hasattr(data, "option") and hasattr(data.option, "market"):
|
||||||
|
data.market = data.option.market
|
||||||
|
return data
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.http import HttpRequest
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def market_home(request: HttpRequest):
|
||||||
|
return render(request, "market.html", {})
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import LogfileSubmission, Objectiv, ObjectivPoint
|
|
||||||
|
from noita.services.objectives import parse_objectives_and_store
|
||||||
|
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
||||||
|
|
||||||
|
|
||||||
@admin.register(LogfileSubmission)
|
@admin.register(LogfileSubmission)
|
||||||
@@ -23,13 +25,28 @@ class LogfileSubmissionAdmin(admin.ModelAdmin):
|
|||||||
("Processing", {"fields": ("processed",)}),
|
("Processing", {"fields": ("processed",)}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
actions = ["validate_submission"]
|
||||||
|
|
||||||
|
def validate_submission(self, request, queryset):
|
||||||
|
for logfile in queryset:
|
||||||
|
parse_objectives_and_store(logfile)
|
||||||
|
|
||||||
|
self.message_user(request, f"{queryset.count()} submissions validated.")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Objectiv)
|
@admin.register(Objectiv)
|
||||||
class ObjectivAdmin(admin.ModelAdmin):
|
class ObjectivAdmin(admin.ModelAdmin):
|
||||||
list_display = ("objectiv_id", "user", "count")
|
list_display = ("objectiv_id", "user", "first_seen_at", "get_user_objectiv_count")
|
||||||
list_filter = ("objectiv_id", "user")
|
list_filter = ("objectiv_id", "user")
|
||||||
search_fields = ("objectiv_id", "user__username")
|
search_fields = ("objectiv_id", "user__username")
|
||||||
readonly_fields = ("user",)
|
readonly_fields = ("user", "first_seen_at")
|
||||||
|
|
||||||
|
def get_user_objectiv_count(self, obj):
|
||||||
|
return Objectiv.objects.filter(
|
||||||
|
objectiv_id=obj.objectiv_id, user=obj.user
|
||||||
|
).count()
|
||||||
|
|
||||||
|
get_user_objectiv_count.short_description = "Count"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ObjectivPoint)
|
@admin.register(ObjectivPoint)
|
||||||
@@ -41,3 +58,15 @@ class ObjectivPointAdmin(admin.ModelAdmin):
|
|||||||
("Objective Information", {"fields": ("objectiv_id", "display_string")}),
|
("Objective Information", {"fields": ("objectiv_id", "display_string")}),
|
||||||
("Scoring", {"fields": ("max_count", "point")}),
|
("Scoring", {"fields": ("max_count", "point")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DeathCounter)
|
||||||
|
class DeathCounterAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user_id", "seed", "seen_at")
|
||||||
|
list_filter = ("user_id", "seed")
|
||||||
|
search_fields = ("user_id", "seed")
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
("User Information", {"fields": ("user_id",)}),
|
||||||
|
("Scoring", {"fields": ("seed",)}),
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,33 +5,29 @@ from django.db.models import (
|
|||||||
F,
|
F,
|
||||||
Case,
|
Case,
|
||||||
When,
|
When,
|
||||||
Sum,
|
|
||||||
Count,
|
Count,
|
||||||
IntegerField,
|
IntegerField,
|
||||||
Subquery,
|
Subquery,
|
||||||
OuterRef,
|
OuterRef,
|
||||||
Window,
|
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Rank
|
|
||||||
from ninja import Router, File
|
from ninja import Router, File
|
||||||
from ninja.files import UploadedFile
|
from ninja.files import UploadedFile
|
||||||
|
from ninja.decorators import decorate_view
|
||||||
|
|
||||||
from noita.schemas import ObjectivOut, ResultsOut, LeaderboardOut
|
from noita.schemas import ResultsOut, LeaderboardOut
|
||||||
from noita.services.objectives import parse_objectives_and_store
|
from noita.services.objectives import parse_objectives_and_store
|
||||||
|
from games.decorators import require_game_enabled
|
||||||
|
|
||||||
from .models import LogfileSubmission, Objectiv, ObjectivPoint
|
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
||||||
from .schemas import NoitaSubmissionOut
|
from .schemas import NoitaSubmissionOut
|
||||||
|
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
NOITA_APP_ID = 881100
|
||||||
|
|
||||||
@router.get("objectives", response=list[ObjectivOut])
|
|
||||||
def get_my_objectives(request: HttpRequest):
|
|
||||||
return Objectiv.objects.order_by("-count").filter(user=request.user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("results", response=ResultsOut)
|
@router.get("results", response=ResultsOut)
|
||||||
|
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||||
def get_results(request: HttpRequest):
|
def get_results(request: HttpRequest):
|
||||||
cache_key = f"api:noita:results:{request.user.id}"
|
cache_key = f"api:noita:results:{request.user.id}"
|
||||||
cached_data = cache.get(cache_key)
|
cached_data = cache.get(cache_key)
|
||||||
@@ -44,8 +40,15 @@ def get_results(request: HttpRequest):
|
|||||||
Calculates points as: ObjectivPoint.point * min(max_count, count) for each objective
|
Calculates points as: ObjectivPoint.point * min(max_count, count) for each objective
|
||||||
Uses Django ORM annotate for efficient queryset computation.
|
Uses Django ORM annotate for efficient queryset computation.
|
||||||
"""
|
"""
|
||||||
|
# Group objectives by objectiv_id and count occurrences
|
||||||
|
user_objectives = (
|
||||||
|
Objectiv.objects.filter(user=request.user)
|
||||||
|
.values("objectiv_id")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
)
|
||||||
|
|
||||||
# Fetch points from ObjectivPoint using Subquery
|
# Fetch points from ObjectivPoint using Subquery
|
||||||
user_objectives = Objectiv.objects.filter(user=request.user).annotate(
|
user_objectives = user_objectives.annotate(
|
||||||
# Get points per objective from ObjectivPoint
|
# Get points per objective from ObjectivPoint
|
||||||
points_per_objectiv=Subquery(
|
points_per_objectiv=Subquery(
|
||||||
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values(
|
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values(
|
||||||
@@ -81,26 +84,46 @@ def get_results(request: HttpRequest):
|
|||||||
total_points=F("points_per_objectiv") * F("capped_count"),
|
total_points=F("points_per_objectiv") * F("capped_count"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get total score
|
# Annotate seed + first-seen-at
|
||||||
total_score_result = user_objectives.aggregate(Sum("total_points"))[
|
user_objectives = user_objectives.annotate(
|
||||||
"total_points__sum"
|
seed=F("seed"),
|
||||||
]
|
first_seen_at=F("first_seen_at"),
|
||||||
total_score = total_score_result or 0
|
)
|
||||||
|
|
||||||
# Build response with all objectives
|
# Build response with all objectives and compute total score
|
||||||
objectives_with_points = [
|
total_score = 0
|
||||||
{
|
with_points = {}
|
||||||
|
for obj in ObjectivPoint.objects.all():
|
||||||
|
with_points[obj.objectiv_id] = {
|
||||||
"objectiv_id": obj.objectiv_id,
|
"objectiv_id": obj.objectiv_id,
|
||||||
"count": obj.count,
|
"display_string": obj.display_string,
|
||||||
"points_per_objectiv": obj.points_per_objectiv or 0,
|
"count": 0,
|
||||||
"total_points": obj.total_points or 0,
|
"max_count": obj.max_count,
|
||||||
|
"points_per_objectiv": obj.point,
|
||||||
|
"total_points": 0,
|
||||||
|
"first_seen_at": None,
|
||||||
|
"seed": None,
|
||||||
}
|
}
|
||||||
for obj in user_objectives.order_by("-total_points")
|
|
||||||
]
|
for obj in user_objectives.order_by("-total_points"):
|
||||||
|
points = obj["total_points"] or 0
|
||||||
|
with_points[obj["objectiv_id"]].update(
|
||||||
|
{
|
||||||
|
"count": obj["count"],
|
||||||
|
"total_points": points,
|
||||||
|
"first_seen_at": obj["first_seen_at"],
|
||||||
|
"seed": obj["seed"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
total_score += points
|
||||||
|
|
||||||
|
# Count deaths for the user
|
||||||
|
deaths_count = DeathCounter.objects.filter(user=request.user).count()
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"total_score": total_score,
|
"total_score": total_score,
|
||||||
"objectives": objectives_with_points,
|
"deaths_count": deaths_count,
|
||||||
|
"objectives": list(with_points.values()),
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.set(f"api:noita:results:{request.user.id}", data, 300)
|
cache.set(f"api:noita:results:{request.user.id}", data, 300)
|
||||||
@@ -108,6 +131,7 @@ def get_results(request: HttpRequest):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("leaderboard", response=LeaderboardOut)
|
@router.get("leaderboard", response=LeaderboardOut)
|
||||||
|
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||||
def get_leaderboard(request: HttpRequest):
|
def get_leaderboard(request: HttpRequest):
|
||||||
"""
|
"""
|
||||||
Get the global leaderboard for all users ranked by total score.
|
Get the global leaderboard for all users ranked by total score.
|
||||||
@@ -124,9 +148,11 @@ def get_leaderboard(request: HttpRequest):
|
|||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
# Get all objectives with calculated points
|
# Get all objectives with calculated points (grouped by objectiv_id and user)
|
||||||
all_objectives = (
|
all_objectives = (
|
||||||
Objectiv.objects.annotate(
|
Objectiv.objects.values("user", "objectiv_id")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.annotate(
|
||||||
# Fetch points from ObjectivPoint using Subquery
|
# Fetch points from ObjectivPoint using Subquery
|
||||||
points_per_objectiv=Subquery(
|
points_per_objectiv=Subquery(
|
||||||
ObjectivPoint.objects.filter(
|
ObjectivPoint.objects.filter(
|
||||||
@@ -161,51 +187,57 @@ def get_leaderboard(request: HttpRequest):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get user totals using subquery
|
# Build user totals by iterating through objectives
|
||||||
user_totals = (
|
user_totals_dict = {}
|
||||||
all_objectives.values("user")
|
for obj in all_objectives:
|
||||||
.annotate(total_score=Sum("total_points"))
|
user_id = obj["user"]
|
||||||
.values("user", "total_score")
|
points = obj["total_points"] or 0
|
||||||
)
|
if user_id not in user_totals_dict:
|
||||||
|
user_totals_dict[user_id] = 0
|
||||||
|
user_totals_dict[user_id] += points
|
||||||
|
|
||||||
# Get unique users and their scores, then apply ranking
|
# Get unique users and their scores, then apply ranking
|
||||||
leaderboard = (
|
users_with_scores = []
|
||||||
User.objects.filter(objectiv__isnull=False)
|
for user_id, total_score in user_totals_dict.items():
|
||||||
.distinct()
|
user = User.objects.get(id=user_id)
|
||||||
.annotate(
|
objectives_count = (
|
||||||
total_score=Subquery(
|
Objectiv.objects.filter(user_id=user_id)
|
||||||
user_totals.filter(user=OuterRef("id")).values("total_score")[:1],
|
.values("objectiv_id")
|
||||||
output_field=IntegerField(),
|
.distinct()
|
||||||
)
|
.count()
|
||||||
)
|
)
|
||||||
.annotate(objectives_count=Count("objectiv", distinct=True))
|
deaths_count = DeathCounter.objects.filter(user_id=user_id).count()
|
||||||
.annotate(
|
users_with_scores.append(
|
||||||
rank=Window(
|
|
||||||
expression=Rank(),
|
|
||||||
order_by=F("total_score").desc(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.values("rank", "username", "total_score", "objectives_count")
|
|
||||||
.order_by("rank")
|
|
||||||
)
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"leaderboard": [
|
|
||||||
{
|
{
|
||||||
"rank": entry["rank"],
|
"user_id": user_id,
|
||||||
"username": entry["username"],
|
"user": user,
|
||||||
"total_score": entry["total_score"] or 0,
|
"total_score": total_score,
|
||||||
"objectives_count": entry["objectives_count"],
|
"objectives_count": objectives_count,
|
||||||
|
"deaths_count": deaths_count,
|
||||||
}
|
}
|
||||||
for entry in leaderboard
|
)
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
# Sort by score and add rank
|
||||||
|
users_with_scores.sort(key=lambda x: x["total_score"], reverse=True)
|
||||||
|
leaderboard = [
|
||||||
|
{
|
||||||
|
"rank": idx + 1,
|
||||||
|
"username": entry["user"].username,
|
||||||
|
"is_staff": entry["user"].is_staff,
|
||||||
|
"total_score": entry["total_score"],
|
||||||
|
"objectives_count": entry["objectives_count"],
|
||||||
|
"deaths_count": entry["deaths_count"],
|
||||||
|
}
|
||||||
|
for idx, entry in enumerate(users_with_scores)
|
||||||
|
]
|
||||||
|
|
||||||
|
data = {"leaderboard": leaderboard}
|
||||||
cache.set("api:noita:leaderboard", data, 300)
|
cache.set("api:noita:leaderboard", data, 300)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@router.post("submit", response={200: NoitaSubmissionOut, 400: dict})
|
@router.post("submit", response={200: NoitaSubmissionOut, 400: dict})
|
||||||
|
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||||
def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
|
def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
|
||||||
"""
|
"""
|
||||||
Submit a Noita run file (log file, screenshot, or video).
|
Submit a Noita run file (log file, screenshot, or video).
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from noita.models import ObjectivPoint
|
from noita.models import ObjectivPoint
|
||||||
from noita.services.decode import POINTS
|
from noita.services.decode import POINTS
|
||||||
|
from noita.services.spells import ALL_PERKS, ALL_SPELLS
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -55,6 +56,20 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for op in ObjectivPoint.objects.filter(objectiv_id__in=ALL_SPELLS):
|
||||||
|
op.display_string = f"Spell: {op.display_string}"
|
||||||
|
op.save()
|
||||||
|
|
||||||
|
for op in ObjectivPoint.objects.filter(objectiv_id__in=ALL_PERKS):
|
||||||
|
op.display_string = f"Perk: {op.display_string}"
|
||||||
|
op.save()
|
||||||
|
|
||||||
|
for op in ObjectivPoint.objects.filter(objectiv_id__startswith="BOSS_KILL_"):
|
||||||
|
b, k, c = op.display_string.split(" ")
|
||||||
|
op.display_string = f"Perk: {op.display_string}"
|
||||||
|
op.display_string = f"{b} {k} (with {c} orbs)"
|
||||||
|
op.save()
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"\nCreated: {created_count}"))
|
self.stdout.write(self.style.SUCCESS(f"\nCreated: {created_count}"))
|
||||||
self.stdout.write(self.style.SUCCESS(f"Updated: {updated_count}"))
|
self.stdout.write(self.style.SUCCESS(f"Updated: {updated_count}"))
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
|
|||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-11 08:20
|
||||||
|
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0006_objectivpoint"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="count",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="first_seen_at",
|
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="objectiv",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("objectiv_id", "user", "first_seen_at"),
|
||||||
|
name="unique_objectiv_per_user_timestamp",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-11 08:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0007_remove_objectiv_count_objectiv_first_seen_at_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="seed",
|
||||||
|
field=models.CharField(default="", max_length=32),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-11 08:31
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0008_objectiv_seed"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="submission",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
default=1,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="noita.logfilesubmission",
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-14 23:36
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def fw_func(apps, _schema_editor):
|
||||||
|
Objectiv = apps.get_model("noita", "Objectiv")
|
||||||
|
Objectiv.objects.filter(objectiv_id="DEATH").delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0009_objectiv_submission"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DeathCounter",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("seed", models.CharField(max_length=32)),
|
||||||
|
("seen_at", models.DateTimeField()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.RunPython(fw_func, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-14 23:43
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0010_deathcounter"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="deathcounter",
|
||||||
|
name="user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
default=1,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-14 23:45
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0011_deathcounter_user"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="deathcounter",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("user_id", "seen_at"), name="unique_death_per_seen_at"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -49,7 +50,17 @@ class Objectiv(models.Model):
|
|||||||
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
|
||||||
count = models.IntegerField(default=1)
|
first_seen_at = models.DateTimeField(default=timezone.now)
|
||||||
|
seed = models.CharField(max_length=32)
|
||||||
|
submission = models.ForeignKey("LogfileSubmission", on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["objectiv_id", "user", "first_seen_at"],
|
||||||
|
name="unique_objectiv_per_user_timestamp",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ObjectivPoint(models.Model):
|
class ObjectivPoint(models.Model):
|
||||||
@@ -57,3 +68,17 @@ class ObjectivPoint(models.Model):
|
|||||||
display_string = models.CharField(max_length=255)
|
display_string = models.CharField(max_length=255)
|
||||||
max_count = models.IntegerField(default=1)
|
max_count = models.IntegerField(default=1)
|
||||||
point = models.IntegerField(default=0)
|
point = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class DeathCounter(models.Model):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
seed = models.CharField(max_length=32)
|
||||||
|
seen_at = models.DateTimeField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user_id", "seen_at"],
|
||||||
|
name="unique_death_per_seen_at",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from ninja import Schema, ModelSchema
|
from ninja import Schema
|
||||||
|
|
||||||
from noita.models import Objectiv
|
|
||||||
|
|
||||||
|
|
||||||
class ObjectivOut(ModelSchema):
|
|
||||||
class Meta:
|
|
||||||
model = Objectiv
|
|
||||||
fields = ["objectiv_id", "count"]
|
|
||||||
|
|
||||||
|
|
||||||
class NoitaSubmissionOut(Schema):
|
class NoitaSubmissionOut(Schema):
|
||||||
@@ -23,21 +15,28 @@ class NoitaSubmissionOut(Schema):
|
|||||||
|
|
||||||
class ObjectivResultOut(Schema):
|
class ObjectivResultOut(Schema):
|
||||||
objectiv_id: str
|
objectiv_id: str
|
||||||
|
display_string: str
|
||||||
|
first_seen_at: datetime | None
|
||||||
count: int
|
count: int
|
||||||
|
max_count: int
|
||||||
|
seed: str | None
|
||||||
points_per_objectiv: int
|
points_per_objectiv: int
|
||||||
total_points: int
|
total_points: int | None
|
||||||
|
|
||||||
|
|
||||||
class ResultsOut(Schema):
|
class ResultsOut(Schema):
|
||||||
total_score: int
|
total_score: int
|
||||||
|
deaths_count: int
|
||||||
objectives: list[ObjectivResultOut]
|
objectives: list[ObjectivResultOut]
|
||||||
|
|
||||||
|
|
||||||
class LeaderboardEntryOut(Schema):
|
class LeaderboardEntryOut(Schema):
|
||||||
rank: int
|
rank: int
|
||||||
username: str
|
username: str
|
||||||
|
is_staff: bool
|
||||||
total_score: int
|
total_score: int
|
||||||
objectives_count: int
|
objectives_count: int
|
||||||
|
deaths_count: int
|
||||||
|
|
||||||
|
|
||||||
class LeaderboardOut(Schema):
|
class LeaderboardOut(Schema):
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ POINTS = {
|
|||||||
"NOLLA": 10,
|
"NOLLA": 10,
|
||||||
"CHAOTIC_TRANSMUTATION": 10,
|
"CHAOTIC_TRANSMUTATION": 10,
|
||||||
"DUPLICATE": 5,
|
"DUPLICATE": 5,
|
||||||
"OMEGA": 10,
|
|
||||||
"BURST_2": 10,
|
"BURST_2": 10,
|
||||||
"BURST_3": 15,
|
"BURST_3": 15,
|
||||||
"BURST_4": 20,
|
"BURST_4": 20,
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
from noita.models import LogfileSubmission, Objectiv
|
from noita.models import LogfileSubmission, Objectiv, DeathCounter
|
||||||
from noita.services.decode import parse_log, resolve
|
from noita.services.decode import parse_log, resolve
|
||||||
|
|
||||||
|
|
||||||
from collections import Counter
|
def parse_objectives_from_logfile(
|
||||||
|
logfile: LogfileSubmission,
|
||||||
|
) -> list[tuple[str, str, str]]:
|
||||||
def parse_objectives_from_logfile(logfile: LogfileSubmission) -> Counter:
|
|
||||||
"""Parse a log file, and output a count for each ID."""
|
"""Parse a log file, and output a count for each ID."""
|
||||||
file_data = logfile.file.read().decode()
|
file_data = logfile.file.read().decode()
|
||||||
|
|
||||||
ids = []
|
entries: list[tuple[str, str, str]] = []
|
||||||
for entry in parse_log(file_data):
|
for entry in parse_log(file_data):
|
||||||
idx, _seed = resolve(entry["hash"], entry["ts"])
|
idx, seed = resolve(entry["hash"], entry["ts"])
|
||||||
|
|
||||||
if idx:
|
if idx and seed:
|
||||||
ids.append(idx)
|
entries.append((idx, str(seed), entry["ts"]))
|
||||||
|
|
||||||
return Counter(ids)
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
||||||
@@ -25,16 +24,31 @@ def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
|||||||
if not logfile.user:
|
if not logfile.user:
|
||||||
return
|
return
|
||||||
|
|
||||||
counter = parse_objectives_from_logfile(logfile)
|
objectives = []
|
||||||
|
deaths = []
|
||||||
for idx, count in counter.items():
|
for idx, seed, ts in parse_objectives_from_logfile(logfile):
|
||||||
if idx in {"-", "DEBUG", "polylan-mod"}:
|
if idx in {"-", "DEBUG", "polylan-mod"}:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
obj, created = Objectiv.objects.get_or_create(
|
if idx == "DEATH":
|
||||||
objectiv_id=idx,
|
deaths.append(DeathCounter(user=logfile.user, seed=seed, seen_at=ts))
|
||||||
user=logfile.user,
|
continue
|
||||||
|
|
||||||
|
objectives.append(
|
||||||
|
Objectiv(
|
||||||
|
objectiv_id=idx,
|
||||||
|
user=logfile.user,
|
||||||
|
first_seen_at=ts,
|
||||||
|
seed=seed,
|
||||||
|
submission=logfile,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
obj.count += count
|
Objectiv.objects.bulk_create(
|
||||||
obj.save(update_fields=["count"])
|
objectives,
|
||||||
|
update_conflicts=True,
|
||||||
|
update_fields=["seed", "submission"],
|
||||||
|
unique_fields=["objectiv_id", "user", "first_seen_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
DeathCounter.objects.bulk_create(deaths, ignore_conflicts=True)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from '@hey-api/openapi-ts';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
input: `http://localhost:7777/api/openapi.json`,
|
||||||
|
output: 'src/api/',
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from submissions.models import (
|
from opus_magnum.models import (
|
||||||
SteamAPIKey,
|
SteamAPIKey,
|
||||||
SteamCollection,
|
SteamCollection,
|
||||||
SteamCollectionItem,
|
SteamCollectionItem,
|
||||||
@@ -81,6 +81,7 @@ class SteamCollectionAdmin(admin.ModelAdmin):
|
|||||||
"current_favorites",
|
"current_favorites",
|
||||||
"last_fetched",
|
"last_fetched",
|
||||||
"is_active",
|
"is_active",
|
||||||
|
"accepting_submissions",
|
||||||
]
|
]
|
||||||
list_filter = ["is_active", "last_fetched", "created_at"]
|
list_filter = ["is_active", "last_fetched", "created_at"]
|
||||||
search_fields = ["title", "steam_id", "author_name", "description"]
|
search_fields = ["title", "steam_id", "author_name", "description"]
|
||||||
@@ -115,7 +116,7 @@ class SteamCollectionAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
("Status", {"fields": ("fetch_error",)}),
|
("Status", {"fields": ("fetch_error", "accepting_submissions")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from ninja import Router, File
|
from ninja import Router, File
|
||||||
from ninja.files import UploadedFile
|
from ninja.files import UploadedFile
|
||||||
from ninja.pagination import paginate
|
from ninja.pagination import paginate
|
||||||
|
from ninja.errors import HttpError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
@@ -8,21 +9,32 @@ from django.utils import timezone
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from submissions.utils import verify_and_validate_ocr_date_for_submission
|
from games.decorators import require_game_enabled
|
||||||
|
from opus_magnum.utils import verify_and_validate_ocr_date_for_submission
|
||||||
|
from ninja.decorators import decorate_view
|
||||||
|
|
||||||
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem
|
from .models import (
|
||||||
|
Submission,
|
||||||
|
PuzzleResponse,
|
||||||
|
SubmissionFile,
|
||||||
|
SteamCollectionItem,
|
||||||
|
SteamCollection,
|
||||||
|
)
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
SubmissionIn,
|
SubmissionIn,
|
||||||
SubmissionOut,
|
SubmissionOut,
|
||||||
PuzzleResponseOut,
|
PuzzleResponseOut,
|
||||||
ValidationIn,
|
ValidationIn,
|
||||||
SteamCollectionItemOut,
|
SteamCollectionItemOut,
|
||||||
|
SteamCollectionOut,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
OPUS_MAGNUM_APP_ID = 558990
|
||||||
|
|
||||||
|
|
||||||
@router.get("/puzzles", response=List[SteamCollectionItemOut])
|
@router.get("/puzzles", response=List[SteamCollectionItemOut])
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def list_puzzles(request):
|
def list_puzzles(request):
|
||||||
"""Get list of available puzzles"""
|
"""Get list of available puzzles"""
|
||||||
return SteamCollectionItem.objects.select_related("collection").filter(
|
return SteamCollectionItem.objects.select_related("collection").filter(
|
||||||
@@ -30,7 +42,16 @@ def list_puzzles(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/collection", response=SteamCollectionOut)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
|
def get_collection(request):
|
||||||
|
"""Get the active collection details"""
|
||||||
|
collection = get_object_or_404(SteamCollection, is_active=True)
|
||||||
|
return collection
|
||||||
|
|
||||||
|
|
||||||
@router.get("/submissions", response=List[SubmissionOut])
|
@router.get("/submissions", response=List[SubmissionOut])
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
@paginate
|
@paginate
|
||||||
def list_submissions(request):
|
def list_submissions(request):
|
||||||
"""Get paginated list of submissions"""
|
"""Get paginated list of submissions"""
|
||||||
@@ -40,6 +61,7 @@ def list_submissions(request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/submissions/{submission_id}", response=SubmissionOut)
|
@router.get("/submissions/{submission_id}", response=SubmissionOut)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def get_submission(request, submission_id: str):
|
def get_submission(request, submission_id: str):
|
||||||
"""Get detailed submission by ID"""
|
"""Get detailed submission by ID"""
|
||||||
return get_object_or_404(
|
return get_object_or_404(
|
||||||
@@ -51,6 +73,7 @@ def get_submission(request, submission_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/submissions", response=SubmissionOut)
|
@router.post("/submissions", response=SubmissionOut)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def create_submission(
|
def create_submission(
|
||||||
request, data: SubmissionIn, files: List[UploadedFile] = File(...)
|
request, data: SubmissionIn, files: List[UploadedFile] = File(...)
|
||||||
):
|
):
|
||||||
@@ -65,7 +88,15 @@ def create_submission(
|
|||||||
if len(files) < len(data.responses):
|
if len(files) < len(data.responses):
|
||||||
return 400, {"detail": "Not enough files for all responses"}
|
return 400, {"detail": "Not enough files for all responses"}
|
||||||
|
|
||||||
print(data, files)
|
# Check if collection is accepting submissions
|
||||||
|
if data.responses:
|
||||||
|
for puzzle in data.responses:
|
||||||
|
if not get_object_or_404(
|
||||||
|
SteamCollectionItem, id=puzzle.puzzle_id
|
||||||
|
).collection.accepting_submissions:
|
||||||
|
raise HttpError(
|
||||||
|
403, "This tournament is no longer accepting submissions"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@@ -126,7 +157,6 @@ def create_submission(
|
|||||||
# Process files for this response
|
# Process files for this response
|
||||||
# For simplicity, we'll take one file per response
|
# For simplicity, we'll take one file per response
|
||||||
# In a real implementation, you'd need better file-to-response mapping
|
# In a real implementation, you'd need better file-to-response mapping
|
||||||
print("FI", file_index, files)
|
|
||||||
if file_index < len(files):
|
if file_index < len(files):
|
||||||
uploaded_file = files[file_index]
|
uploaded_file = files[file_index]
|
||||||
|
|
||||||
@@ -172,11 +202,11 @@ def create_submission(
|
|||||||
return submission
|
return submission
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
|
||||||
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/responses/{response_id}/validate", response=PuzzleResponseOut)
|
@router.put("/responses/{response_id}/validate", response=PuzzleResponseOut)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def validate_response(request, response_id: int, data: ValidationIn):
|
def validate_response(request, response_id: int, data: ValidationIn):
|
||||||
"""Manually validate a puzzle response"""
|
"""Manually validate a puzzle response"""
|
||||||
|
|
||||||
@@ -212,6 +242,7 @@ def validate_response(request, response_id: int, data: ValidationIn):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/responses/{response_id}/validate/auto", response=PuzzleResponseOut)
|
@router.put("/responses/{response_id}/validate/auto", response=PuzzleResponseOut)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def validate_auto(request, response_id: int):
|
def validate_auto(request, response_id: int):
|
||||||
"""Try to auto validate a puzzle response"""
|
"""Try to auto validate a puzzle response"""
|
||||||
|
|
||||||
@@ -227,6 +258,7 @@ def validate_auto(request, response_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/responses/needs-validation", response=List[PuzzleResponseOut])
|
@router.get("/responses/needs-validation", response=List[PuzzleResponseOut])
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def list_responses_needing_validation(request):
|
def list_responses_needing_validation(request):
|
||||||
"""Get all responses that need manual validation"""
|
"""Get all responses that need manual validation"""
|
||||||
|
|
||||||
@@ -242,6 +274,7 @@ def list_responses_needing_validation(request):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/submissions/{submission_id}/validate", response=SubmissionOut)
|
@router.post("/submissions/{submission_id}/validate", response=SubmissionOut)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def validate_submission(request, submission_id: str):
|
def validate_submission(request, submission_id: str):
|
||||||
"""Mark entire submission as validated"""
|
"""Mark entire submission as validated"""
|
||||||
|
|
||||||
@@ -270,6 +303,7 @@ def validate_submission(request, submission_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/submissions/{submission_id}")
|
@router.delete("/submissions/{submission_id}")
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def delete_submission(request, submission_id: str):
|
def delete_submission(request, submission_id: str):
|
||||||
"""Delete a submission (admin only)"""
|
"""Delete a submission (admin only)"""
|
||||||
|
|
||||||
@@ -286,6 +320,7 @@ def delete_submission(request, submission_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def get_stats(request):
|
def get_stats(request):
|
||||||
"""Get submission statistics"""
|
"""Get submission statistics"""
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class OpusMagnumConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "opus_magnum"
|
||||||
+3
-3
@@ -3,8 +3,8 @@ Django management command to fetch Steam Workshop collections
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from submissions.utils import create_or_update_collection
|
from opus_magnum.utils import create_or_update_collection
|
||||||
from submissions.models import SteamAPIKey, SteamCollection
|
from opus_magnum.models import SteamAPIKey, SteamCollection
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -34,7 +34,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if collection already exists
|
# Check if collection already exists
|
||||||
from submissions.utils import SteamCollectionFetcher
|
from opus_magnum.utils import SteamCollectionFetcher
|
||||||
|
|
||||||
fetcher = SteamCollectionFetcher(api_key.api_key)
|
fetcher = SteamCollectionFetcher(api_key.api_key)
|
||||||
collection_id = fetcher.extract_collection_id(url)
|
collection_id = fetcher.extract_collection_id(url)
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from submissions.utils import verify_and_validate_ocr_date_for_submission
|
from opus_magnum.utils import verify_and_validate_ocr_date_for_submission
|
||||||
from submissions.models import SubmissionFile
|
from opus_magnum.models import SubmissionFile
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
+1
-1
@@ -205,7 +205,7 @@ class Migration(migrations.Migration):
|
|||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
related_name="items",
|
related_name="items",
|
||||||
to="submissions.steamcollection",
|
to="opus_magnum.steamcollection",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
+1
-1
@@ -5,7 +5,7 @@ from django.db import migrations
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0001_initial"),
|
("opus_magnum", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+1
-1
@@ -5,7 +5,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0002_delete_collection"),
|
("opus_magnum", "0002_delete_collection"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+6
-6
@@ -1,7 +1,7 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-29 01:32
|
# Generated by Django 5.2.7 on 2025-10-29 01:32
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import submissions.models
|
import opus_magnum.models
|
||||||
import uuid
|
import uuid
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
@@ -9,7 +9,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0003_steamapikey"),
|
("opus_magnum", "0003_steamapikey"),
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ class Migration(migrations.Migration):
|
|||||||
help_text="The puzzle this response is for",
|
help_text="The puzzle this response is for",
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
related_name="responses",
|
related_name="responses",
|
||||||
to="submissions.steamcollectionitem",
|
to="opus_magnum.steamcollectionitem",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -168,7 +168,7 @@ class Migration(migrations.Migration):
|
|||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
related_name="responses",
|
related_name="responses",
|
||||||
to="submissions.submission",
|
to="opus_magnum.submission",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -195,7 +195,7 @@ class Migration(migrations.Migration):
|
|||||||
"file",
|
"file",
|
||||||
models.FileField(
|
models.FileField(
|
||||||
help_text="Uploaded file (image/gif)",
|
help_text="Uploaded file (image/gif)",
|
||||||
upload_to=submissions.models.submission_file_upload_path,
|
upload_to=opus_magnum.models.submission_file_upload_path,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -239,7 +239,7 @@ class Migration(migrations.Migration):
|
|||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
related_name="files",
|
related_name="files",
|
||||||
to="submissions.puzzleresponse",
|
to="opus_magnum.puzzleresponse",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
+1
-1
@@ -5,7 +5,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0004_submission_puzzleresponse_submissionfile"),
|
("opus_magnum", "0004_submission_puzzleresponse_submissionfile"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+1
-1
@@ -5,7 +5,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0005_alter_submission_notes"),
|
("opus_magnum", "0005_alter_submission_notes"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+1
-1
@@ -5,7 +5,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0006_remove_puzzleresponse_ocr_confidence_score_and_more"),
|
("opus_magnum", "0006_remove_puzzleresponse_ocr_confidence_score_and_more"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+1
-1
@@ -5,7 +5,7 @@ from django.db import migrations
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0007_submission_manual_validation_requested"),
|
("opus_magnum", "0007_submission_manual_validation_requested"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+1
-1
@@ -7,7 +7,7 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("animations", "0001_initial"),
|
("animations", "0001_initial"),
|
||||||
("submissions", "0008_alter_puzzleresponse_unique_together"),
|
("opus_magnum", "0008_alter_puzzleresponse_unique_together"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+1
-1
@@ -5,7 +5,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0009_steamcollectionitem_points_factor"),
|
("opus_magnum", "0009_steamcollectionitem_points_factor"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+1
-1
@@ -5,7 +5,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0010_alter_puzzleresponse_validated_area_and_more"),
|
("opus_magnum", "0010_alter_puzzleresponse_validated_area_and_more"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+1
-1
@@ -6,7 +6,7 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
(
|
(
|
||||||
"submissions",
|
"opus_magnum",
|
||||||
"0011_alter_puzzleresponse_area_alter_puzzleresponse_cost_and_more",
|
"0011_alter_puzzleresponse_area_alter_puzzleresponse_cost_and_more",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
+1
-1
@@ -7,7 +7,7 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("animations", "0002_puzzlepointsvalue"),
|
("animations", "0002_puzzlepointsvalue"),
|
||||||
("submissions", "0012_alter_puzzleresponse_validated_area_and_more"),
|
("opus_magnum", "0012_alter_puzzleresponse_validated_area_and_more"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-21 10:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("opus_magnum", "0013_steamcollectionitem_points_value"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="steamcollection",
|
||||||
|
name="accepting_submissions",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Whether the tournament is accepting new submissions",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Data migration: Copy all data from submissions app tables to opus_magnum app tables
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_data_forward(apps, schema_editor):
|
||||||
|
"""Copy data from submissions_* tables to opus_magnum_* tables"""
|
||||||
|
# Use raw SQL to copy data while preserving all fields
|
||||||
|
with schema_editor.connection.cursor() as cursor:
|
||||||
|
# Copy SteamAPIKey
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO opus_magnum_steamapikey
|
||||||
|
SELECT * FROM submissions_steamapikey
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Copy SteamCollection
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO opus_magnum_steamcollection
|
||||||
|
SELECT * FROM submissions_steamcollection
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Copy SteamCollectionItem
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO opus_magnum_steamcollectionitem
|
||||||
|
SELECT * FROM submissions_steamcollectionitem
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Copy Submission
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO opus_magnum_submission
|
||||||
|
SELECT * FROM submissions_submission
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Copy PuzzleResponse
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO opus_magnum_puzzleresponse
|
||||||
|
SELECT * FROM submissions_puzzleresponse
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Copy SubmissionFile
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO opus_magnum_submissionfile
|
||||||
|
SELECT * FROM submissions_submissionfile
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_data_backward(apps, schema_editor):
|
||||||
|
"""Delete all data from opus_magnum_* tables"""
|
||||||
|
with schema_editor.connection.cursor() as cursor:
|
||||||
|
# Delete in reverse order of foreign key dependencies
|
||||||
|
cursor.execute("DELETE FROM opus_magnum_submissionfile")
|
||||||
|
cursor.execute("DELETE FROM opus_magnum_puzzleresponse")
|
||||||
|
cursor.execute("DELETE FROM opus_magnum_submission")
|
||||||
|
cursor.execute("DELETE FROM opus_magnum_steamcollectionitem")
|
||||||
|
cursor.execute("DELETE FROM opus_magnum_steamcollection")
|
||||||
|
cursor.execute("DELETE FROM opus_magnum_steamapikey")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("opus_magnum", "0014_steamcollection_accepting_submissions"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(migrate_data_forward, migrate_data_backward),
|
||||||
|
]
|
||||||
@@ -157,6 +157,9 @@ class SteamCollection(models.Model):
|
|||||||
is_active = models.BooleanField(
|
is_active = models.BooleanField(
|
||||||
default=True, help_text="Whether this collection is actively tracked"
|
default=True, help_text="Whether this collection is actively tracked"
|
||||||
)
|
)
|
||||||
|
accepting_submissions = models.BooleanField(
|
||||||
|
default=True, help_text="Whether the tournament is accepting new submissions"
|
||||||
|
)
|
||||||
fetch_error = models.TextField(
|
fetch_error = models.TextField(
|
||||||
blank=True, help_text="Last error encountered when fetching data"
|
blank=True, help_text="Last error encountered when fetching data"
|
||||||
)
|
)
|
||||||
+47
-1
@@ -3,7 +3,13 @@ from typing import List, Optional
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem
|
from .models import (
|
||||||
|
Submission,
|
||||||
|
PuzzleResponse,
|
||||||
|
SubmissionFile,
|
||||||
|
SteamCollectionItem,
|
||||||
|
SteamCollection,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Input Schemas
|
# Input Schemas
|
||||||
@@ -132,10 +138,39 @@ class ValidationIn(Schema):
|
|||||||
|
|
||||||
|
|
||||||
# Collection Schemas
|
# Collection Schemas
|
||||||
|
class PuzzlePointsFactorOut(Schema):
|
||||||
|
"""Schema for puzzle points factor output"""
|
||||||
|
|
||||||
|
cost: int
|
||||||
|
cycles: int
|
||||||
|
area: int
|
||||||
|
|
||||||
|
|
||||||
|
class SteamCollectionOut(ModelSchema):
|
||||||
|
"""Schema for Steam collection output"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = SteamCollection
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"steam_id",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"author_name",
|
||||||
|
"total_items",
|
||||||
|
"unique_visitors",
|
||||||
|
"current_favorites",
|
||||||
|
"accepting_submissions",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class SteamCollectionItemOut(ModelSchema):
|
class SteamCollectionItemOut(ModelSchema):
|
||||||
"""Schema for Steam collection item output"""
|
"""Schema for Steam collection item output"""
|
||||||
|
|
||||||
steam_url: str
|
steam_url: str
|
||||||
|
points_factor: Optional[PuzzlePointsFactorOut] = None
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SteamCollectionItem
|
model = SteamCollectionItem
|
||||||
@@ -151,6 +186,16 @@ class SteamCollectionItemOut(ModelSchema):
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_points_factor(obj) -> Optional[PuzzlePointsFactorOut]:
|
||||||
|
if obj.points_factor:
|
||||||
|
return PuzzlePointsFactorOut(
|
||||||
|
cost=obj.points_factor.cost,
|
||||||
|
cycles=obj.points_factor.cycles,
|
||||||
|
area=obj.points_factor.area,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Error Schemas
|
# Error Schemas
|
||||||
class ErrorOut(Schema):
|
class ErrorOut(Schema):
|
||||||
@@ -176,6 +221,7 @@ class UserInfoOut(Schema):
|
|||||||
first_name: Optional[str] = None
|
first_name: Optional[str] = None
|
||||||
last_name: Optional[str] = None
|
last_name: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
|
points: int = 0
|
||||||
is_authenticated: bool
|
is_authenticated: bool
|
||||||
is_staff: bool
|
is_staff: bool
|
||||||
is_superuser: bool
|
is_superuser: bool
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Create your tests here.
|
||||||
@@ -4,7 +4,7 @@ Utilities for fetching Steam Workshop collection data using Steam Web API
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import requests
|
import requests
|
||||||
from submissions.models import SteamCollection, SteamCollectionItem, SubmissionFile
|
from opus_magnum.models import SteamCollection, SteamCollectionItem, SubmissionFile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -7,12 +7,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"schema": "openapi-ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vueuse/core": "^14.0.0",
|
"@vueuse/core": "^14.0.0",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
"install": "^0.13.0",
|
"install": "^0.13.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
"vue": "^3.5.22"
|
"vue": "^3.5.22"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@hey-api/openapi-ts": "^0.97.2",
|
||||||
"@mdi/font": "^7.4.47",
|
"@mdi/font": "^7.4.47",
|
||||||
"@types/node": "^24.6.0",
|
"@types/node": "^24.6.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
|||||||
Generated
+390
@@ -17,6 +17,9 @@ importers:
|
|||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^14.0.0
|
specifier: ^14.0.0
|
||||||
version: 14.0.0(vue@3.5.22(typescript@5.9.3))
|
version: 14.0.0(vue@3.5.22(typescript@5.9.3))
|
||||||
|
dayjs:
|
||||||
|
specifier: ^1.11.20
|
||||||
|
version: 1.11.20
|
||||||
install:
|
install:
|
||||||
specifier: ^0.13.0
|
specifier: ^0.13.0
|
||||||
version: 0.13.0
|
version: 0.13.0
|
||||||
@@ -33,6 +36,9 @@ importers:
|
|||||||
specifier: ^3.5.22
|
specifier: ^3.5.22
|
||||||
version: 3.5.22(typescript@5.9.3)
|
version: 3.5.22(typescript@5.9.3)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@hey-api/openapi-ts':
|
||||||
|
specifier: ^0.97.2
|
||||||
|
version: 0.97.2(typescript@5.9.3)
|
||||||
'@mdi/font':
|
'@mdi/font':
|
||||||
specifier: ^7.4.47
|
specifier: ^7.4.47
|
||||||
version: 7.4.47
|
version: 7.4.47
|
||||||
@@ -233,6 +239,31 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@hey-api/codegen-core@0.8.1':
|
||||||
|
resolution: {integrity: sha512-Iciv2vUCJTW9lWM/ROvyZLblmcbYJHPuXfzb1SzeDVVn4xEXu2ilLU1pq3fn+09FZ/Y0P7VyvRE47UDU6om8xA==}
|
||||||
|
engines: {node: '>=22.13.0'}
|
||||||
|
|
||||||
|
'@hey-api/json-schema-ref-parser@1.4.2':
|
||||||
|
resolution: {integrity: sha512-ZhCFSKI2ipZHEbgmtUHdyddvRU3wJ4elgCfYUC7T7hZa4EivSrVflTQf2w+v3TuaYxR1Y2V2kq3otqTttrrK8Q==}
|
||||||
|
engines: {node: '>=22.13.0'}
|
||||||
|
|
||||||
|
'@hey-api/openapi-ts@0.97.2':
|
||||||
|
resolution: {integrity: sha512-nA+y0/I5O9loQMeJKumi6BQ40/Y71N0hIMmXZ/I7rh8jEOzYxSxmf5a4TBEI2Ap4RAfZyh7RJzJfVzT98KUYQQ==}
|
||||||
|
engines: {node: '>=22.13.0'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '>=5.5.3 || >=6.0.0 || 6.0.1-rc'
|
||||||
|
|
||||||
|
'@hey-api/shared@0.4.4':
|
||||||
|
resolution: {integrity: sha512-UZgaQNEdo/OSGLeNXhSv0VQTHQQm5Q2mHOuoYhFPJkNvLVrz7KZtGdKR8O4QPrhyblshxY+caJli08WKM0gREg==}
|
||||||
|
engines: {node: '>=22.13.0'}
|
||||||
|
|
||||||
|
'@hey-api/spec-types@0.2.0':
|
||||||
|
resolution: {integrity: sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==}
|
||||||
|
|
||||||
|
'@hey-api/types@0.1.4':
|
||||||
|
resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
|
||||||
@@ -249,6 +280,13 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@jsdevtools/ono@7.1.3':
|
||||||
|
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
|
||||||
|
|
||||||
|
'@lukeed/ms@2.0.2':
|
||||||
|
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
'@mdi/font@7.4.47':
|
'@mdi/font@7.4.47':
|
||||||
resolution: {integrity: sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==}
|
resolution: {integrity: sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==}
|
||||||
|
|
||||||
@@ -468,6 +506,9 @@ packages:
|
|||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@types/json-schema@7.0.15':
|
||||||
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
'@types/node@24.9.2':
|
'@types/node@24.9.2':
|
||||||
resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==}
|
resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==}
|
||||||
|
|
||||||
@@ -563,26 +604,89 @@ packages:
|
|||||||
alien-signals@3.0.3:
|
alien-signals@3.0.3:
|
||||||
resolution: {integrity: sha512-2JXjom6R7ZwrISpUphLhf4htUq1aKRCennTJ6u9kFfr3sLmC9+I4CxxVi+McoFnIg+p1HnVrfLT/iCt4Dlz//Q==}
|
resolution: {integrity: sha512-2JXjom6R7ZwrISpUphLhf4htUq1aKRCennTJ6u9kFfr3sLmC9+I4CxxVi+McoFnIg+p1HnVrfLT/iCt4Dlz//Q==}
|
||||||
|
|
||||||
|
ansi-colors@4.1.3:
|
||||||
|
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
argparse@2.0.1:
|
||||||
|
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||||
|
|
||||||
birpc@2.6.1:
|
birpc@2.6.1:
|
||||||
resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==}
|
resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==}
|
||||||
|
|
||||||
bmp-js@0.1.0:
|
bmp-js@0.1.0:
|
||||||
resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==}
|
resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==}
|
||||||
|
|
||||||
|
bundle-name@4.1.0:
|
||||||
|
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
c12@3.3.4:
|
||||||
|
resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==}
|
||||||
|
peerDependencies:
|
||||||
|
magicast: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
magicast:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
chokidar@5.0.0:
|
||||||
|
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
||||||
|
engines: {node: '>= 20.19.0'}
|
||||||
|
|
||||||
|
color-support@1.1.3:
|
||||||
|
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
commander@14.0.3:
|
||||||
|
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
confbox@0.2.4:
|
||||||
|
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
|
||||||
|
|
||||||
copy-anything@4.0.5:
|
copy-anything@4.0.5:
|
||||||
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
|
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
cross-spawn@7.0.6:
|
||||||
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
csstype@3.1.3:
|
csstype@3.1.3:
|
||||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||||
|
|
||||||
daisyui@5.3.10:
|
daisyui@5.3.10:
|
||||||
resolution: {integrity: sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==}
|
resolution: {integrity: sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==}
|
||||||
|
|
||||||
|
dayjs@1.11.20:
|
||||||
|
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
|
||||||
|
|
||||||
|
default-browser-id@5.0.1:
|
||||||
|
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
default-browser@5.5.0:
|
||||||
|
resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
define-lazy-prop@3.0.0:
|
||||||
|
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
defu@6.1.7:
|
||||||
|
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
|
||||||
|
|
||||||
|
destr@2.0.5:
|
||||||
|
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||||
|
|
||||||
detect-libc@2.1.2:
|
detect-libc@2.1.2:
|
||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
dotenv@17.4.2:
|
||||||
|
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
enhanced-resolve@5.18.3:
|
enhanced-resolve@5.18.3:
|
||||||
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -599,6 +703,9 @@ packages:
|
|||||||
estree-walker@2.0.2:
|
estree-walker@2.0.2:
|
||||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||||
|
|
||||||
|
exsolve@1.0.8:
|
||||||
|
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
|
||||||
|
|
||||||
fdir@6.5.0:
|
fdir@6.5.0:
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -613,6 +720,13 @@ packages:
|
|||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
|
get-tsconfig@4.14.0:
|
||||||
|
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
|
||||||
|
|
||||||
|
giget@3.2.0:
|
||||||
|
resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
@@ -626,9 +740,23 @@ packages:
|
|||||||
resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==}
|
resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
|
is-docker@3.0.0:
|
||||||
|
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||||
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
is-electron@2.2.2:
|
is-electron@2.2.2:
|
||||||
resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==}
|
resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==}
|
||||||
|
|
||||||
|
is-in-ssh@1.0.0:
|
||||||
|
resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
is-inside-container@1.0.0:
|
||||||
|
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
|
||||||
|
engines: {node: '>=14.16'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
is-url@1.2.4:
|
is-url@1.2.4:
|
||||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||||
|
|
||||||
@@ -636,10 +764,21 @@ packages:
|
|||||||
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
|
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
is-wsl@3.1.1:
|
||||||
|
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
isexe@2.0.0:
|
||||||
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
jiti@2.6.1:
|
jiti@2.6.1:
|
||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
js-yaml@4.1.1:
|
||||||
|
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
lightningcss-android-arm64@1.30.2:
|
lightningcss-android-arm64@1.30.2:
|
||||||
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -733,6 +872,13 @@ packages:
|
|||||||
encoding:
|
encoding:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
ohash@2.0.11:
|
||||||
|
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||||
|
|
||||||
|
open@11.0.0:
|
||||||
|
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
opencollective-postinstall@2.0.3:
|
opencollective-postinstall@2.0.3:
|
||||||
resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==}
|
resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -740,9 +886,19 @@ packages:
|
|||||||
path-browserify@1.0.1:
|
path-browserify@1.0.1:
|
||||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||||
|
|
||||||
|
path-key@3.1.1:
|
||||||
|
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
pathe@2.0.3:
|
||||||
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
perfect-debounce@1.0.0:
|
perfect-debounce@1.0.0:
|
||||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||||
|
|
||||||
|
perfect-debounce@2.1.0:
|
||||||
|
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -759,13 +915,30 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
pkg-types@2.3.1:
|
||||||
|
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
|
||||||
|
|
||||||
postcss@8.5.6:
|
postcss@8.5.6:
|
||||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
powershell-utils@0.1.0:
|
||||||
|
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
rc9@3.0.1:
|
||||||
|
resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==}
|
||||||
|
|
||||||
|
readdirp@5.0.0:
|
||||||
|
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||||
|
engines: {node: '>= 20.19.0'}
|
||||||
|
|
||||||
regenerator-runtime@0.13.11:
|
regenerator-runtime@0.13.11:
|
||||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||||
|
|
||||||
|
resolve-pkg-maps@1.0.0:
|
||||||
|
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||||
|
|
||||||
rfdc@1.4.1:
|
rfdc@1.4.1:
|
||||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||||
|
|
||||||
@@ -774,6 +947,23 @@ packages:
|
|||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
run-applescript@7.1.0:
|
||||||
|
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
semver@7.7.4:
|
||||||
|
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
shebang-command@2.0.0:
|
||||||
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
shebang-regex@3.0.0:
|
||||||
|
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -880,6 +1070,15 @@ packages:
|
|||||||
whatwg-url@5.0.0:
|
whatwg-url@5.0.0:
|
||||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||||
|
|
||||||
|
which@2.0.2:
|
||||||
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
wsl-utils@0.3.1:
|
||||||
|
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
yaml@2.8.4:
|
yaml@2.8.4:
|
||||||
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
|
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
|
||||||
engines: {node: '>= 14.6'}
|
engines: {node: '>= 14.6'}
|
||||||
@@ -981,6 +1180,56 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.25.11':
|
'@esbuild/win32-x64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@hey-api/codegen-core@0.8.1':
|
||||||
|
dependencies:
|
||||||
|
'@hey-api/types': 0.1.4
|
||||||
|
ansi-colors: 4.1.3
|
||||||
|
c12: 3.3.4
|
||||||
|
color-support: 1.1.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- magicast
|
||||||
|
|
||||||
|
'@hey-api/json-schema-ref-parser@1.4.2':
|
||||||
|
dependencies:
|
||||||
|
'@jsdevtools/ono': 7.1.3
|
||||||
|
'@types/json-schema': 7.0.15
|
||||||
|
js-yaml: 4.1.1
|
||||||
|
|
||||||
|
'@hey-api/openapi-ts@0.97.2(typescript@5.9.3)':
|
||||||
|
dependencies:
|
||||||
|
'@hey-api/codegen-core': 0.8.1
|
||||||
|
'@hey-api/json-schema-ref-parser': 1.4.2
|
||||||
|
'@hey-api/shared': 0.4.4
|
||||||
|
'@hey-api/spec-types': 0.2.0
|
||||||
|
'@hey-api/types': 0.1.4
|
||||||
|
'@lukeed/ms': 2.0.2
|
||||||
|
ansi-colors: 4.1.3
|
||||||
|
color-support: 1.1.3
|
||||||
|
commander: 14.0.3
|
||||||
|
get-tsconfig: 4.14.0
|
||||||
|
typescript: 5.9.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- magicast
|
||||||
|
|
||||||
|
'@hey-api/shared@0.4.4':
|
||||||
|
dependencies:
|
||||||
|
'@hey-api/codegen-core': 0.8.1
|
||||||
|
'@hey-api/json-schema-ref-parser': 1.4.2
|
||||||
|
'@hey-api/spec-types': 0.2.0
|
||||||
|
'@hey-api/types': 0.1.4
|
||||||
|
ansi-colors: 4.1.3
|
||||||
|
cross-spawn: 7.0.6
|
||||||
|
open: 11.0.0
|
||||||
|
semver: 7.7.4
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- magicast
|
||||||
|
|
||||||
|
'@hey-api/spec-types@0.2.0':
|
||||||
|
dependencies:
|
||||||
|
'@hey-api/types': 0.1.4
|
||||||
|
|
||||||
|
'@hey-api/types@0.1.4': {}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@@ -1000,6 +1249,10 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@jsdevtools/ono@7.1.3': {}
|
||||||
|
|
||||||
|
'@lukeed/ms@2.0.2': {}
|
||||||
|
|
||||||
'@mdi/font@7.4.47': {}
|
'@mdi/font@7.4.47': {}
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.29': {}
|
'@rolldown/pluginutils@1.0.0-beta.29': {}
|
||||||
@@ -1147,6 +1400,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
'@types/node@24.9.2':
|
'@types/node@24.9.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.16.0
|
undici-types: 7.16.0
|
||||||
@@ -1275,20 +1530,76 @@ snapshots:
|
|||||||
|
|
||||||
alien-signals@3.0.3: {}
|
alien-signals@3.0.3: {}
|
||||||
|
|
||||||
|
ansi-colors@4.1.3: {}
|
||||||
|
|
||||||
|
argparse@2.0.1: {}
|
||||||
|
|
||||||
birpc@2.6.1: {}
|
birpc@2.6.1: {}
|
||||||
|
|
||||||
bmp-js@0.1.0: {}
|
bmp-js@0.1.0: {}
|
||||||
|
|
||||||
|
bundle-name@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
run-applescript: 7.1.0
|
||||||
|
|
||||||
|
c12@3.3.4:
|
||||||
|
dependencies:
|
||||||
|
chokidar: 5.0.0
|
||||||
|
confbox: 0.2.4
|
||||||
|
defu: 6.1.7
|
||||||
|
dotenv: 17.4.2
|
||||||
|
exsolve: 1.0.8
|
||||||
|
giget: 3.2.0
|
||||||
|
jiti: 2.6.1
|
||||||
|
ohash: 2.0.11
|
||||||
|
pathe: 2.0.3
|
||||||
|
perfect-debounce: 2.1.0
|
||||||
|
pkg-types: 2.3.1
|
||||||
|
rc9: 3.0.1
|
||||||
|
|
||||||
|
chokidar@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
readdirp: 5.0.0
|
||||||
|
|
||||||
|
color-support@1.1.3: {}
|
||||||
|
|
||||||
|
commander@14.0.3: {}
|
||||||
|
|
||||||
|
confbox@0.2.4: {}
|
||||||
|
|
||||||
copy-anything@4.0.5:
|
copy-anything@4.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-what: 5.5.0
|
is-what: 5.5.0
|
||||||
|
|
||||||
|
cross-spawn@7.0.6:
|
||||||
|
dependencies:
|
||||||
|
path-key: 3.1.1
|
||||||
|
shebang-command: 2.0.0
|
||||||
|
which: 2.0.2
|
||||||
|
|
||||||
csstype@3.1.3: {}
|
csstype@3.1.3: {}
|
||||||
|
|
||||||
daisyui@5.3.10: {}
|
daisyui@5.3.10: {}
|
||||||
|
|
||||||
|
dayjs@1.11.20: {}
|
||||||
|
|
||||||
|
default-browser-id@5.0.1: {}
|
||||||
|
|
||||||
|
default-browser@5.5.0:
|
||||||
|
dependencies:
|
||||||
|
bundle-name: 4.1.0
|
||||||
|
default-browser-id: 5.0.1
|
||||||
|
|
||||||
|
define-lazy-prop@3.0.0: {}
|
||||||
|
|
||||||
|
defu@6.1.7: {}
|
||||||
|
|
||||||
|
destr@2.0.5: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
|
dotenv@17.4.2: {}
|
||||||
|
|
||||||
enhanced-resolve@5.18.3:
|
enhanced-resolve@5.18.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -1327,6 +1638,8 @@ snapshots:
|
|||||||
|
|
||||||
estree-walker@2.0.2: {}
|
estree-walker@2.0.2: {}
|
||||||
|
|
||||||
|
exsolve@1.0.8: {}
|
||||||
|
|
||||||
fdir@6.5.0(picomatch@4.0.3):
|
fdir@6.5.0(picomatch@4.0.3):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
@@ -1334,6 +1647,12 @@ snapshots:
|
|||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
get-tsconfig@4.14.0:
|
||||||
|
dependencies:
|
||||||
|
resolve-pkg-maps: 1.0.0
|
||||||
|
|
||||||
|
giget@3.2.0: {}
|
||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
hookable@5.5.3: {}
|
hookable@5.5.3: {}
|
||||||
@@ -1342,14 +1661,32 @@ snapshots:
|
|||||||
|
|
||||||
install@0.13.0: {}
|
install@0.13.0: {}
|
||||||
|
|
||||||
|
is-docker@3.0.0: {}
|
||||||
|
|
||||||
is-electron@2.2.2: {}
|
is-electron@2.2.2: {}
|
||||||
|
|
||||||
|
is-in-ssh@1.0.0: {}
|
||||||
|
|
||||||
|
is-inside-container@1.0.0:
|
||||||
|
dependencies:
|
||||||
|
is-docker: 3.0.0
|
||||||
|
|
||||||
is-url@1.2.4: {}
|
is-url@1.2.4: {}
|
||||||
|
|
||||||
is-what@5.5.0: {}
|
is-what@5.5.0: {}
|
||||||
|
|
||||||
|
is-wsl@3.1.1:
|
||||||
|
dependencies:
|
||||||
|
is-inside-container: 1.0.0
|
||||||
|
|
||||||
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
jiti@2.6.1: {}
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
|
js-yaml@4.1.1:
|
||||||
|
dependencies:
|
||||||
|
argparse: 2.0.1
|
||||||
|
|
||||||
lightningcss-android-arm64@1.30.2:
|
lightningcss-android-arm64@1.30.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1413,12 +1750,29 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
whatwg-url: 5.0.0
|
whatwg-url: 5.0.0
|
||||||
|
|
||||||
|
ohash@2.0.11: {}
|
||||||
|
|
||||||
|
open@11.0.0:
|
||||||
|
dependencies:
|
||||||
|
default-browser: 5.5.0
|
||||||
|
define-lazy-prop: 3.0.0
|
||||||
|
is-in-ssh: 1.0.0
|
||||||
|
is-inside-container: 1.0.0
|
||||||
|
powershell-utils: 0.1.0
|
||||||
|
wsl-utils: 0.3.1
|
||||||
|
|
||||||
opencollective-postinstall@2.0.3: {}
|
opencollective-postinstall@2.0.3: {}
|
||||||
|
|
||||||
path-browserify@1.0.1: {}
|
path-browserify@1.0.1: {}
|
||||||
|
|
||||||
|
path-key@3.1.1: {}
|
||||||
|
|
||||||
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
perfect-debounce@1.0.0: {}
|
perfect-debounce@1.0.0: {}
|
||||||
|
|
||||||
|
perfect-debounce@2.1.0: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@4.0.3: {}
|
picomatch@4.0.3: {}
|
||||||
@@ -1430,14 +1784,31 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
pkg-types@2.3.1:
|
||||||
|
dependencies:
|
||||||
|
confbox: 0.2.4
|
||||||
|
exsolve: 1.0.8
|
||||||
|
pathe: 2.0.3
|
||||||
|
|
||||||
postcss@8.5.6:
|
postcss@8.5.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.11
|
nanoid: 3.3.11
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
powershell-utils@0.1.0: {}
|
||||||
|
|
||||||
|
rc9@3.0.1:
|
||||||
|
dependencies:
|
||||||
|
defu: 6.1.7
|
||||||
|
destr: 2.0.5
|
||||||
|
|
||||||
|
readdirp@5.0.0: {}
|
||||||
|
|
||||||
regenerator-runtime@0.13.11: {}
|
regenerator-runtime@0.13.11: {}
|
||||||
|
|
||||||
|
resolve-pkg-maps@1.0.0: {}
|
||||||
|
|
||||||
rfdc@1.4.1: {}
|
rfdc@1.4.1: {}
|
||||||
|
|
||||||
rollup@4.52.5:
|
rollup@4.52.5:
|
||||||
@@ -1468,6 +1839,16 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc': 4.52.5
|
'@rollup/rollup-win32-x64-msvc': 4.52.5
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
run-applescript@7.1.0: {}
|
||||||
|
|
||||||
|
semver@7.7.4: {}
|
||||||
|
|
||||||
|
shebang-command@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
shebang-regex: 3.0.0
|
||||||
|
|
||||||
|
shebang-regex@3.0.0: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
speakingurl@14.0.1: {}
|
speakingurl@14.0.1: {}
|
||||||
@@ -1550,6 +1931,15 @@ snapshots:
|
|||||||
tr46: 0.0.3
|
tr46: 0.0.3
|
||||||
webidl-conversions: 3.0.1
|
webidl-conversions: 3.0.1
|
||||||
|
|
||||||
|
which@2.0.2:
|
||||||
|
dependencies:
|
||||||
|
isexe: 2.0.0
|
||||||
|
|
||||||
|
wsl-utils@0.3.1:
|
||||||
|
dependencies:
|
||||||
|
is-wsl: 3.1.1
|
||||||
|
powershell-utils: 0.1.0
|
||||||
|
|
||||||
yaml@2.8.4:
|
yaml@2.8.4:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from ninja import NinjaAPI
|
from ninja import NinjaAPI
|
||||||
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from submissions.api import router as submissions_router
|
from opus_magnum.api import router as submissions_router
|
||||||
from submissions.schemas import UserInfoOut
|
from opus_magnum.schemas import UserInfoOut
|
||||||
from animations.api import router as results_router
|
from animations.api import router as results_router
|
||||||
from noita.api import router as noita_router
|
from noita.api import router as noita_router
|
||||||
|
from games.api import router as games_router
|
||||||
|
from market.api import router as market_router
|
||||||
|
|
||||||
# Create the main API instance
|
# Create the main API instance
|
||||||
api = NinjaAPI(
|
api = NinjaAPI(
|
||||||
@@ -30,10 +33,14 @@ The Noita Submission API allows clients to upload the result of the log file of
|
|||||||
# Add authentication for protected endpoints
|
# Add authentication for protected endpoints
|
||||||
# api.auth = django_auth # Uncomment if you want global auth
|
# api.auth = django_auth # Uncomment if you want global auth
|
||||||
|
|
||||||
# Include the submissions router
|
# Include the opus_magnum router
|
||||||
api.add_router("/submissions/", submissions_router, tags=["submissions"])
|
api.add_router("/opus-magnum/", submissions_router, tags=["opus-magnum"])
|
||||||
api.add_router("/results/", results_router, tags=["results"])
|
api.add_router("/results/", results_router, tags=["results"])
|
||||||
api.add_router("/noita/", noita_router, tags=["noita"])
|
api.add_router("/noita/", noita_router, tags=["noita"])
|
||||||
|
api.add_router("/games/", games_router, tags=["games"])
|
||||||
|
|
||||||
|
if settings.MARKET_ENABLED:
|
||||||
|
api.add_router("/market/", market_router)
|
||||||
|
|
||||||
|
|
||||||
# Health check endpoint
|
# Health check endpoint
|
||||||
@@ -63,20 +70,10 @@ def get_user_info(request):
|
|||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
if user.is_authenticated:
|
if user.is_authenticated:
|
||||||
return {
|
return user
|
||||||
"id": user.id,
|
|
||||||
"username": user.username,
|
return {
|
||||||
"first_name": user.first_name,
|
"is_authenticated": False,
|
||||||
"last_name": user.last_name,
|
"is_staff": False,
|
||||||
"email": user.email,
|
"is_superuser": False,
|
||||||
"is_authenticated": True,
|
}
|
||||||
"is_staff": user.is_staff,
|
|
||||||
"is_superuser": user.is_superuser,
|
|
||||||
"cas_groups": getattr(user, "cas_groups", []),
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"is_authenticated": False,
|
|
||||||
"is_staff": False,
|
|
||||||
"is_superuser": False,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -41,8 +41,10 @@ INSTALLED_APPS = [
|
|||||||
"django_vite",
|
"django_vite",
|
||||||
"accounts",
|
"accounts",
|
||||||
"animations",
|
"animations",
|
||||||
"submissions",
|
"opus_magnum",
|
||||||
"noita",
|
"noita",
|
||||||
|
"games",
|
||||||
|
"market",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -167,6 +169,9 @@ ALLOWED_SUBMISSION_TYPES = [
|
|||||||
"video/webm",
|
"video/webm",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Market app settings
|
||||||
|
MARKET_ENABLED = os.environ.get("MARKET_ENABLED", "true").lower() == "true"
|
||||||
|
|
||||||
# Authentication backends
|
# Authentication backends
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
"django.contrib.auth.backends.ModelBackend",
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
@@ -193,7 +198,7 @@ STATICFILES_DIRS = [
|
|||||||
from polylan_submitter.settingsLocal import * # noqa
|
from polylan_submitter.settingsLocal import * # noqa
|
||||||
|
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk # noqa
|
||||||
|
|
||||||
sentry_sdk.init(
|
sentry_sdk.init(
|
||||||
dsn="https://cc62a4ce3f3470890b43accf02cc6d8c@sentry2.polylan.ch/12",
|
dsn="https://cc62a4ce3f3470890b43accf02cc6d8c@sentry2.polylan.ch/12",
|
||||||
|
|||||||
@@ -23,17 +23,31 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
|
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
|
||||||
|
from games.decorators import require_game_enabled
|
||||||
|
from market.views import market_home
|
||||||
from .api import api
|
from .api import api
|
||||||
|
|
||||||
|
NOITA_APP_ID = 881100
|
||||||
|
OPUS_MAGNUM_APP_ID = 558990
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def home(request: HttpRequest):
|
def home(request: HttpRequest):
|
||||||
return render(request, "home.html", {})
|
from django.conf import settings
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"home.html",
|
||||||
|
{
|
||||||
|
"market_enabled": settings.MARKET_ENABLED,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@require_game_enabled(OPUS_MAGNUM_APP_ID)
|
||||||
def opus_magnum_home(request: HttpRequest):
|
def opus_magnum_home(request: HttpRequest):
|
||||||
from submissions.models import SteamCollection
|
from opus_magnum.models import SteamCollection
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
@@ -45,6 +59,7 @@ def opus_magnum_home(request: HttpRequest):
|
|||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@require_game_enabled(NOITA_APP_ID)
|
||||||
def noita_home(request: HttpRequest):
|
def noita_home(request: HttpRequest):
|
||||||
return render(request, "noita.html", {})
|
return render(request, "noita.html", {})
|
||||||
|
|
||||||
@@ -54,6 +69,7 @@ urlpatterns = [
|
|||||||
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
|
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
|
||||||
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
|
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
|
||||||
path("api/", api.urls),
|
path("api/", api.urls),
|
||||||
|
path("market", market_home, name="market.home"),
|
||||||
path("opus-magnum", opus_magnum_home, name="opus-magnum.home"),
|
path("opus-magnum", opus_magnum_home, name="opus-magnum.home"),
|
||||||
path("noita", noita_home, name="noita.home"),
|
path("noita", noita_home, name="noita.home"),
|
||||||
path("", home, name="home"),
|
path("", home, name="home"),
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
|
import { gamesApiListGames } from "./api";
|
||||||
|
import type { GamesApiListGamesResponse } from "./api/types.gen";
|
||||||
|
|
||||||
const games = computed(() => [
|
interface Props {
|
||||||
{
|
marketEnabled?: string | boolean;
|
||||||
id: "opus-magnum",
|
}
|
||||||
title: "Opus Magnum",
|
|
||||||
description: "Submit your best Opus Magnum puzzle solutions",
|
|
||||||
appId: 558990,
|
|
||||||
path: "/opus-magnum",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "noita",
|
|
||||||
title: "Noita",
|
|
||||||
description: "Submit your greatest Noita achievements",
|
|
||||||
appId: 881100,
|
|
||||||
path: "/noita",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
marketEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const games = ref<GamesApiListGamesResponse | undefined>();
|
||||||
|
const loading = ref(true);
|
||||||
const imageErrors = ref<Set<number>>(new Set());
|
const imageErrors = ref<Set<number>>(new Set());
|
||||||
|
|
||||||
const getHeaderImage = (appId: number) => {
|
const getHeaderImage = (appId: number) => {
|
||||||
@@ -31,6 +26,22 @@ const onImageError = (appId: number) => {
|
|||||||
const navigate = (path: string) => {
|
const navigate = (path: string) => {
|
||||||
window.location.href = path;
|
window.location.href = path;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isMarketEnabled = () => {
|
||||||
|
if (typeof props.marketEnabled === 'string') {
|
||||||
|
return props.marketEnabled === 'true';
|
||||||
|
}
|
||||||
|
return Boolean(props.marketEnabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Fetch games list
|
||||||
|
const response = await gamesApiListGames();
|
||||||
|
if (response.data) {
|
||||||
|
games.value = response.data;
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -44,13 +55,38 @@ const navigate = (path: string) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex justify-center py-20">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Cards Grid -->
|
<!-- Cards Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
<div v-for="game in games" :key="game.id" @click="navigate(game.path)"
|
<!-- Market Card -->
|
||||||
|
<div v-if="isMarketEnabled()" @click="navigate('/market')"
|
||||||
|
class="card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden">
|
||||||
|
<figure class="relative h-60 bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center">
|
||||||
|
<i class="mdi mdi-chart-box text-6xl text-white opacity-80"></i>
|
||||||
|
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"></div>
|
||||||
|
</figure>
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl">Market</h2>
|
||||||
|
<p class="text-base-content/70">Place your bets and compete</p>
|
||||||
|
<div class="card-actions justify-end mt-4">
|
||||||
|
<button class="btn btn-primary">
|
||||||
|
<i class="mdi mdi-arrow-right mr-2"></i>
|
||||||
|
Place bets
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Game Cards -->
|
||||||
|
<div v-for="game in games" :key="game.steam_app_id" @click="navigate(game.path)"
|
||||||
class="card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden">
|
class="card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden">
|
||||||
<figure class="relative h-60 bg-base-300 overflow-hidden">
|
<figure class="relative h-60 bg-base-300 overflow-hidden">
|
||||||
<img v-if="!imageErrors.has(game.appId)" :src="getHeaderImage(game.appId)" :alt="game.title"
|
<img v-if="!imageErrors.has(game.steam_app_id)" :src="getHeaderImage(game.steam_app_id)" :alt="game.name"
|
||||||
@error="onImageError(game.appId)" class="w-full h-full object-cover" />
|
@error="onImageError(game.steam_app_id)" class="w-full h-full object-cover" />
|
||||||
<div v-else
|
<div v-else
|
||||||
class="w-full h-full bg-gradient-to-br from-blue-600 to-blue-400 flex items-center justify-center text-white">
|
class="w-full h-full bg-gradient-to-br from-blue-600 to-blue-400 flex items-center justify-center text-white">
|
||||||
<i class="mdi mdi-gamepad-variant text-5xl"></i>
|
<i class="mdi mdi-gamepad-variant text-5xl"></i>
|
||||||
@@ -58,8 +94,7 @@ const navigate = (path: string) => {
|
|||||||
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"></div>
|
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"></div>
|
||||||
</figure>
|
</figure>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl">{{ game.title }}</h2>
|
<h2 class="card-title text-2xl">{{ game.name }}</h2>
|
||||||
<p class="text-base-content/70">{{ game.description }}</p>
|
|
||||||
<div class="card-actions justify-end mt-4">
|
<div class="card-actions justify-end mt-4">
|
||||||
<button class="btn btn-primary">
|
<button class="btn btn-primary">
|
||||||
<i class="mdi mdi-arrow-right mr-2"></i>
|
<i class="mdi mdi-arrow-right mr-2"></i>
|
||||||
@@ -75,7 +110,5 @@ const navigate = (path: string) => {
|
|||||||
<p>Select a game above to begin submitting</p>
|
<p>Select a game above to begin submitting</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from "vue";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { useMarketStore } from "./stores/market";
|
||||||
|
import MarketCard from "./components/MarketCard.vue";
|
||||||
|
import UserBets from "./components/UserBets.vue";
|
||||||
|
|
||||||
|
const marketStore = useMarketStore();
|
||||||
|
const { markets, userInfo, isLoading } = storeToRefs(marketStore);
|
||||||
|
|
||||||
|
const goHome = () => {
|
||||||
|
window.location.href = "/";
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadPage = async () => {
|
||||||
|
await marketStore.refreshPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
marketStore.initializeMarketPage();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-base-200">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="navbar bg-base-100 shadow-lg">
|
||||||
|
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
|
||||||
|
<button @click="goHome" class="btn btn-primary btn-sm">
|
||||||
|
<i class="mdi mdi-arrow-left"></i>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<h1 class="text-xl font-bold">Market</h1>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="font-medium">{{ userInfo.username }}</span>
|
||||||
|
<span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">Admin</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm text-base-content/70">Not logged in</div>
|
||||||
|
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
||||||
|
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="container min-w-3/4 mx-auto px-4 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-3xl font-bold mb-2">Market</h2>
|
||||||
|
<p class="text-base-content/70">Place your bets on upcoming events</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="isLoading" class="flex justify-center py-20">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- My Bets Section -->
|
||||||
|
<div v-if="userInfo?.is_authenticated">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<i class="mdi mdi-heart text-error"></i>
|
||||||
|
My Bets
|
||||||
|
</h3>
|
||||||
|
<div class="text-lg font-semibold">
|
||||||
|
<span class="text-primary">{{ userInfo.points }}</span>
|
||||||
|
<span class="text-base-content/60 ml-1">pts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UserBets :markets="markets" @refresh="reloadPage" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- All Markets Section -->
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<i class="mdi mdi-list"></i>
|
||||||
|
All Markets
|
||||||
|
</h3>
|
||||||
|
<a v-if="userInfo?.is_superuser" href="/admin/market/market/add/" class="btn btn-sm btn-primary">
|
||||||
|
<i class="mdi mdi-plus"></i>
|
||||||
|
Create Market
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="markets.length === 0" class="alert">
|
||||||
|
<i class="mdi mdi-information mr-2"></i>
|
||||||
|
<span>No markets available</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<MarketCard v-for="market in markets" :key="market.uuid" :market="market" @refresh="reloadPage" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+211
-279
@@ -1,81 +1,107 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import RankBadge from "@/components/RankBadge.vue";
|
||||||
|
import { useNoitaStore, type Objective } from "@/stores/noita";
|
||||||
|
import {
|
||||||
|
createColumnHelper,
|
||||||
|
useVueTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type ColumnFiltersState,
|
||||||
|
type SortingState,
|
||||||
|
} from "@tanstack/vue-table";
|
||||||
|
|
||||||
interface Objective {
|
const noitaStore = useNoitaStore();
|
||||||
objectiv_id: string;
|
const { userInfo, objectives, leaderboard, isLoadingLeaderboard, isUploading } = storeToRefs(noitaStore);
|
||||||
count: number;
|
|
||||||
points_per_objectiv?: number;
|
|
||||||
total_points?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userInfo = ref({
|
|
||||||
username: "Player",
|
|
||||||
rank: null as number | null,
|
|
||||||
score: 0,
|
|
||||||
runsSubmitted: 0,
|
|
||||||
isStaff: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadedFiles = ref<File[]>([]);
|
const uploadedFiles = ref<File[]>([]);
|
||||||
const isUploading = ref(false);
|
|
||||||
const isDragover = ref(false);
|
const isDragover = ref(false);
|
||||||
const objectives = ref<Objective[]>([]);
|
|
||||||
const objectiveSearchQuery = ref("");
|
|
||||||
const objectiveSortBy = ref<"id" | "count" | "points_per" | "total_points">("id");
|
|
||||||
const objectiveSortDesc = ref(false);
|
|
||||||
const isLoadingLeaderboard = ref(false);
|
|
||||||
const leaderboard = ref<any[]>([]);
|
|
||||||
const isLeaderboardModalOpen = ref(false);
|
|
||||||
|
|
||||||
const filteredObjectives = computed(() => {
|
const columnHelper = createColumnHelper<Objective>();
|
||||||
const query = objectiveSearchQuery.value.toLowerCase();
|
const sorting = ref<SortingState>([]);
|
||||||
|
const columnFilters = ref<ColumnFiltersState>([]);
|
||||||
|
|
||||||
let filtered = objectives.value;
|
const formatDate = (dateString: string | null) => {
|
||||||
|
if (!dateString) {
|
||||||
if (query) {
|
return ""
|
||||||
filtered = filtered.filter(
|
|
||||||
(obj) =>
|
|
||||||
obj.objectiv_id.toLowerCase().includes(query) ||
|
|
||||||
obj.count.toString().includes(query)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sorted = [...filtered].sort((a, b) => {
|
|
||||||
let aValue: number | string;
|
|
||||||
let bValue: number | string;
|
|
||||||
|
|
||||||
switch (objectiveSortBy.value) {
|
|
||||||
case "points_per":
|
|
||||||
aValue = a.points_per_objectiv || 0;
|
|
||||||
bValue = b.points_per_objectiv || 0;
|
|
||||||
break;
|
|
||||||
case "total_points":
|
|
||||||
aValue = a.total_points || 0;
|
|
||||||
bValue = b.total_points || 0;
|
|
||||||
break;
|
|
||||||
case "id":
|
|
||||||
default:
|
|
||||||
aValue = a.objectiv_id.toLowerCase();
|
|
||||||
bValue = b.objectiv_id.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aValue < bValue) return objectiveSortDesc.value ? 1 : -1;
|
|
||||||
if (aValue > bValue) return objectiveSortDesc.value ? -1 : 1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
return sorted;
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleObjectiveSort = (column: "id" | "points_per" | "total_points") => {
|
|
||||||
if (objectiveSortBy.value === column) {
|
|
||||||
objectiveSortDesc.value = !objectiveSortDesc.value;
|
|
||||||
} else {
|
|
||||||
objectiveSortBy.value = column;
|
|
||||||
objectiveSortDesc.value = false;
|
|
||||||
}
|
}
|
||||||
|
const date = dayjs(dateString);
|
||||||
|
return date.format("MMM DD, YYYY HH:mm");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor("objectiv_id", {
|
||||||
|
header: "Objective ID",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("total_points", {
|
||||||
|
header: "Your points",
|
||||||
|
cell: (info) => info.getValue() || 0,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("first_seen_at", {
|
||||||
|
header: "First seen",
|
||||||
|
cell: (info) => formatDate(info.getValue()),
|
||||||
|
sortingFn: (rowA, rowB) => {
|
||||||
|
const dateA = dayjs(rowA.original.first_seen_at);
|
||||||
|
const dateB = dayjs(rowB.original.first_seen_at);
|
||||||
|
if (!rowA.original.first_seen_at) {
|
||||||
|
return rowB.original.first_seen_at ? 1 : 0
|
||||||
|
}
|
||||||
|
if (!rowB.original.first_seen_at) {
|
||||||
|
return rowA.original.first_seen_at ? 0 : 1
|
||||||
|
}
|
||||||
|
return dateA.isBefore(dateB) ? -1 : dateA.isAfter(dateB) ? 1 : 0;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("seed", {
|
||||||
|
header: "Seed",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = computed(() =>
|
||||||
|
useVueTable({
|
||||||
|
get data() {
|
||||||
|
return objectives.value;
|
||||||
|
},
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
get sorting() {
|
||||||
|
return sorting.value;
|
||||||
|
},
|
||||||
|
get columnFilters() {
|
||||||
|
return columnFilters.value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onSortingChange: (updater) => {
|
||||||
|
sorting.value =
|
||||||
|
typeof updater === "function" ? updater(sorting.value) : updater;
|
||||||
|
},
|
||||||
|
onColumnFiltersChange: (updater) => {
|
||||||
|
columnFilters.value =
|
||||||
|
typeof updater === "function" ? updater(columnFilters.value) : updater;
|
||||||
|
},
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
filterFns: {
|
||||||
|
fuzzy: (row, columnId, value) => {
|
||||||
|
const itemData = row.getValue(columnId);
|
||||||
|
const searchValue = value.toLowerCase();
|
||||||
|
if (columnId === "first_seen_at") {
|
||||||
|
return formatDate(itemData as string).includes(searchValue)
|
||||||
|
}
|
||||||
|
return String(itemData).toLowerCase().includes(searchValue);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredObjectives = computed(() => table.value.getRowModel().rows);
|
||||||
|
|
||||||
const handleFileUpload = (event: Event) => {
|
const handleFileUpload = (event: Event) => {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
if (input.files) {
|
if (input.files) {
|
||||||
@@ -108,40 +134,13 @@ const handleDrop = (event: DragEvent) => {
|
|||||||
const submitRun = async () => {
|
const submitRun = async () => {
|
||||||
if (uploadedFiles.value.length === 0) return;
|
if (uploadedFiles.value.length === 0) return;
|
||||||
|
|
||||||
isUploading.value = true;
|
|
||||||
try {
|
try {
|
||||||
for (const file of uploadedFiles.value) {
|
await noitaStore.submitRun(uploadedFiles.value);
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
const response = await fetch("/api/noita/submit", {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
alert(`Error submitting ${file.name}: ${error.detail || "Unknown error"}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
console.log("Submission successful:", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadedFiles.value = [];
|
uploadedFiles.value = [];
|
||||||
alert("Run submitted successfully!");
|
alert("Run submitted successfully!");
|
||||||
|
|
||||||
// Refresh objectives, score, and rank after successful submission
|
|
||||||
await Promise.all([
|
|
||||||
fetchUserResults(),
|
|
||||||
fetchLeaderboard(),
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error submitting run:", error);
|
console.error("Error submitting run:", error);
|
||||||
alert("Error submitting run. Please try again.");
|
alert("Error submitting run. Please try again.");
|
||||||
} finally {
|
|
||||||
isUploading.value = false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,92 +148,18 @@ const goHome = () => {
|
|||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchUserResults = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/noita/results");
|
|
||||||
if (!response.ok) throw new Error("Failed to fetch results");
|
|
||||||
|
|
||||||
const results = await response.json();
|
|
||||||
userInfo.value.score = results.total_score;
|
|
||||||
userInfo.value.runsSubmitted = results.objectives.length;
|
|
||||||
objectives.value = results.objectives;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching results:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchLeaderboard = async () => {
|
|
||||||
isLoadingLeaderboard.value = true;
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/noita/leaderboard");
|
|
||||||
if (!response.ok) throw new Error("Failed to fetch leaderboard");
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
leaderboard.value = data.leaderboard;
|
|
||||||
|
|
||||||
// Find current user's rank
|
|
||||||
const userRank = leaderboard.value.find(
|
|
||||||
(entry: any) => entry.username === userInfo.value.username
|
|
||||||
);
|
|
||||||
|
|
||||||
if (userRank) {
|
|
||||||
userInfo.value.rank = userRank.rank;
|
|
||||||
userInfo.value.score = userRank.total_score;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching leaderboard:", error);
|
|
||||||
} finally {
|
|
||||||
isLoadingLeaderboard.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearCache = async () => {
|
const clearCache = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/cache/clear", {
|
await noitaStore.clearCache();
|
||||||
method: "POST",
|
alert("Cache cleared successfully!");
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
alert("Cache cleared successfully!");
|
|
||||||
// Refresh data after clearing cache
|
|
||||||
await Promise.all([
|
|
||||||
fetchUserResults(),
|
|
||||||
fetchLeaderboard(),
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
alert(`Error clearing cache: ${error.detail || "Unknown error"}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error clearing cache:", error);
|
console.error("Error clearing cache:", error);
|
||||||
alert("Error clearing cache. Please try again.");
|
alert("Error clearing cache. Please try again.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadUserData = async () => {
|
|
||||||
// Get user info first
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/user");
|
|
||||||
if (response.ok) {
|
|
||||||
const user = await response.json();
|
|
||||||
if (user.is_authenticated) {
|
|
||||||
userInfo.value.username = user.username;
|
|
||||||
userInfo.value.isStaff = user.is_staff || false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching user info:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch results and leaderboard
|
|
||||||
await Promise.all([
|
|
||||||
fetchUserResults(),
|
|
||||||
fetchLeaderboard(),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadUserData();
|
noitaStore.loadUserData();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -242,7 +167,7 @@ onMounted(() => {
|
|||||||
<div class="min-h-screen bg-base-200">
|
<div class="min-h-screen bg-base-200">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="navbar bg-base-100 shadow-lg">
|
<div class="navbar bg-base-100 shadow-lg">
|
||||||
<div class="container mx-auto w-full flex items-center gap-4">
|
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
|
||||||
<button @click="goHome" class="btn btn-primary btn-sm">
|
<button @click="goHome" class="btn btn-primary btn-sm">
|
||||||
<i class="mdi mdi-arrow-left"></i>
|
<i class="mdi mdi-arrow-left"></i>
|
||||||
Back
|
Back
|
||||||
@@ -255,19 +180,21 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container min-w-3/4 mx-auto px-4 py-8">
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<!-- Left Column: User Ranking -->
|
<!-- Left Column: User Ranking -->
|
||||||
<div class="lg:col-span-1">
|
<div class="lg:col-span-1">
|
||||||
<div class="card bg-base-100 shadow-lg sticky top-8">
|
<div class="card bg-base-100 shadow-lg sticky top-8">
|
||||||
<div class="bg-gradient-to-br from-purple-600 to-purple-400 p-6 text-white rounded-t-2xl">
|
<div class="bg-gradient-to-br from-purple-600 to-purple-400 p-8 text-white rounded-t-2xl">
|
||||||
<i class="mdi mdi-trophy text-4xl"></i>
|
<h2 class="text-3xl font-bold">
|
||||||
<h2 class="text-2xl font-bold mt-2">Your Ranking</h2>
|
<i class="mdi mdi-trophy text-3xl"></i>
|
||||||
|
Your Ranking
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body p-8">
|
||||||
<div class="text-center mb-6">
|
<div class="text-center mb-8">
|
||||||
<p class="text-sm text-base-content/70">Player</p>
|
<p class="text-base text-base-content/70">Player</p>
|
||||||
<p class="text-3xl font-bold">{{ userInfo.username }}</p>
|
<p class="text-4xl font-bold mt-2">{{ userInfo.username }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
@@ -276,30 +203,66 @@ onMounted(() => {
|
|||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-6">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-base-content/70 mb-1">Current Rank</p>
|
<p class="text-base text-base-content/70 mb-3">Current Rank</p>
|
||||||
<p v-if="userInfo.rank !== null" class="text-4xl font-bold text-primary">
|
<RankBadge :rank="userInfo.rank" />
|
||||||
#{{ userInfo.rank }}
|
|
||||||
</p>
|
|
||||||
<p v-else class="text-2xl text-base-content/50">No rank yet</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-base-content/70 mb-1">Total Score</p>
|
<p class="text-base text-base-content/70 mb-2">Total Score</p>
|
||||||
<p class="text-2xl font-bold">{{ userInfo.score.toLocaleString() }}</p>
|
<p class="text-3xl font-bold">{{ userInfo.score.toLocaleString() }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-base-content/70 mb-1">Objectives Completed</p>
|
<p class="text-base text-base-content/70 mb-2">Objectives Completed</p>
|
||||||
<p class="text-2xl font-bold">{{ userInfo.runsSubmitted }}</p>
|
<p class="text-3xl font-bold">{{ userInfo.runsSubmitted }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-base text-base-content/70 mb-2">Deaths</p>
|
||||||
|
<p class="text-3xl font-bold text-error">{{ userInfo.deathsCount }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button @click="isLeaderboardModalOpen = true" class="btn btn-outline btn-sm w-full mt-6">
|
<!-- Leaderboard Table -->
|
||||||
<i class="mdi mdi-trophy mr-1"></i>
|
<div class="mt-6">
|
||||||
View Full Leaderboard
|
<h3 class="font-bold text-lg mb-3">Global Leaderboard</h3>
|
||||||
</button>
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-sm w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Rank</th>
|
||||||
|
<th>Player</th>
|
||||||
|
<th class="text-right">Score</th>
|
||||||
|
<th class="text-right">Objectives</th>
|
||||||
|
<th class="text-right">Deaths</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="entry in leaderboard" :key="entry.username"
|
||||||
|
:class="{ 'bg-primary/20': entry.username === userInfo.username }">
|
||||||
|
<td class="font-bold">
|
||||||
|
<RankBadge :rank="entry.rank" />
|
||||||
|
</td>
|
||||||
|
<td class="text-sm">
|
||||||
|
{{ entry.username }}
|
||||||
|
<span v-if="entry.username === userInfo.username" class="badge badge-primary badge-sm ml-1">
|
||||||
|
You
|
||||||
|
</span>
|
||||||
|
<span v-if="entry.is_staff" class="badge badge-warning badge-sm ml-1">
|
||||||
|
admin
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right text-sm font-bold text-primary">{{ entry.total_score.toLocaleString() }}
|
||||||
|
</td>
|
||||||
|
<td class="text-right text-sm">{{ entry.objectives_count }}</td>
|
||||||
|
<td class="text-right text-sm text-error">{{ entry.deaths_count }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button v-if="userInfo.isStaff" @click="clearCache" class="btn btn-error btn-sm w-full mt-3">
|
<button v-if="userInfo.isStaff" @click="clearCache" class="btn btn-error btn-sm w-full mt-3">
|
||||||
<i class="mdi mdi-cache-clear mr-1"></i>
|
<i class="mdi mdi-cache-clear mr-1"></i>
|
||||||
@@ -310,7 +273,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Column: Upload -->
|
<!-- Right Column: Upload -->
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-1">
|
||||||
<div class="card bg-base-100 shadow-lg">
|
<div class="card bg-base-100 shadow-lg">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl mb-6">
|
<h2 class="card-title text-2xl mb-6">
|
||||||
@@ -377,7 +340,7 @@ onMounted(() => {
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl mb-6">
|
<h2 class="card-title text-2xl mb-6">
|
||||||
<i class="mdi mdi-view-list text-purple-500 mr-2"></i>
|
<i class="mdi mdi-view-list text-purple-500 mr-2"></i>
|
||||||
Your Objectives
|
Objectives
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div v-if="objectives.length === 0" class="text-center py-8">
|
<div v-if="objectives.length === 0" class="text-center py-8">
|
||||||
@@ -387,8 +350,12 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div v-if="objectives.length > 0" class="space-y-4">
|
<div v-if="objectives.length > 0" class="space-y-4">
|
||||||
<!-- Search Input -->
|
<!-- Search Input -->
|
||||||
<input v-model="objectiveSearchQuery" type="text" placeholder="Search objectives..."
|
<input :value="columnFilters.find((f) => f.id === 'objectiv_id')?.value ?? ''" @input="
|
||||||
class="input input-bordered w-full" />
|
(e) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
table.getColumn('objectiv_id')?.setFilterValue(target.value);
|
||||||
|
}
|
||||||
|
" type="text" placeholder="Search objectives..." class="input input-bordered w-full" />
|
||||||
|
|
||||||
<!-- Results Summary -->
|
<!-- Results Summary -->
|
||||||
<div class="text-sm text-base-content/70">
|
<div class="text-sm text-base-content/70">
|
||||||
@@ -400,31 +367,54 @@ onMounted(() => {
|
|||||||
<table class="table table-zebra w-full">
|
<table class="table table-zebra w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="cursor-pointer hover:bg-base-300" @click="toggleObjectiveSort('id')">
|
<th v-for="header in table.getHeaderGroups()[0]?.headers" :key="header.id" :class="[
|
||||||
Objective ID
|
'cursor-pointer hover:bg-base-300',
|
||||||
<i v-if="objectiveSortBy === 'id'"
|
header.column.columnDef.id === 'objectiv_id' ? 'text-left' : 'text-right',
|
||||||
:class="['mdi ml-2', objectiveSortDesc ? 'mdi-arrow-down' : 'mdi-arrow-up']"></i>
|
]" @click="header.column.toggleSorting()">
|
||||||
</th>
|
<div class="flex items-center justify-between">
|
||||||
<th class="text-right cursor-pointer hover:bg-base-300"
|
<span v-if="header.column.columnDef.id === 'objectiv_id'">
|
||||||
@click="toggleObjectiveSort('total_points')">
|
{{ header.isPlaceholder ? null : header.column.columnDef.header }}
|
||||||
Total Points
|
</span>
|
||||||
<i v-if="objectiveSortBy === 'total_points'"
|
<span v-else class="ml-auto">
|
||||||
:class="['mdi ml-2', objectiveSortDesc ? 'mdi-arrow-down' : 'mdi-arrow-up']"></i>
|
{{ header.isPlaceholder ? null : header.column.columnDef.header }}
|
||||||
|
</span>
|
||||||
|
<i v-if="header.column.getIsSorted()" :class="[
|
||||||
|
'mdi ml-2',
|
||||||
|
header.column.getIsSorted() === 'desc'
|
||||||
|
? 'mdi-arrow-down'
|
||||||
|
: 'mdi-arrow-up',
|
||||||
|
]"></i>
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="obj in filteredObjectives" :key="obj.objectiv_id">
|
<tr v-for="row in filteredObjectives" :key="row.id">
|
||||||
<td class="font-medium">
|
<td v-for="cell in row.getVisibleCells()" :key="cell.id" :class="[
|
||||||
<a :href="`https://noita.wiki.gg/wiki/${obj.objectiv_id}`" target="_blank">
|
cell.column.id === 'objectiv_id'
|
||||||
{{ obj.objectiv_id }}
|
? 'font-medium'
|
||||||
<i class="mdi mdi-open-in-new"></i>
|
: 'text-right',
|
||||||
</a>
|
]">
|
||||||
</td>
|
<template v-if="cell.column.id === 'objectiv_id'">
|
||||||
<td class="text-right font-bold text-success">
|
<a :href="`https://noita.wiki.gg/wiki/${row.original.objectiv_id}`" target="_blank">
|
||||||
{{ obj.total_points || 0 }}
|
{{ row.original.display_string }}
|
||||||
</td>
|
<i class="mdi mdi-open-in-new"></i>
|
||||||
<td class="text-right">
|
</a>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="cell.column.id === 'total_points'">
|
||||||
|
<span :class="row.original.count >= row.original.max_count ? 'text-primary' : 'text-error'">
|
||||||
|
{{ row.original.total_points }} / {{ row.original.points_per_objectiv *
|
||||||
|
row.original.max_count }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="cell.column.id === 'first_seen_at'">
|
||||||
|
<span :title="formatDate(row.original.first_seen_at)">
|
||||||
|
{{ formatDate(row.original.first_seen_at) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ cell.renderValue() }}
|
||||||
|
</template>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -442,63 +432,5 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Leaderboard Modal -->
|
|
||||||
<div v-if="isLeaderboardModalOpen" class="modal modal-open">
|
|
||||||
<div class="modal-box max-w-4xl">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="font-bold text-lg">
|
|
||||||
<i class="mdi mdi-trophy text-yellow-500 mr-2"></i>
|
|
||||||
Global Leaderboard
|
|
||||||
</h3>
|
|
||||||
<button @click="isLeaderboardModalOpen = false" class="btn btn-sm btn-circle btn-ghost">
|
|
||||||
<i class="mdi mdi-close"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Leaderboard Table -->
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="table table-zebra w-full">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Rank</th>
|
|
||||||
<th>Username</th>
|
|
||||||
<th class="text-right">Objectives</th>
|
|
||||||
<th class="text-right">Score</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="entry in leaderboard" :key="entry.username"
|
|
||||||
:class="{ 'bg-primary/20': entry.username === userInfo.username }">
|
|
||||||
<td class="font-bold">
|
|
||||||
<span v-if="entry.rank === 1" class="badge badge-warning badge-lg">
|
|
||||||
🏆 #{{ entry.rank }}
|
|
||||||
</span>
|
|
||||||
<span v-else-if="entry.rank === 2" class="badge badge-lg">
|
|
||||||
🥈 #{{ entry.rank }}
|
|
||||||
</span>
|
|
||||||
<span v-else-if="entry.rank === 3" class="badge badge-lg">
|
|
||||||
🥉 #{{ entry.rank }}
|
|
||||||
</span>
|
|
||||||
<span v-else>#{{ entry.rank }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="font-medium">
|
|
||||||
{{ entry.username }}
|
|
||||||
<span v-if="entry.username === userInfo.username" class="badge badge-primary badge-sm ml-2">
|
|
||||||
You
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-right">{{ entry.objectives_count }}</td>
|
|
||||||
<td class="text-right font-bold">{{ entry.total_score.toLocaleString() }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="leaderboard.length === 0" class="text-center py-8">
|
|
||||||
<p class="text-base-content/70">No entries yet</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-backdrop" @click="isLeaderboardModalOpen = false"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import PuzzleCard from "@/components/PuzzleCard.vue";
|
|||||||
import SubmissionForm from "@/components/SubmissionForm.vue";
|
import SubmissionForm from "@/components/SubmissionForm.vue";
|
||||||
import AdminPanel from "@/components/AdminPanel.vue";
|
import AdminPanel from "@/components/AdminPanel.vue";
|
||||||
import Results from "@/components/Results.vue";
|
import Results from "@/components/Results.vue";
|
||||||
import { apiService, errorHelpers } from "@/services/apiService";
|
import Winners from "@/components/Winners.vue";
|
||||||
|
import PuzzleResults from "@/components/PuzzleResults.vue";
|
||||||
|
import TopUsersLeaderboard from "@/components/TopUsersLeaderboard.vue";
|
||||||
|
import { polylanSubmitterApiGetUserInfo, opusMagnumApiGetCollection } from "@/api";
|
||||||
|
import { errorHelpers } from "@/services/apiService";
|
||||||
import { usePuzzlesStore } from "@/stores/puzzles";
|
import { usePuzzlesStore } from "@/stores/puzzles";
|
||||||
import { useSubmissionsStore } from "@/stores/submissions";
|
import { useSubmissionsStore } from "@/stores/submissions";
|
||||||
import type { PuzzleResponse, UserInfo } from "@/types";
|
import type { PuzzleResponse, UserInfo, SteamCollection } from "@/types";
|
||||||
import { useCountdown } from "@vueuse/core";
|
import { useCountdown } from "@vueuse/core";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
@@ -26,6 +30,7 @@ const { openSubmissionModal, loadSubmissions, closeSubmissionModal } =
|
|||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const userInfo = ref<UserInfo | null>(null);
|
const userInfo = ref<UserInfo | null>(null);
|
||||||
|
const collection = ref<SteamCollection | null>(null);
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
const error = ref<string>("");
|
const error = ref<string>("");
|
||||||
|
|
||||||
@@ -34,6 +39,10 @@ const isSuperuser = computed(() => {
|
|||||||
return userInfo.value?.is_superuser || false;
|
return userInfo.value?.is_superuser || false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isTournamentClosed = computed(() => {
|
||||||
|
return !!(collection.value && !collection.value.accepting_submissions);
|
||||||
|
});
|
||||||
|
|
||||||
// Computed property to get responses grouped by puzzle
|
// Computed property to get responses grouped by puzzle
|
||||||
const responsesByPuzzle = computed(() => {
|
const responsesByPuzzle = computed(() => {
|
||||||
const grouped: Record<number, PuzzleResponse[]> = {};
|
const grouped: Record<number, PuzzleResponse[]> = {};
|
||||||
@@ -58,14 +67,24 @@ async function initialize() {
|
|||||||
|
|
||||||
// Load user info
|
// Load user info
|
||||||
console.log("Loading user info...");
|
console.log("Loading user info...");
|
||||||
const userResponse = await apiService.getUserInfo();
|
const userResponse = await polylanSubmitterApiGetUserInfo();
|
||||||
if (userResponse.data) {
|
if (userResponse.data) {
|
||||||
userInfo.value = userResponse.data;
|
userInfo.value = userResponse.data as UserInfo;
|
||||||
console.log("User info loaded:", userResponse.data);
|
console.log("User info loaded:", userResponse.data);
|
||||||
} else if (userResponse.error) {
|
} else if (userResponse.error) {
|
||||||
console.warn("User info error:", userResponse.error);
|
console.warn("User info error:", userResponse.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load collection data
|
||||||
|
console.log("Loading collection...");
|
||||||
|
const collectionResponse = await opusMagnumApiGetCollection();
|
||||||
|
if (collectionResponse.data) {
|
||||||
|
collection.value = collectionResponse.data as SteamCollection;
|
||||||
|
console.log("Collection loaded:", collectionResponse.data);
|
||||||
|
} else if (collectionResponse.error) {
|
||||||
|
console.warn("Collection error:", collectionResponse.error);
|
||||||
|
}
|
||||||
|
|
||||||
// Load puzzles from API using store
|
// Load puzzles from API using store
|
||||||
console.log("Loading puzzles...");
|
console.log("Loading puzzles...");
|
||||||
await puzzlesStore.loadPuzzles();
|
await puzzlesStore.loadPuzzles();
|
||||||
@@ -118,7 +137,7 @@ const goHome = () => {
|
|||||||
<div class="min-h-screen bg-base-200">
|
<div class="min-h-screen bg-base-200">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="navbar bg-base-100 shadow-lg">
|
<div class="navbar bg-base-100 shadow-lg">
|
||||||
<div class="container mx-auto w-full flex items-center gap-4">
|
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
|
||||||
<button @click="goHome" class="btn btn-primary btn-sm">
|
<button @click="goHome" class="btn btn-primary btn-sm">
|
||||||
<i class="mdi mdi-arrow-left"></i>
|
<i class="mdi mdi-arrow-left"></i>
|
||||||
Back
|
Back
|
||||||
@@ -126,17 +145,10 @@ const goHome = () => {
|
|||||||
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
|
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div
|
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
|
||||||
v-if="userInfo?.is_authenticated"
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<span class="font-medium">{{ userInfo.username }}</span>
|
<span class="font-medium">{{ userInfo.username }}</span>
|
||||||
<span
|
<span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">Admin</span>
|
||||||
v-if="userInfo.is_superuser"
|
|
||||||
class="badge badge-warning badge-xs ml-1"
|
|
||||||
>Admin</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-sm text-base-content/70">Not logged in</div>
|
<div v-else class="text-sm text-base-content/70">Not logged in</div>
|
||||||
@@ -147,7 +159,7 @@ const goHome = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container min-w-3/4 mx-auto px-4 py-8">
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="userInfo?.is_superuser" class="flex justify-center">
|
<div v-if="userInfo?.is_superuser" class="flex justify-center">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
@@ -158,10 +170,7 @@ const goHome = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div v-if="isLoading" class="flex justify-center items-center min-h-[400px]">
|
||||||
v-if="isLoading"
|
|
||||||
class="flex justify-center items-center min-h-[400px]"
|
|
||||||
>
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
|
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
|
||||||
@@ -183,49 +192,63 @@ const goHome = () => {
|
|||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div v-else class="space-y-8">
|
<div v-else class="space-y-8">
|
||||||
<!-- Collection Info -->
|
|
||||||
<div class="mb-8">
|
<!-- Results Section (only when tournament is closed) -->
|
||||||
<div class="card bg-base-100 shadow-lg">
|
<div v-if="isTournamentClosed" class="space-y-8">
|
||||||
<div class="card-body">
|
<TopUsersLeaderboard />
|
||||||
<h2 class="card-title text-2xl">{{ props.collectionTitle }}</h2>
|
<PuzzleResults />
|
||||||
<p class="text-base-content/70">
|
<Winners />
|
||||||
{{ props.collectionDescription }}
|
</div>
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap gap-4 mt-4">
|
<template v-else>
|
||||||
<button @click="openSubmissionModal" class="btn btn-primary">
|
|
||||||
<i class="mdi mdi-plus mr-2"></i>
|
<!-- Collection Info -->
|
||||||
Submit Solution
|
<div class="mb-8">
|
||||||
</button>
|
<div class="card bg-base-100 shadow-lg">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl">{{ props.collectionTitle }}</h2>
|
||||||
|
<p class="text-base-content/70">
|
||||||
|
{{ props.collectionDescription }}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-4 mt-4">
|
||||||
|
<button @click="openSubmissionModal" class="btn btn-primary" :disabled="isTournamentClosed">
|
||||||
|
<i class="mdi mdi-plus mr-2"></i>
|
||||||
|
Submit Solution
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="isTournamentClosed" class="alert alert-warning mt-4">
|
||||||
|
<i class="mdi mdi-alert-circle text-xl"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold">Tournament Closed</h3>
|
||||||
|
<div class="text-sm">This tournament is no longer accepting new submissions.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Results />
|
<Results />
|
||||||
|
|
||||||
<!-- Admin Panel (only for superusers) -->
|
<!-- Admin Panel (only for superusers) -->
|
||||||
<div v-if="isSuperuser">
|
<div v-if="isSuperuser">
|
||||||
<AdminPanel />
|
<AdminPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Puzzles Grid -->
|
<!-- Puzzles Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<PuzzleCard
|
<PuzzleCard v-for="puzzle in puzzlesStore.puzzles" :key="puzzle.id" :puzzle="puzzle"
|
||||||
v-for="puzzle in puzzlesStore.puzzles"
|
:responses="responsesByPuzzle[puzzle.id] || []" />
|
||||||
:key="puzzle.id"
|
</div>
|
||||||
:puzzle="puzzle"
|
|
||||||
:responses="responsesByPuzzle[puzzle.id] || []"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-if="puzzlesStore.puzzles.length === 0" class="text-center py-12">
|
<div v-if="puzzlesStore.puzzles.length === 0" class="text-center py-12">
|
||||||
<div class="text-6xl mb-4">🧩</div>
|
<div class="text-6xl mb-4">🧩</div>
|
||||||
<h3 class="text-xl font-bold mb-2">No Puzzles Available</h3>
|
<h3 class="text-xl font-bold mb-2">No Puzzles Available</h3>
|
||||||
<p class="text-base-content/70">
|
<p class="text-base-content/70">
|
||||||
Check back later for new puzzle collections!
|
Check back later for new puzzle collections!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -234,18 +257,12 @@ const goHome = () => {
|
|||||||
<div class="modal-box max-w-6xl">
|
<div class="modal-box max-w-6xl">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h3 class="font-bold text-lg">Submit Solution</h3>
|
<h3 class="font-bold text-lg">Submit Solution</h3>
|
||||||
<button
|
<button @click="closeSubmissionModal" class="btn btn-sm btn-circle btn-ghost">
|
||||||
@click="closeSubmissionModal"
|
|
||||||
class="btn btn-sm btn-circle btn-ghost"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-close"></i>
|
<i class="mdi mdi-close"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SubmissionForm
|
<SubmissionForm :puzzles="puzzlesStore.puzzles" :find-puzzle-by-name="findPuzzleByName" />
|
||||||
:puzzles="puzzlesStore.puzzles"
|
|
||||||
:find-puzzle-by-name="findPuzzleByName"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
|
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import { type ClientOptions, type Config, createClient, createConfig } from './client';
|
||||||
|
import type { ClientOptions as ClientOptions2 } from './types.gen';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `createClientConfig()` function will be called on client initialization
|
||||||
|
* and the returned object will become the client's initial configuration.
|
||||||
|
*
|
||||||
|
* You may want to initialize your client this way instead of calling
|
||||||
|
* `setConfig()`. This is useful for example if you're using Next.js
|
||||||
|
* to ensure your client always has the correct values.
|
||||||
|
*/
|
||||||
|
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
|
||||||
|
|
||||||
|
export const client = createClient(createConfig<ClientOptions2>());
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import { createSseClient } from '../core/serverSentEvents.gen';
|
||||||
|
import type { HttpMethod } from '../core/types.gen';
|
||||||
|
import { getValidRequestBody } from '../core/utils.gen';
|
||||||
|
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen';
|
||||||
|
import {
|
||||||
|
buildUrl,
|
||||||
|
createConfig,
|
||||||
|
createInterceptors,
|
||||||
|
getParseAs,
|
||||||
|
mergeConfigs,
|
||||||
|
mergeHeaders,
|
||||||
|
setAuthParams,
|
||||||
|
} from './utils.gen';
|
||||||
|
|
||||||
|
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
|
||||||
|
body?: any;
|
||||||
|
headers: ReturnType<typeof mergeHeaders>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createClient = (config: Config = {}): Client => {
|
||||||
|
let _config = mergeConfigs(createConfig(), config);
|
||||||
|
|
||||||
|
const getConfig = (): Config => ({ ..._config });
|
||||||
|
|
||||||
|
const setConfig = (config: Config): Config => {
|
||||||
|
_config = mergeConfigs(_config, config);
|
||||||
|
return getConfig();
|
||||||
|
};
|
||||||
|
|
||||||
|
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
|
||||||
|
|
||||||
|
const beforeRequest = async <
|
||||||
|
TData = unknown,
|
||||||
|
TResponseStyle extends 'data' | 'fields' = 'fields',
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
Url extends string = string,
|
||||||
|
>(
|
||||||
|
options: RequestOptions<TData, TResponseStyle, ThrowOnError, Url>,
|
||||||
|
) => {
|
||||||
|
const opts = {
|
||||||
|
..._config,
|
||||||
|
...options,
|
||||||
|
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
||||||
|
headers: mergeHeaders(_config.headers, options.headers),
|
||||||
|
serializedBody: undefined as string | undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (opts.security) {
|
||||||
|
await setAuthParams(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.requestValidator) {
|
||||||
|
await opts.requestValidator(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.body !== undefined && opts.bodySerializer) {
|
||||||
|
opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove Content-Type header if body is empty to avoid sending invalid requests
|
||||||
|
if (opts.body === undefined || opts.serializedBody === '') {
|
||||||
|
opts.headers.delete('Content-Type');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedOpts = opts as typeof opts &
|
||||||
|
ResolvedRequestOptions<TResponseStyle, ThrowOnError, Url>;
|
||||||
|
const url = buildUrl(resolvedOpts);
|
||||||
|
|
||||||
|
return { opts: resolvedOpts, url };
|
||||||
|
};
|
||||||
|
|
||||||
|
const request: Client['request'] = async (options) => {
|
||||||
|
const throwOnError = options.throwOnError ?? _config.throwOnError;
|
||||||
|
const responseStyle = options.responseStyle ?? _config.responseStyle;
|
||||||
|
|
||||||
|
let request: Request | undefined;
|
||||||
|
let response: Response | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { opts, url } = await beforeRequest(options);
|
||||||
|
const requestInit: ReqInit = {
|
||||||
|
redirect: 'follow',
|
||||||
|
...opts,
|
||||||
|
body: getValidRequestBody(opts),
|
||||||
|
};
|
||||||
|
|
||||||
|
request = new Request(url, requestInit);
|
||||||
|
|
||||||
|
for (const fn of interceptors.request.fns) {
|
||||||
|
if (fn) {
|
||||||
|
request = await fn(request, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch must be assigned here, otherwise it would throw the error:
|
||||||
|
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||||
|
const _fetch = opts.fetch!;
|
||||||
|
|
||||||
|
response = await _fetch(request);
|
||||||
|
|
||||||
|
for (const fn of interceptors.response.fns) {
|
||||||
|
if (fn) {
|
||||||
|
response = await fn(response, request, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const parseAs =
|
||||||
|
(opts.parseAs === 'auto'
|
||||||
|
? getParseAs(response.headers.get('Content-Type'))
|
||||||
|
: opts.parseAs) ?? 'json';
|
||||||
|
|
||||||
|
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
|
||||||
|
let emptyData: any;
|
||||||
|
switch (parseAs) {
|
||||||
|
case 'arrayBuffer':
|
||||||
|
case 'blob':
|
||||||
|
case 'text':
|
||||||
|
emptyData = await response[parseAs]();
|
||||||
|
break;
|
||||||
|
case 'formData':
|
||||||
|
emptyData = new FormData();
|
||||||
|
break;
|
||||||
|
case 'stream':
|
||||||
|
emptyData = response.body;
|
||||||
|
break;
|
||||||
|
case 'json':
|
||||||
|
default:
|
||||||
|
emptyData = {};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return opts.responseStyle === 'data'
|
||||||
|
? emptyData
|
||||||
|
: {
|
||||||
|
data: emptyData,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: any;
|
||||||
|
switch (parseAs) {
|
||||||
|
case 'arrayBuffer':
|
||||||
|
case 'blob':
|
||||||
|
case 'formData':
|
||||||
|
case 'text':
|
||||||
|
data = await response[parseAs]();
|
||||||
|
break;
|
||||||
|
case 'json': {
|
||||||
|
// Some servers return 200 with no Content-Length and empty body.
|
||||||
|
// response.json() would throw; read as text and parse if non-empty.
|
||||||
|
const text = await response.text();
|
||||||
|
data = text ? JSON.parse(text) : {};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'stream':
|
||||||
|
return opts.responseStyle === 'data'
|
||||||
|
? response.body
|
||||||
|
: {
|
||||||
|
data: response.body,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parseAs === 'json') {
|
||||||
|
if (opts.responseValidator) {
|
||||||
|
await opts.responseValidator(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.responseTransformer) {
|
||||||
|
data = await opts.responseTransformer(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts.responseStyle === 'data'
|
||||||
|
? data
|
||||||
|
: {
|
||||||
|
data,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const textError = await response.text();
|
||||||
|
let jsonError: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
jsonError = JSON.parse(textError);
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
|
||||||
|
throw jsonError ?? textError;
|
||||||
|
} catch (error) {
|
||||||
|
let finalError = error;
|
||||||
|
|
||||||
|
for (const fn of interceptors.error.fns) {
|
||||||
|
if (fn) {
|
||||||
|
finalError = await fn(finalError, response, request, options as ResolvedRequestOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalError = finalError || {};
|
||||||
|
|
||||||
|
if (throwOnError) {
|
||||||
|
throw finalError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: we probably want to return error and improve types
|
||||||
|
return responseStyle === 'data'
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
error: finalError,
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
||||||
|
request({ ...options, method });
|
||||||
|
|
||||||
|
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||||
|
const { opts, url } = await beforeRequest(options);
|
||||||
|
return createSseClient({
|
||||||
|
...opts,
|
||||||
|
body: opts.body as BodyInit | null | undefined,
|
||||||
|
method,
|
||||||
|
onRequest: async (url, init) => {
|
||||||
|
let request = new Request(url, init);
|
||||||
|
for (const fn of interceptors.request.fns) {
|
||||||
|
if (fn) {
|
||||||
|
request = await fn(request, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options });
|
||||||
|
|
||||||
|
return {
|
||||||
|
buildUrl: _buildUrl,
|
||||||
|
connect: makeMethodFn('CONNECT'),
|
||||||
|
delete: makeMethodFn('DELETE'),
|
||||||
|
get: makeMethodFn('GET'),
|
||||||
|
getConfig,
|
||||||
|
head: makeMethodFn('HEAD'),
|
||||||
|
interceptors,
|
||||||
|
options: makeMethodFn('OPTIONS'),
|
||||||
|
patch: makeMethodFn('PATCH'),
|
||||||
|
post: makeMethodFn('POST'),
|
||||||
|
put: makeMethodFn('PUT'),
|
||||||
|
request,
|
||||||
|
setConfig,
|
||||||
|
sse: {
|
||||||
|
connect: makeSseFn('CONNECT'),
|
||||||
|
delete: makeSseFn('DELETE'),
|
||||||
|
get: makeSseFn('GET'),
|
||||||
|
head: makeSseFn('HEAD'),
|
||||||
|
options: makeSseFn('OPTIONS'),
|
||||||
|
patch: makeSseFn('PATCH'),
|
||||||
|
post: makeSseFn('POST'),
|
||||||
|
put: makeSseFn('PUT'),
|
||||||
|
trace: makeSseFn('TRACE'),
|
||||||
|
},
|
||||||
|
trace: makeMethodFn('TRACE'),
|
||||||
|
} as Client;
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
export type { Auth } from '../core/auth.gen';
|
||||||
|
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||||
|
export {
|
||||||
|
formDataBodySerializer,
|
||||||
|
jsonBodySerializer,
|
||||||
|
urlSearchParamsBodySerializer,
|
||||||
|
} from '../core/bodySerializer.gen';
|
||||||
|
export { buildClientParams } from '../core/params.gen';
|
||||||
|
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
|
||||||
|
export { createClient } from './client.gen';
|
||||||
|
export type {
|
||||||
|
Client,
|
||||||
|
ClientOptions,
|
||||||
|
Config,
|
||||||
|
CreateClientConfig,
|
||||||
|
Options,
|
||||||
|
RequestOptions,
|
||||||
|
RequestResult,
|
||||||
|
ResolvedRequestOptions,
|
||||||
|
ResponseStyle,
|
||||||
|
TDataShape,
|
||||||
|
} from './types.gen';
|
||||||
|
export { createConfig, mergeHeaders } from './utils.gen';
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { Auth } from '../core/auth.gen';
|
||||||
|
import type {
|
||||||
|
ServerSentEventsOptions,
|
||||||
|
ServerSentEventsResult,
|
||||||
|
} from '../core/serverSentEvents.gen';
|
||||||
|
import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen';
|
||||||
|
import type { Middleware } from './utils.gen';
|
||||||
|
|
||||||
|
export type ResponseStyle = 'data' | 'fields';
|
||||||
|
|
||||||
|
export interface Config<T extends ClientOptions = ClientOptions>
|
||||||
|
extends Omit<RequestInit, 'body' | 'headers' | 'method'>, CoreConfig {
|
||||||
|
/**
|
||||||
|
* Base URL for all requests made by this client.
|
||||||
|
*/
|
||||||
|
baseUrl?: T['baseUrl'];
|
||||||
|
/**
|
||||||
|
* Fetch API implementation. You can use this option to provide a custom
|
||||||
|
* fetch instance.
|
||||||
|
*
|
||||||
|
* @default globalThis.fetch
|
||||||
|
*/
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
/**
|
||||||
|
* Please don't use the Fetch client for Next.js applications. The `next`
|
||||||
|
* options won't have any effect.
|
||||||
|
*
|
||||||
|
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
|
||||||
|
*/
|
||||||
|
next?: never;
|
||||||
|
/**
|
||||||
|
* Return the response data parsed in a specified format. By default, `auto`
|
||||||
|
* will infer the appropriate method from the `Content-Type` response header.
|
||||||
|
* You can override this behavior with any of the {@link Body} methods.
|
||||||
|
* Select `stream` if you don't want to parse response data at all.
|
||||||
|
*
|
||||||
|
* @default 'auto'
|
||||||
|
*/
|
||||||
|
parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text';
|
||||||
|
/**
|
||||||
|
* Should we return only data or multiple fields (data, error, response, etc.)?
|
||||||
|
*
|
||||||
|
* @default 'fields'
|
||||||
|
*/
|
||||||
|
responseStyle?: ResponseStyle;
|
||||||
|
/**
|
||||||
|
* Throw an error instead of returning it in the response?
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
throwOnError?: T['throwOnError'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestOptions<
|
||||||
|
TData = unknown,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
Url extends string = string,
|
||||||
|
>
|
||||||
|
extends
|
||||||
|
Config<{
|
||||||
|
responseStyle: TResponseStyle;
|
||||||
|
throwOnError: ThrowOnError;
|
||||||
|
}>,
|
||||||
|
Pick<
|
||||||
|
ServerSentEventsOptions<TData>,
|
||||||
|
| 'onRequest'
|
||||||
|
| 'onSseError'
|
||||||
|
| 'onSseEvent'
|
||||||
|
| 'sseDefaultRetryDelay'
|
||||||
|
| 'sseMaxRetryAttempts'
|
||||||
|
| 'sseMaxRetryDelay'
|
||||||
|
> {
|
||||||
|
/**
|
||||||
|
* Any body that you want to add to your request.
|
||||||
|
*
|
||||||
|
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
||||||
|
*/
|
||||||
|
body?: unknown;
|
||||||
|
path?: Record<string, unknown>;
|
||||||
|
query?: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* Security mechanism(s) to use for the request.
|
||||||
|
*/
|
||||||
|
security?: ReadonlyArray<Auth>;
|
||||||
|
url: Url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedRequestOptions<
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
Url extends string = string,
|
||||||
|
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
||||||
|
headers: Headers;
|
||||||
|
serializedBody?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestResult<
|
||||||
|
TData = unknown,
|
||||||
|
TError = unknown,
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
> = ThrowOnError extends true
|
||||||
|
? Promise<
|
||||||
|
TResponseStyle extends 'data'
|
||||||
|
? TData extends Record<string, unknown>
|
||||||
|
? TData[keyof TData]
|
||||||
|
: TData
|
||||||
|
: {
|
||||||
|
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||||
|
request: Request;
|
||||||
|
response: Response;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
: Promise<
|
||||||
|
TResponseStyle extends 'data'
|
||||||
|
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
|
||||||
|
: (
|
||||||
|
| {
|
||||||
|
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||||
|
error: undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
data: undefined;
|
||||||
|
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
|
||||||
|
}
|
||||||
|
) & {
|
||||||
|
/** request may be undefined, because error may be from building the request object itself */
|
||||||
|
request?: Request;
|
||||||
|
/** response may be undefined, because error may be from building the request object itself or from a network error */
|
||||||
|
response?: Response;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface ClientOptions {
|
||||||
|
baseUrl?: string;
|
||||||
|
responseStyle?: ResponseStyle;
|
||||||
|
throwOnError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MethodFn = <
|
||||||
|
TData = unknown,
|
||||||
|
TError = unknown,
|
||||||
|
ThrowOnError extends boolean = false,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
>(
|
||||||
|
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||||
|
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||||
|
|
||||||
|
type SseFn = <
|
||||||
|
TData = unknown,
|
||||||
|
TError = unknown,
|
||||||
|
ThrowOnError extends boolean = false,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
>(
|
||||||
|
options: Omit<RequestOptions<never, TResponseStyle, ThrowOnError>, 'method'>,
|
||||||
|
) => Promise<ServerSentEventsResult<TData, TError>>;
|
||||||
|
|
||||||
|
type RequestFn = <
|
||||||
|
TData = unknown,
|
||||||
|
TError = unknown,
|
||||||
|
ThrowOnError extends boolean = false,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
>(
|
||||||
|
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
|
||||||
|
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, 'method'>,
|
||||||
|
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||||
|
|
||||||
|
type BuildUrlFn = <
|
||||||
|
TData extends {
|
||||||
|
body?: unknown;
|
||||||
|
path?: Record<string, unknown>;
|
||||||
|
query?: Record<string, unknown>;
|
||||||
|
url: string;
|
||||||
|
},
|
||||||
|
>(
|
||||||
|
options: TData & Options<TData>,
|
||||||
|
) => string;
|
||||||
|
|
||||||
|
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
|
||||||
|
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `createClientConfig()` function will be called on client initialization
|
||||||
|
* and the returned object will become the client's initial configuration.
|
||||||
|
*
|
||||||
|
* You may want to initialize your client this way instead of calling
|
||||||
|
* `setConfig()`. This is useful for example if you're using Next.js
|
||||||
|
* to ensure your client always has the correct values.
|
||||||
|
*/
|
||||||
|
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
|
||||||
|
override?: Config<ClientOptions & T>,
|
||||||
|
) => Config<Required<ClientOptions> & T>;
|
||||||
|
|
||||||
|
export interface TDataShape {
|
||||||
|
body?: unknown;
|
||||||
|
headers?: unknown;
|
||||||
|
path?: unknown;
|
||||||
|
query?: unknown;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||||
|
|
||||||
|
export type Options<
|
||||||
|
TData extends TDataShape = TDataShape,
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
TResponse = unknown,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
> = OmitKeys<
|
||||||
|
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
|
||||||
|
'body' | 'path' | 'query' | 'url'
|
||||||
|
> &
|
||||||
|
([TData] extends [never] ? unknown : Omit<TData, 'url'>);
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import { getAuthToken } from '../core/auth.gen';
|
||||||
|
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||||
|
import { jsonBodySerializer } from '../core/bodySerializer.gen';
|
||||||
|
import {
|
||||||
|
serializeArrayParam,
|
||||||
|
serializeObjectParam,
|
||||||
|
serializePrimitiveParam,
|
||||||
|
} from '../core/pathSerializer.gen';
|
||||||
|
import { getUrl } from '../core/utils.gen';
|
||||||
|
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
|
||||||
|
|
||||||
|
export const createQuerySerializer = <T = unknown>({
|
||||||
|
parameters = {},
|
||||||
|
...args
|
||||||
|
}: QuerySerializerOptions = {}) => {
|
||||||
|
const querySerializer = (queryParams: T) => {
|
||||||
|
const search: string[] = [];
|
||||||
|
if (queryParams && typeof queryParams === 'object') {
|
||||||
|
for (const name in queryParams) {
|
||||||
|
const value = queryParams[name];
|
||||||
|
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = parameters[name] || args;
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const serializedArray = serializeArrayParam({
|
||||||
|
allowReserved: options.allowReserved,
|
||||||
|
explode: true,
|
||||||
|
name,
|
||||||
|
style: 'form',
|
||||||
|
value,
|
||||||
|
...options.array,
|
||||||
|
});
|
||||||
|
if (serializedArray) search.push(serializedArray);
|
||||||
|
} else if (typeof value === 'object') {
|
||||||
|
const serializedObject = serializeObjectParam({
|
||||||
|
allowReserved: options.allowReserved,
|
||||||
|
explode: true,
|
||||||
|
name,
|
||||||
|
style: 'deepObject',
|
||||||
|
value: value as Record<string, unknown>,
|
||||||
|
...options.object,
|
||||||
|
});
|
||||||
|
if (serializedObject) search.push(serializedObject);
|
||||||
|
} else {
|
||||||
|
const serializedPrimitive = serializePrimitiveParam({
|
||||||
|
allowReserved: options.allowReserved,
|
||||||
|
name,
|
||||||
|
value: value as string,
|
||||||
|
});
|
||||||
|
if (serializedPrimitive) search.push(serializedPrimitive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return search.join('&');
|
||||||
|
};
|
||||||
|
return querySerializer;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infers parseAs value from provided Content-Type header.
|
||||||
|
*/
|
||||||
|
export const getParseAs = (contentType: string | null): Exclude<Config['parseAs'], 'auto'> => {
|
||||||
|
if (!contentType) {
|
||||||
|
// If no Content-Type header is provided, the best we can do is return the raw response body,
|
||||||
|
// which is effectively the same as the 'stream' option.
|
||||||
|
return 'stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanContent = contentType.split(';')[0]?.trim();
|
||||||
|
|
||||||
|
if (!cleanContent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) {
|
||||||
|
return 'json';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanContent === 'multipart/form-data') {
|
||||||
|
return 'formData';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type))
|
||||||
|
) {
|
||||||
|
return 'blob';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanContent.startsWith('text/')) {
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkForExistence = (
|
||||||
|
options: Pick<RequestOptions, 'auth' | 'query'> & {
|
||||||
|
headers: Headers;
|
||||||
|
},
|
||||||
|
name?: string,
|
||||||
|
): boolean => {
|
||||||
|
if (!name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
options.headers.has(name) ||
|
||||||
|
options.query?.[name] ||
|
||||||
|
options.headers.get('Cookie')?.includes(`${name}=`)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function setAuthParams(
|
||||||
|
options: Pick<RequestOptions, 'auth' | 'query' | 'security'> & {
|
||||||
|
headers: Headers;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
for (const auth of options.security ?? []) {
|
||||||
|
if (checkForExistence(options, auth.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getAuthToken(auth, options.auth);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = auth.name ?? 'Authorization';
|
||||||
|
|
||||||
|
switch (auth.in) {
|
||||||
|
case 'query':
|
||||||
|
if (!options.query) {
|
||||||
|
options.query = {};
|
||||||
|
}
|
||||||
|
options.query[name] = token;
|
||||||
|
break;
|
||||||
|
case 'cookie':
|
||||||
|
options.headers.append('Cookie', `${name}=${token}`);
|
||||||
|
break;
|
||||||
|
case 'header':
|
||||||
|
default:
|
||||||
|
options.headers.set(name, token);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildUrl: Client['buildUrl'] = (options) =>
|
||||||
|
getUrl({
|
||||||
|
baseUrl: options.baseUrl as string,
|
||||||
|
path: options.path,
|
||||||
|
query: options.query,
|
||||||
|
querySerializer:
|
||||||
|
typeof options.querySerializer === 'function'
|
||||||
|
? options.querySerializer
|
||||||
|
: createQuerySerializer(options.querySerializer),
|
||||||
|
url: options.url,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mergeConfigs = (a: Config, b: Config): Config => {
|
||||||
|
const config = { ...a, ...b };
|
||||||
|
if (config.baseUrl?.endsWith('/')) {
|
||||||
|
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
||||||
|
}
|
||||||
|
config.headers = mergeHeaders(a.headers, b.headers);
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
|
const headersEntries = (headers: Headers): Array<[string, string]> => {
|
||||||
|
const entries: Array<[string, string]> = [];
|
||||||
|
headers.forEach((value, key) => {
|
||||||
|
entries.push([key, value]);
|
||||||
|
});
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mergeHeaders = (
|
||||||
|
...headers: Array<Required<Config>['headers'] | undefined>
|
||||||
|
): Headers => {
|
||||||
|
const mergedHeaders = new Headers();
|
||||||
|
for (const header of headers) {
|
||||||
|
if (!header) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
|
||||||
|
|
||||||
|
for (const [key, value] of iterator) {
|
||||||
|
if (value === null) {
|
||||||
|
mergedHeaders.delete(key);
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
for (const v of value) {
|
||||||
|
mergedHeaders.append(key, v as string);
|
||||||
|
}
|
||||||
|
} else if (value !== undefined) {
|
||||||
|
// assume object headers are meant to be JSON stringified, i.e., their
|
||||||
|
// content value in OpenAPI specification is 'application/json'
|
||||||
|
mergedHeaders.set(
|
||||||
|
key,
|
||||||
|
typeof value === 'object' ? JSON.stringify(value) : (value as string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mergedHeaders;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ErrInterceptor<Err, Res, Req, Options> = (
|
||||||
|
error: Err,
|
||||||
|
/** response may be undefined due to a network error where no response object is produced */
|
||||||
|
response: Res | undefined,
|
||||||
|
/** request may be undefined, because error may be from building the request object itself */
|
||||||
|
request: Req | undefined,
|
||||||
|
options: Options,
|
||||||
|
) => Err | Promise<Err>;
|
||||||
|
|
||||||
|
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>;
|
||||||
|
|
||||||
|
type ResInterceptor<Res, Req, Options> = (
|
||||||
|
response: Res,
|
||||||
|
request: Req,
|
||||||
|
options: Options,
|
||||||
|
) => Res | Promise<Res>;
|
||||||
|
|
||||||
|
class Interceptors<Interceptor> {
|
||||||
|
fns: Array<Interceptor | null> = [];
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.fns = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
eject(id: number | Interceptor): void {
|
||||||
|
const index = this.getInterceptorIndex(id);
|
||||||
|
if (this.fns[index]) {
|
||||||
|
this.fns[index] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exists(id: number | Interceptor): boolean {
|
||||||
|
const index = this.getInterceptorIndex(id);
|
||||||
|
return Boolean(this.fns[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
getInterceptorIndex(id: number | Interceptor): number {
|
||||||
|
if (typeof id === 'number') {
|
||||||
|
return this.fns[id] ? id : -1;
|
||||||
|
}
|
||||||
|
return this.fns.indexOf(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
|
||||||
|
const index = this.getInterceptorIndex(id);
|
||||||
|
if (this.fns[index]) {
|
||||||
|
this.fns[index] = fn;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
use(fn: Interceptor): number {
|
||||||
|
this.fns.push(fn);
|
||||||
|
return this.fns.length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Middleware<Req, Res, Err, Options> {
|
||||||
|
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
|
||||||
|
request: Interceptors<ReqInterceptor<Req, Options>>;
|
||||||
|
response: Interceptors<ResInterceptor<Res, Req, Options>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
Err,
|
||||||
|
Options
|
||||||
|
> => ({
|
||||||
|
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
|
||||||
|
request: new Interceptors<ReqInterceptor<Req, Options>>(),
|
||||||
|
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultQuerySerializer = createQuerySerializer({
|
||||||
|
allowReserved: false,
|
||||||
|
array: {
|
||||||
|
explode: true,
|
||||||
|
style: 'form',
|
||||||
|
},
|
||||||
|
object: {
|
||||||
|
explode: true,
|
||||||
|
style: 'deepObject',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createConfig = <T extends ClientOptions = ClientOptions>(
|
||||||
|
override: Config<Omit<ClientOptions, keyof T> & T> = {},
|
||||||
|
): Config<Omit<ClientOptions, keyof T> & T> => ({
|
||||||
|
...jsonBodySerializer,
|
||||||
|
headers: defaultHeaders,
|
||||||
|
parseAs: 'auto',
|
||||||
|
querySerializer: defaultQuerySerializer,
|
||||||
|
...override,
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
export type AuthToken = string | undefined;
|
||||||
|
|
||||||
|
export interface Auth {
|
||||||
|
/**
|
||||||
|
* Which part of the request do we use to send the auth?
|
||||||
|
*
|
||||||
|
* @default 'header'
|
||||||
|
*/
|
||||||
|
in?: 'header' | 'query' | 'cookie';
|
||||||
|
/**
|
||||||
|
* Header or query parameter name.
|
||||||
|
*
|
||||||
|
* @default 'Authorization'
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
scheme?: 'basic' | 'bearer';
|
||||||
|
type: 'apiKey' | 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAuthToken = async (
|
||||||
|
auth: Auth,
|
||||||
|
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
const token = typeof callback === 'function' ? await callback(auth) : callback;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.scheme === 'bearer') {
|
||||||
|
return `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.scheme === 'basic') {
|
||||||
|
return `Basic ${btoa(token)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen';
|
||||||
|
|
||||||
|
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
export type BodySerializer = (body: unknown) => unknown;
|
||||||
|
|
||||||
|
type QuerySerializerOptionsObject = {
|
||||||
|
allowReserved?: boolean;
|
||||||
|
array?: Partial<SerializerOptions<ArrayStyle>>;
|
||||||
|
object?: Partial<SerializerOptions<ObjectStyle>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
|
||||||
|
/**
|
||||||
|
* Per-parameter serialization overrides. When provided, these settings
|
||||||
|
* override the global array/object settings for specific parameter names.
|
||||||
|
*/
|
||||||
|
parameters?: Record<string, QuerySerializerOptionsObject>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
|
||||||
|
if (typeof value === 'string' || value instanceof Blob) {
|
||||||
|
data.append(key, value);
|
||||||
|
} else if (value instanceof Date) {
|
||||||
|
data.append(key, value.toISOString());
|
||||||
|
} else {
|
||||||
|
data.append(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
data.append(key, value);
|
||||||
|
} else {
|
||||||
|
data.append(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formDataBodySerializer = {
|
||||||
|
bodySerializer: (body: unknown): FormData => {
|
||||||
|
const data = new FormData();
|
||||||
|
|
||||||
|
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((v) => serializeFormDataPair(data, key, v));
|
||||||
|
} else {
|
||||||
|
serializeFormDataPair(data, key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const jsonBodySerializer = {
|
||||||
|
bodySerializer: (body: unknown): string =>
|
||||||
|
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const urlSearchParamsBodySerializer = {
|
||||||
|
bodySerializer: (body: unknown): string => {
|
||||||
|
const data = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
||||||
|
} else {
|
||||||
|
serializeUrlSearchParamsPair(data, key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.toString();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
type Slot = 'body' | 'headers' | 'path' | 'query';
|
||||||
|
|
||||||
|
export type Field =
|
||||||
|
| {
|
||||||
|
in: Exclude<Slot, 'body'>;
|
||||||
|
/**
|
||||||
|
* Field name. This is the name we want the user to see and use.
|
||||||
|
*/
|
||||||
|
key: string;
|
||||||
|
/**
|
||||||
|
* Field mapped name. This is the name we want to use in the request.
|
||||||
|
* If omitted, we use the same value as `key`.
|
||||||
|
*/
|
||||||
|
map?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
in: Extract<Slot, 'body'>;
|
||||||
|
/**
|
||||||
|
* Key isn't required for bodies.
|
||||||
|
*/
|
||||||
|
key?: string;
|
||||||
|
map?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* Field name. This is the name we want the user to see and use.
|
||||||
|
*/
|
||||||
|
key: string;
|
||||||
|
/**
|
||||||
|
* Field mapped name. This is the name we want to use in the request.
|
||||||
|
* If `in` is omitted, `map` aliases `key` to the transport layer.
|
||||||
|
*/
|
||||||
|
map: Slot;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Fields {
|
||||||
|
allowExtra?: Partial<Record<Slot, boolean>>;
|
||||||
|
args?: ReadonlyArray<Field>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FieldsConfig = ReadonlyArray<Field | Fields>;
|
||||||
|
|
||||||
|
const extraPrefixesMap: Record<string, Slot> = {
|
||||||
|
$body_: 'body',
|
||||||
|
$headers_: 'headers',
|
||||||
|
$path_: 'path',
|
||||||
|
$query_: 'query',
|
||||||
|
};
|
||||||
|
const extraPrefixes = Object.entries(extraPrefixesMap);
|
||||||
|
|
||||||
|
type KeyMap = Map<
|
||||||
|
string,
|
||||||
|
| {
|
||||||
|
in: Slot;
|
||||||
|
map?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
in?: never;
|
||||||
|
map: Slot;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||||
|
if (!map) {
|
||||||
|
map = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const config of fields) {
|
||||||
|
if ('in' in config) {
|
||||||
|
if (config.key) {
|
||||||
|
map.set(config.key, {
|
||||||
|
in: config.in,
|
||||||
|
map: config.map,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if ('key' in config) {
|
||||||
|
map.set(config.key, {
|
||||||
|
map: config.map,
|
||||||
|
});
|
||||||
|
} else if (config.args) {
|
||||||
|
buildKeyMap(config.args, map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Params {
|
||||||
|
body: unknown;
|
||||||
|
headers: Record<string, unknown>;
|
||||||
|
path: Record<string, unknown>;
|
||||||
|
query: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripEmptySlots = (params: Params) => {
|
||||||
|
for (const [slot, value] of Object.entries(params)) {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) {
|
||||||
|
delete params[slot as Slot];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
|
||||||
|
const params: Params = {
|
||||||
|
body: {},
|
||||||
|
headers: {},
|
||||||
|
path: {},
|
||||||
|
query: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const map = buildKeyMap(fields);
|
||||||
|
|
||||||
|
let config: FieldsConfig[number] | undefined;
|
||||||
|
|
||||||
|
for (const [index, arg] of args.entries()) {
|
||||||
|
if (fields[index]) {
|
||||||
|
config = fields[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('in' in config) {
|
||||||
|
if (config.key) {
|
||||||
|
const field = map.get(config.key)!;
|
||||||
|
const name = field.map || config.key;
|
||||||
|
if (field.in) {
|
||||||
|
(params[field.in] as Record<string, unknown>)[name] = arg;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
params.body = arg;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const [key, value] of Object.entries(arg ?? {})) {
|
||||||
|
const field = map.get(key);
|
||||||
|
|
||||||
|
if (field) {
|
||||||
|
if (field.in) {
|
||||||
|
const name = field.map || key;
|
||||||
|
(params[field.in] as Record<string, unknown>)[name] = value;
|
||||||
|
} else {
|
||||||
|
params[field.map] = value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
|
||||||
|
|
||||||
|
if (extra) {
|
||||||
|
const [prefix, slot] = extra;
|
||||||
|
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
|
||||||
|
} else if ('allowExtra' in config && config.allowExtra) {
|
||||||
|
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
||||||
|
if (allowed) {
|
||||||
|
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stripEmptySlots(params);
|
||||||
|
|
||||||
|
return params;
|
||||||
|
};
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
|
||||||
|
|
||||||
|
interface SerializePrimitiveOptions {
|
||||||
|
allowReserved?: boolean;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializerOptions<T> {
|
||||||
|
/**
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
explode: boolean;
|
||||||
|
style: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
|
||||||
|
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
|
||||||
|
type MatrixStyle = 'label' | 'matrix' | 'simple';
|
||||||
|
export type ObjectStyle = 'form' | 'deepObject';
|
||||||
|
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
|
||||||
|
|
||||||
|
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
|
||||||
|
switch (style) {
|
||||||
|
case 'label':
|
||||||
|
return '.';
|
||||||
|
case 'matrix':
|
||||||
|
return ';';
|
||||||
|
case 'simple':
|
||||||
|
return ',';
|
||||||
|
default:
|
||||||
|
return '&';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
|
||||||
|
switch (style) {
|
||||||
|
case 'form':
|
||||||
|
return ',';
|
||||||
|
case 'pipeDelimited':
|
||||||
|
return '|';
|
||||||
|
case 'spaceDelimited':
|
||||||
|
return '%20';
|
||||||
|
default:
|
||||||
|
return ',';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
|
||||||
|
switch (style) {
|
||||||
|
case 'label':
|
||||||
|
return '.';
|
||||||
|
case 'matrix':
|
||||||
|
return ';';
|
||||||
|
case 'simple':
|
||||||
|
return ',';
|
||||||
|
default:
|
||||||
|
return '&';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeArrayParam = ({
|
||||||
|
allowReserved,
|
||||||
|
explode,
|
||||||
|
name,
|
||||||
|
style,
|
||||||
|
value,
|
||||||
|
}: SerializeOptions<ArraySeparatorStyle> & {
|
||||||
|
value: unknown[];
|
||||||
|
}) => {
|
||||||
|
if (!explode) {
|
||||||
|
const joinedValues = (
|
||||||
|
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
|
||||||
|
).join(separatorArrayNoExplode(style));
|
||||||
|
switch (style) {
|
||||||
|
case 'label':
|
||||||
|
return `.${joinedValues}`;
|
||||||
|
case 'matrix':
|
||||||
|
return `;${name}=${joinedValues}`;
|
||||||
|
case 'simple':
|
||||||
|
return joinedValues;
|
||||||
|
default:
|
||||||
|
return `${name}=${joinedValues}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = separatorArrayExplode(style);
|
||||||
|
const joinedValues = value
|
||||||
|
.map((v) => {
|
||||||
|
if (style === 'label' || style === 'simple') {
|
||||||
|
return allowReserved ? v : encodeURIComponent(v as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return serializePrimitiveParam({
|
||||||
|
allowReserved,
|
||||||
|
name,
|
||||||
|
value: v as string,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.join(separator);
|
||||||
|
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializePrimitiveParam = ({
|
||||||
|
allowReserved,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
}: SerializePrimitiveParam) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
throw new Error(
|
||||||
|
'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeObjectParam = ({
|
||||||
|
allowReserved,
|
||||||
|
explode,
|
||||||
|
name,
|
||||||
|
style,
|
||||||
|
value,
|
||||||
|
valueOnly,
|
||||||
|
}: SerializeOptions<ObjectSeparatorStyle> & {
|
||||||
|
value: Record<string, unknown> | Date;
|
||||||
|
valueOnly?: boolean;
|
||||||
|
}) => {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style !== 'deepObject' && !explode) {
|
||||||
|
let values: string[] = [];
|
||||||
|
Object.entries(value).forEach(([key, v]) => {
|
||||||
|
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
|
||||||
|
});
|
||||||
|
const joinedValues = values.join(',');
|
||||||
|
switch (style) {
|
||||||
|
case 'form':
|
||||||
|
return `${name}=${joinedValues}`;
|
||||||
|
case 'label':
|
||||||
|
return `.${joinedValues}`;
|
||||||
|
case 'matrix':
|
||||||
|
return `;${name}=${joinedValues}`;
|
||||||
|
default:
|
||||||
|
return joinedValues;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = separatorObjectExplode(style);
|
||||||
|
const joinedValues = Object.entries(value)
|
||||||
|
.map(([key, v]) =>
|
||||||
|
serializePrimitiveParam({
|
||||||
|
allowReserved,
|
||||||
|
name: style === 'deepObject' ? `${name}[${key}]` : key,
|
||||||
|
value: v as string,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join(separator);
|
||||||
|
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
|
||||||
|
};
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-friendly union that mirrors what Pinia Colada can hash.
|
||||||
|
*/
|
||||||
|
export type JsonValue =
|
||||||
|
| null
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| JsonValue[]
|
||||||
|
| { [key: string]: JsonValue };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
|
||||||
|
*/
|
||||||
|
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
|
||||||
|
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely stringifies a value and parses it back into a JsonValue.
|
||||||
|
*/
|
||||||
|
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(input, queryKeyJsonReplacer);
|
||||||
|
if (json === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return JSON.parse(json) as JsonValue;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects plain objects (including objects with a null prototype).
|
||||||
|
*/
|
||||||
|
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||||
|
if (value === null || typeof value !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const prototype = Object.getPrototypeOf(value as object);
|
||||||
|
return prototype === Object.prototype || prototype === null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
|
||||||
|
*/
|
||||||
|
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
||||||
|
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
const result: Record<string, JsonValue> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
const existing = result[key];
|
||||||
|
if (existing === undefined) {
|
||||||
|
result[key] = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(existing)) {
|
||||||
|
(existing as string[]).push(value);
|
||||||
|
} else {
|
||||||
|
result[key] = [existing, value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes any accepted value into a JSON-friendly shape for query keys.
|
||||||
|
*/
|
||||||
|
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return stringifyToJsonValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) {
|
||||||
|
return serializeSearchParams(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(value)) {
|
||||||
|
return stringifyToJsonValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { Config } from './types.gen';
|
||||||
|
|
||||||
|
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, 'method'> &
|
||||||
|
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
|
||||||
|
/**
|
||||||
|
* Fetch API implementation. You can use this option to provide a custom
|
||||||
|
* fetch instance.
|
||||||
|
*
|
||||||
|
* @default globalThis.fetch
|
||||||
|
*/
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
/**
|
||||||
|
* Implementing clients can call request interceptors inside this hook.
|
||||||
|
*/
|
||||||
|
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
|
||||||
|
/**
|
||||||
|
* Callback invoked when a network or parsing error occurs during streaming.
|
||||||
|
*
|
||||||
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
|
*
|
||||||
|
* @param error The error that occurred.
|
||||||
|
*/
|
||||||
|
onSseError?: (error: unknown) => void;
|
||||||
|
/**
|
||||||
|
* Callback invoked when an event is streamed from the server.
|
||||||
|
*
|
||||||
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
|
*
|
||||||
|
* @param event Event streamed from the server.
|
||||||
|
* @returns Nothing (void).
|
||||||
|
*/
|
||||||
|
onSseEvent?: (event: StreamEvent<TData>) => void;
|
||||||
|
serializedBody?: RequestInit['body'];
|
||||||
|
/**
|
||||||
|
* Default retry delay in milliseconds.
|
||||||
|
*
|
||||||
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
|
*
|
||||||
|
* @default 3000
|
||||||
|
*/
|
||||||
|
sseDefaultRetryDelay?: number;
|
||||||
|
/**
|
||||||
|
* Maximum number of retry attempts before giving up.
|
||||||
|
*/
|
||||||
|
sseMaxRetryAttempts?: number;
|
||||||
|
/**
|
||||||
|
* Maximum retry delay in milliseconds.
|
||||||
|
*
|
||||||
|
* Applies only when exponential backoff is used.
|
||||||
|
*
|
||||||
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
|
*
|
||||||
|
* @default 30000
|
||||||
|
*/
|
||||||
|
sseMaxRetryDelay?: number;
|
||||||
|
/**
|
||||||
|
* Optional sleep function for retry backoff.
|
||||||
|
*
|
||||||
|
* Defaults to using `setTimeout`.
|
||||||
|
*/
|
||||||
|
sseSleepFn?: (ms: number) => Promise<void>;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StreamEvent<TData = unknown> {
|
||||||
|
data: TData;
|
||||||
|
event?: string;
|
||||||
|
id?: string;
|
||||||
|
retry?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
|
||||||
|
stream: AsyncGenerator<
|
||||||
|
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
|
||||||
|
TReturn,
|
||||||
|
TNext
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createSseClient<TData = unknown>({
|
||||||
|
onRequest,
|
||||||
|
onSseError,
|
||||||
|
onSseEvent,
|
||||||
|
responseTransformer,
|
||||||
|
responseValidator,
|
||||||
|
sseDefaultRetryDelay,
|
||||||
|
sseMaxRetryAttempts,
|
||||||
|
sseMaxRetryDelay,
|
||||||
|
sseSleepFn,
|
||||||
|
url,
|
||||||
|
...options
|
||||||
|
}: ServerSentEventsOptions): ServerSentEventsResult<TData> {
|
||||||
|
let lastEventId: string | undefined;
|
||||||
|
|
||||||
|
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||||
|
|
||||||
|
const createStream = async function* () {
|
||||||
|
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
||||||
|
let attempt = 0;
|
||||||
|
const signal = options.signal ?? new AbortController().signal;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (signal.aborted) break;
|
||||||
|
|
||||||
|
attempt++;
|
||||||
|
|
||||||
|
const headers =
|
||||||
|
options.headers instanceof Headers
|
||||||
|
? options.headers
|
||||||
|
: new Headers(options.headers as Record<string, string> | undefined);
|
||||||
|
|
||||||
|
if (lastEventId !== undefined) {
|
||||||
|
headers.set('Last-Event-ID', lastEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestInit: RequestInit = {
|
||||||
|
redirect: 'follow',
|
||||||
|
...options,
|
||||||
|
body: options.serializedBody,
|
||||||
|
headers,
|
||||||
|
signal,
|
||||||
|
};
|
||||||
|
let request = new Request(url, requestInit);
|
||||||
|
if (onRequest) {
|
||||||
|
request = await onRequest(url, requestInit);
|
||||||
|
}
|
||||||
|
// fetch must be assigned here, otherwise it would throw the error:
|
||||||
|
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||||
|
const _fetch = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await _fetch(request);
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
|
||||||
|
|
||||||
|
if (!response.body) throw new Error('No body in SSE response');
|
||||||
|
|
||||||
|
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
||||||
|
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
const abortHandler = () => {
|
||||||
|
try {
|
||||||
|
reader.cancel();
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
signal.addEventListener('abort', abortHandler);
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += value;
|
||||||
|
buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings
|
||||||
|
|
||||||
|
const chunks = buffer.split('\n\n');
|
||||||
|
buffer = chunks.pop() ?? '';
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const lines = chunk.split('\n');
|
||||||
|
const dataLines: Array<string> = [];
|
||||||
|
let eventName: string | undefined;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
dataLines.push(line.replace(/^data:\s*/, ''));
|
||||||
|
} else if (line.startsWith('event:')) {
|
||||||
|
eventName = line.replace(/^event:\s*/, '');
|
||||||
|
} else if (line.startsWith('id:')) {
|
||||||
|
lastEventId = line.replace(/^id:\s*/, '');
|
||||||
|
} else if (line.startsWith('retry:')) {
|
||||||
|
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
retryDelay = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: unknown;
|
||||||
|
let parsedJson = false;
|
||||||
|
|
||||||
|
if (dataLines.length) {
|
||||||
|
const rawData = dataLines.join('\n');
|
||||||
|
try {
|
||||||
|
data = JSON.parse(rawData);
|
||||||
|
parsedJson = true;
|
||||||
|
} catch {
|
||||||
|
data = rawData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedJson) {
|
||||||
|
if (responseValidator) {
|
||||||
|
await responseValidator(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseTransformer) {
|
||||||
|
data = await responseTransformer(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSseEvent?.({
|
||||||
|
data,
|
||||||
|
event: eventName,
|
||||||
|
id: lastEventId,
|
||||||
|
retry: retryDelay,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dataLines.length) {
|
||||||
|
yield data as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
signal.removeEventListener('abort', abortHandler);
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
break; // exit loop on normal completion
|
||||||
|
} catch (error) {
|
||||||
|
// connection failed or aborted; retry after delay
|
||||||
|
onSseError?.(error);
|
||||||
|
|
||||||
|
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
|
||||||
|
break; // stop after firing error
|
||||||
|
}
|
||||||
|
|
||||||
|
// exponential backoff: double retry each attempt, cap at 30s
|
||||||
|
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
|
||||||
|
await sleep(backoff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = createStream();
|
||||||
|
|
||||||
|
return { stream };
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { Auth, AuthToken } from './auth.gen';
|
||||||
|
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen';
|
||||||
|
|
||||||
|
export type HttpMethod =
|
||||||
|
| 'connect'
|
||||||
|
| 'delete'
|
||||||
|
| 'get'
|
||||||
|
| 'head'
|
||||||
|
| 'options'
|
||||||
|
| 'patch'
|
||||||
|
| 'post'
|
||||||
|
| 'put'
|
||||||
|
| 'trace';
|
||||||
|
|
||||||
|
export type Client<
|
||||||
|
RequestFn = never,
|
||||||
|
Config = unknown,
|
||||||
|
MethodFn = never,
|
||||||
|
BuildUrlFn = never,
|
||||||
|
SseFn = never,
|
||||||
|
> = {
|
||||||
|
/**
|
||||||
|
* Returns the final request URL.
|
||||||
|
*/
|
||||||
|
buildUrl: BuildUrlFn;
|
||||||
|
getConfig: () => Config;
|
||||||
|
request: RequestFn;
|
||||||
|
setConfig: (config: Config) => Config;
|
||||||
|
} & {
|
||||||
|
[K in HttpMethod]: MethodFn;
|
||||||
|
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
/**
|
||||||
|
* Auth token or a function returning auth token. The resolved value will be
|
||||||
|
* added to the request payload as defined by its `security` array.
|
||||||
|
*/
|
||||||
|
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
||||||
|
/**
|
||||||
|
* A function for serializing request body parameter. By default,
|
||||||
|
* {@link JSON.stringify()} will be used.
|
||||||
|
*/
|
||||||
|
bodySerializer?: BodySerializer | null;
|
||||||
|
/**
|
||||||
|
* An object containing any HTTP headers that you want to pre-populate your
|
||||||
|
* `Headers` object with.
|
||||||
|
*
|
||||||
|
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||||
|
*/
|
||||||
|
headers?:
|
||||||
|
| RequestInit['headers']
|
||||||
|
| Record<
|
||||||
|
string,
|
||||||
|
string | number | boolean | (string | number | boolean)[] | null | undefined | unknown
|
||||||
|
>;
|
||||||
|
/**
|
||||||
|
* The request method.
|
||||||
|
*
|
||||||
|
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
||||||
|
*/
|
||||||
|
method?: Uppercase<HttpMethod>;
|
||||||
|
/**
|
||||||
|
* A function for serializing request query parameters. By default, arrays
|
||||||
|
* will be exploded in form style, objects will be exploded in deepObject
|
||||||
|
* style, and reserved characters are percent-encoded.
|
||||||
|
*
|
||||||
|
* This method will have no effect if the native `paramsSerializer()` Axios
|
||||||
|
* API function is used.
|
||||||
|
*
|
||||||
|
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
||||||
|
*/
|
||||||
|
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
||||||
|
/**
|
||||||
|
* A function validating request data. This is useful if you want to ensure
|
||||||
|
* the request conforms to the desired shape, so it can be safely sent to
|
||||||
|
* the server.
|
||||||
|
*/
|
||||||
|
requestValidator?: (data: unknown) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* A function transforming response data before it's returned. This is useful
|
||||||
|
* for post-processing data, e.g., converting ISO strings into Date objects.
|
||||||
|
*/
|
||||||
|
responseTransformer?: (data: unknown) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* A function validating response data. This is useful if you want to ensure
|
||||||
|
* the response conforms to the desired shape, so it can be safely passed to
|
||||||
|
* the transformers and returned to the user.
|
||||||
|
*/
|
||||||
|
responseValidator?: (data: unknown) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
|
||||||
|
? true
|
||||||
|
: [T] extends [never | undefined]
|
||||||
|
? [undefined] extends [T]
|
||||||
|
? false
|
||||||
|
: true
|
||||||
|
: false;
|
||||||
|
|
||||||
|
export type OmitNever<T extends Record<string, unknown>> = {
|
||||||
|
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K];
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
|
||||||
|
import {
|
||||||
|
type ArraySeparatorStyle,
|
||||||
|
serializeArrayParam,
|
||||||
|
serializeObjectParam,
|
||||||
|
serializePrimitiveParam,
|
||||||
|
} from './pathSerializer.gen';
|
||||||
|
|
||||||
|
export interface PathSerializer {
|
||||||
|
path: Record<string, unknown>;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
||||||
|
|
||||||
|
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||||
|
let url = _url;
|
||||||
|
const matches = _url.match(PATH_PARAM_RE);
|
||||||
|
if (matches) {
|
||||||
|
for (const match of matches) {
|
||||||
|
let explode = false;
|
||||||
|
let name = match.substring(1, match.length - 1);
|
||||||
|
let style: ArraySeparatorStyle = 'simple';
|
||||||
|
|
||||||
|
if (name.endsWith('*')) {
|
||||||
|
explode = true;
|
||||||
|
name = name.substring(0, name.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.startsWith('.')) {
|
||||||
|
name = name.substring(1);
|
||||||
|
style = 'label';
|
||||||
|
} else if (name.startsWith(';')) {
|
||||||
|
name = name.substring(1);
|
||||||
|
style = 'matrix';
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = path[name];
|
||||||
|
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
url = url.replace(
|
||||||
|
match,
|
||||||
|
serializeObjectParam({
|
||||||
|
explode,
|
||||||
|
name,
|
||||||
|
style,
|
||||||
|
value: value as Record<string, unknown>,
|
||||||
|
valueOnly: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style === 'matrix') {
|
||||||
|
url = url.replace(
|
||||||
|
match,
|
||||||
|
`;${serializePrimitiveParam({
|
||||||
|
name,
|
||||||
|
value: value as string,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaceValue = encodeURIComponent(
|
||||||
|
style === 'label' ? `.${value as string}` : (value as string),
|
||||||
|
);
|
||||||
|
url = url.replace(match, replaceValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUrl = ({
|
||||||
|
baseUrl,
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
querySerializer,
|
||||||
|
url: _url,
|
||||||
|
}: {
|
||||||
|
baseUrl?: string;
|
||||||
|
path?: Record<string, unknown>;
|
||||||
|
query?: Record<string, unknown>;
|
||||||
|
querySerializer: QuerySerializer;
|
||||||
|
url: string;
|
||||||
|
}) => {
|
||||||
|
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
|
||||||
|
let url = (baseUrl ?? '') + pathUrl;
|
||||||
|
if (path) {
|
||||||
|
url = defaultPathSerializer({ path, url });
|
||||||
|
}
|
||||||
|
let search = query ? querySerializer(query) : '';
|
||||||
|
if (search.startsWith('?')) {
|
||||||
|
search = search.substring(1);
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
url += `?${search}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getValidRequestBody(options: {
|
||||||
|
body?: unknown;
|
||||||
|
bodySerializer?: BodySerializer | null;
|
||||||
|
serializedBody?: unknown;
|
||||||
|
}) {
|
||||||
|
const hasBody = options.body !== undefined;
|
||||||
|
const isSerializedBody = hasBody && options.bodySerializer;
|
||||||
|
|
||||||
|
if (isSerializedBody) {
|
||||||
|
if ('serializedBody' in options) {
|
||||||
|
const hasSerializedBody =
|
||||||
|
options.serializedBody !== undefined && options.serializedBody !== '';
|
||||||
|
|
||||||
|
return hasSerializedBody ? options.serializedBody : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// not all clients implement a serializedBody property (i.e., client-axios)
|
||||||
|
return options.body !== '' ? options.body : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// plain/text body
|
||||||
|
if (hasBody) {
|
||||||
|
return options.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// no body was provided
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
export { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, gamesApiListGames, marketApiCloseMarket, marketApiCreateBet, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck } from './sdk.gen';
|
||||||
|
export type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, AnimationsApiTopSubmissionsResponses, ClientOptions, GameOut, GamesApiListGamesData, GamesApiListGamesResponse, GamesApiListGamesResponses, Input, LeaderboardEntryOut, LeaderboardOut, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponse, MarketApiResolveMarketResponses, MarketListSchema, MarketOptionSchema, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponse, NoitaApiSubmitLogFileResponses, NoitaSubmissionOut, ObjectivResultOut, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponse, OpusMagnumApiValidateSubmissionResponses, PagedSubmissionOut, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, PuzzlePointsFactorOut, PuzzleResponseIn, PuzzleResponseOut, PuzzleResponseRankingOut, PuzzleResultsOut, PuzzleSubmissionsOut, PuzzleSubmissionWithRankOut, RankingSchema, ResolveMarketSchema, ResultsOut, SteamCollectionItemOut, SteamCollectionOut, SubmissionFileOut, SubmissionIn, SubmissionOut, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserBetCreateSchema, UserBetSchema, UserDisplayOut, UserInfoOut, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.gen';
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
|
||||||
|
import { client } from './client.gen';
|
||||||
|
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses } from './types.gen';
|
||||||
|
|
||||||
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
|
||||||
|
/**
|
||||||
|
* You can provide a client instance returned by `createClient()` instead of
|
||||||
|
* individual options. This might be also useful if you want to implement a
|
||||||
|
* custom client.
|
||||||
|
*/
|
||||||
|
client?: Client;
|
||||||
|
/**
|
||||||
|
* You can pass arbitrary values through the `meta` object. This can be
|
||||||
|
* used to access values that aren't defined as part of the SDK function.
|
||||||
|
*/
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health Check
|
||||||
|
*
|
||||||
|
* Health check endpoint
|
||||||
|
*/
|
||||||
|
export const polylanSubmitterApiHealthCheck = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiHealthCheckData, ThrowOnError>) => (options?.client ?? client).get<PolylanSubmitterApiHealthCheckResponses, unknown, ThrowOnError>({ url: '/api/health', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear Cache
|
||||||
|
*
|
||||||
|
* Clear all API caches (admin only)
|
||||||
|
*/
|
||||||
|
export const polylanSubmitterApiClearCache = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiClearCacheData, ThrowOnError>) => (options?.client ?? client).post<PolylanSubmitterApiClearCacheResponses, unknown, ThrowOnError>({ url: '/api/cache/clear', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get User Info
|
||||||
|
*
|
||||||
|
* Get current user information
|
||||||
|
*/
|
||||||
|
export const polylanSubmitterApiGetUserInfo = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiGetUserInfoData, ThrowOnError>) => (options?.client ?? client).get<PolylanSubmitterApiGetUserInfoResponses, unknown, ThrowOnError>({ url: '/api/user', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Puzzles
|
||||||
|
*
|
||||||
|
* Get list of available puzzles
|
||||||
|
*/
|
||||||
|
export const opusMagnumApiListPuzzles = <ThrowOnError extends boolean = false>(options?: Options<OpusMagnumApiListPuzzlesData, ThrowOnError>) => (options?.client ?? client).get<OpusMagnumApiListPuzzlesResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/puzzles', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Collection
|
||||||
|
*
|
||||||
|
* Get the active collection details
|
||||||
|
*/
|
||||||
|
export const opusMagnumApiGetCollection = <ThrowOnError extends boolean = false>(options?: Options<OpusMagnumApiGetCollectionData, ThrowOnError>) => (options?.client ?? client).get<OpusMagnumApiGetCollectionResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/collection', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Submissions
|
||||||
|
*
|
||||||
|
* Get paginated list of submissions
|
||||||
|
*/
|
||||||
|
export const opusMagnumApiListSubmissions = <ThrowOnError extends boolean = false>(options?: Options<OpusMagnumApiListSubmissionsData, ThrowOnError>) => (options?.client ?? client).get<OpusMagnumApiListSubmissionsResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/submissions', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Submission
|
||||||
|
*
|
||||||
|
* Create a new submission with multiple puzzle responses
|
||||||
|
*/
|
||||||
|
export const opusMagnumApiCreateSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiCreateSubmissionData, ThrowOnError>) => (options.client ?? client).post<OpusMagnumApiCreateSubmissionResponses, unknown, ThrowOnError>({
|
||||||
|
...formDataBodySerializer,
|
||||||
|
url: '/api/opus-magnum/submissions',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': null,
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Submission
|
||||||
|
*
|
||||||
|
* Delete a submission (admin only)
|
||||||
|
*/
|
||||||
|
export const opusMagnumApiDeleteSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiDeleteSubmissionData, ThrowOnError>) => (options.client ?? client).delete<OpusMagnumApiDeleteSubmissionResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/submissions/{submission_id}', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Submission
|
||||||
|
*
|
||||||
|
* Get detailed submission by ID
|
||||||
|
*/
|
||||||
|
export const opusMagnumApiGetSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiGetSubmissionData, ThrowOnError>) => (options.client ?? client).get<OpusMagnumApiGetSubmissionResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/submissions/{submission_id}', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Response
|
||||||
|
*
|
||||||
|
* Manually validate a puzzle response
|
||||||
|
*/
|
||||||
|
export const opusMagnumApiValidateResponse = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateResponseData, ThrowOnError>) => (options.client ?? client).put<OpusMagnumApiValidateResponseResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/opus-magnum/responses/{response_id}/validate',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Auto
|
||||||
|
*
|
||||||
|
* Try to auto validate a puzzle response
|
||||||
|
*/
|
||||||
|
export const opusMagnumApiValidateAuto = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateAutoData, ThrowOnError>) => (options.client ?? client).put<OpusMagnumApiValidateAutoResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/responses/{response_id}/validate/auto', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Responses Needing Validation
|
||||||
|
*
|
||||||
|
* Get all responses that need manual validation
|
||||||
|
*/
|
||||||
|
export const opusMagnumApiListResponsesNeedingValidation = <ThrowOnError extends boolean = false>(options?: Options<OpusMagnumApiListResponsesNeedingValidationData, ThrowOnError>) => (options?.client ?? client).get<OpusMagnumApiListResponsesNeedingValidationResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/responses/needs-validation', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Submission
|
||||||
|
*
|
||||||
|
* Mark entire submission as validated
|
||||||
|
*/
|
||||||
|
export const opusMagnumApiValidateSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateSubmissionData, ThrowOnError>) => (options.client ?? client).post<OpusMagnumApiValidateSubmissionResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/submissions/{submission_id}/validate', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Stats
|
||||||
|
*
|
||||||
|
* Get submission statistics
|
||||||
|
*/
|
||||||
|
export const opusMagnumApiGetStats = <ThrowOnError extends boolean = false>(options?: Options<OpusMagnumApiGetStatsData, ThrowOnError>) => (options?.client ?? client).get<OpusMagnumApiGetStatsResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/stats', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Results
|
||||||
|
*/
|
||||||
|
export const animationsApiResults = <ThrowOnError extends boolean = false>(options?: Options<AnimationsApiResultsData, ThrowOnError>) => (options?.client ?? client).get<AnimationsApiResultsResponses, unknown, ThrowOnError>({ url: '/api/results/results', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top Submissions
|
||||||
|
*
|
||||||
|
* Get tournament top submissions for each puzzle. Only available when tournament is closed.
|
||||||
|
*/
|
||||||
|
export const animationsApiTopSubmissions = <ThrowOnError extends boolean = false>(options?: Options<AnimationsApiTopSubmissionsData, ThrowOnError>) => (options?.client ?? client).get<AnimationsApiTopSubmissionsResponses, unknown, ThrowOnError>({ url: '/api/results/top-submissions', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puzzle Results
|
||||||
|
*
|
||||||
|
* Get tournament results organized by puzzle with coefficients. Only available when tournament is closed.
|
||||||
|
*/
|
||||||
|
export const animationsApiPuzzleResults = <ThrowOnError extends boolean = false>(options?: Options<AnimationsApiPuzzleResultsData, ThrowOnError>) => (options?.client ?? client).get<AnimationsApiPuzzleResultsResponses, unknown, ThrowOnError>({ url: '/api/results/puzzle-results', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Results
|
||||||
|
*/
|
||||||
|
export const noitaApiGetResults = <ThrowOnError extends boolean = false>(options?: Options<NoitaApiGetResultsData, ThrowOnError>) => (options?.client ?? client).get<NoitaApiGetResultsResponses, unknown, ThrowOnError>({ url: '/api/noita/results', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Leaderboard
|
||||||
|
*
|
||||||
|
* Get the global leaderboard for all users ranked by total score.
|
||||||
|
*
|
||||||
|
* Uses Window functions to rank users by their total score in descending order.
|
||||||
|
*/
|
||||||
|
export const noitaApiGetLeaderboard = <ThrowOnError extends boolean = false>(options?: Options<NoitaApiGetLeaderboardData, ThrowOnError>) => (options?.client ?? client).get<NoitaApiGetLeaderboardResponses, unknown, ThrowOnError>({ url: '/api/noita/leaderboard', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit Log File
|
||||||
|
*
|
||||||
|
* Submit a Noita run file (log file, screenshot, or video).
|
||||||
|
*
|
||||||
|
* Accepts:
|
||||||
|
* - Text files (.txt) for polylan_mod_log.txt
|
||||||
|
* - Images (.png, .jpg, .gif)
|
||||||
|
* - Videos (.mp4, .webm)
|
||||||
|
*
|
||||||
|
* Max file size: 256 MB
|
||||||
|
*/
|
||||||
|
export const noitaApiSubmitLogFile = <ThrowOnError extends boolean = false>(options: Options<NoitaApiSubmitLogFileData, ThrowOnError>) => (options.client ?? client).post<NoitaApiSubmitLogFileResponses, NoitaApiSubmitLogFileErrors, ThrowOnError>({
|
||||||
|
...formDataBodySerializer,
|
||||||
|
url: '/api/noita/submit',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': null,
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Games
|
||||||
|
*/
|
||||||
|
export const gamesApiListGames = <ThrowOnError extends boolean = false>(options?: Options<GamesApiListGamesData, ThrowOnError>) => (options?.client ?? client).get<GamesApiListGamesResponses, unknown, ThrowOnError>({ url: '/api/games/', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Markets
|
||||||
|
*
|
||||||
|
* List all markets (excludes draft markets).
|
||||||
|
*/
|
||||||
|
export const marketApiListMarkets = <ThrowOnError extends boolean = false>(options?: Options<MarketApiListMarketsData, ThrowOnError>) => (options?.client ?? client).get<MarketApiListMarketsResponses, unknown, ThrowOnError>({ url: '/api/market/', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List User Bets
|
||||||
|
*
|
||||||
|
* List all bets placed by the current user.
|
||||||
|
*/
|
||||||
|
export const marketApiListUserBets = <ThrowOnError extends boolean = false>(options?: Options<MarketApiListUserBetsData, ThrowOnError>) => (options?.client ?? client).get<MarketApiListUserBetsResponses, unknown, ThrowOnError>({ url: '/api/market/user/bets', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close Market
|
||||||
|
*
|
||||||
|
* Close a market. Admin only.
|
||||||
|
*/
|
||||||
|
export const marketApiCloseMarket = <ThrowOnError extends boolean = false>(options: Options<MarketApiCloseMarketData, ThrowOnError>) => (options.client ?? client).post<MarketApiCloseMarketResponses, unknown, ThrowOnError>({ url: '/api/market/{market_uuid}/actions/close', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve Market
|
||||||
|
*
|
||||||
|
* Resolve a market with a winning option. Admin only.
|
||||||
|
*/
|
||||||
|
export const marketApiResolveMarket = <ThrowOnError extends boolean = false>(options: Options<MarketApiResolveMarketData, ThrowOnError>) => (options.client ?? client).post<MarketApiResolveMarketResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/market/{market_uuid}/actions/resolve',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Bet
|
||||||
|
*
|
||||||
|
* Place a bet on a market option.
|
||||||
|
*/
|
||||||
|
export const marketApiCreateBet = <ThrowOnError extends boolean = false>(options: Options<MarketApiCreateBetData, ThrowOnError>) => (options.client ?? client).post<MarketApiCreateBetResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/market/{market_uuid}/bets',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,338 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { marketApiCreateBet, marketApiCloseMarket, marketApiResolveMarket } from "../api";
|
||||||
|
import { useMarketStore } from "../stores/market";
|
||||||
|
import type { Market, MarketOption } from "../types";
|
||||||
|
import type { UserBetSchema } from "../api/types.gen";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
market: Market;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
refresh: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const marketStore = useMarketStore();
|
||||||
|
const { userInfo, userBets } = storeToRefs(marketStore);
|
||||||
|
|
||||||
|
const selectedOption = ref<string | null>(null);
|
||||||
|
const betAmount = ref<number>(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string>("");
|
||||||
|
const existingBet = ref<UserBetSchema | null>(null);
|
||||||
|
const showResolveModal = ref(false);
|
||||||
|
const selectedWinningOption = ref<string | null>(null);
|
||||||
|
const resolveLoading = ref(false);
|
||||||
|
|
||||||
|
const timeRemaining = computed(() => {
|
||||||
|
const endDate = new Date(props.market.end_date).getTime();
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const diff = endDate - now;
|
||||||
|
|
||||||
|
if (diff <= 0) return "Ended";
|
||||||
|
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
|
||||||
|
if (days > 0) return `${days}d ${hours}h`;
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
return `${minutes}m`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusColor = computed(() => {
|
||||||
|
switch (props.market.status) {
|
||||||
|
case "draft":
|
||||||
|
return "badge-secondary";
|
||||||
|
case "open":
|
||||||
|
return "badge-success";
|
||||||
|
case "closed":
|
||||||
|
return "badge-warning";
|
||||||
|
case "resolved":
|
||||||
|
return "badge-info";
|
||||||
|
default:
|
||||||
|
return "badge-ghost";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const canBet = computed(() => {
|
||||||
|
return props.market.status === "open" && userInfo.value?.is_authenticated && selectedOption.value && betAmount.value > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPot = computed(() => {
|
||||||
|
return props.market.options.reduce((sum, opt) => sum + opt.total_bets, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getMultiplier = (option: MarketOption) => {
|
||||||
|
if (option.total_bets === 0) return 0;
|
||||||
|
return totalPot.value / option.total_bets;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPotentialGain = (option: MarketOption) => {
|
||||||
|
if (!existingBet.value || existingBet.value.option.uuid !== option.uuid) return 0;
|
||||||
|
const multiplier = getMultiplier(option);
|
||||||
|
return Math.round(existingBet.value.amount * multiplier * props.market.multiplier);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMarket = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await marketApiCloseMarket({
|
||||||
|
path: { market_uuid: props.market.uuid },
|
||||||
|
});
|
||||||
|
emit("refresh");
|
||||||
|
} catch (e) {
|
||||||
|
error.value = "Error closing market";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveMarket = async () => {
|
||||||
|
if (!selectedWinningOption.value) {
|
||||||
|
error.value = "Please select a winning option";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveLoading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await marketApiResolveMarket({
|
||||||
|
path: { market_uuid: props.market.uuid },
|
||||||
|
body: { winning_option_uuid: selectedWinningOption.value },
|
||||||
|
});
|
||||||
|
emit("refresh");
|
||||||
|
showResolveModal.value = false;
|
||||||
|
selectedWinningOption.value = null;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as any;
|
||||||
|
if (typeof err === 'object' && err?.detail) {
|
||||||
|
error.value = err.detail;
|
||||||
|
} else if (typeof err === 'string') {
|
||||||
|
error.value = err;
|
||||||
|
} else {
|
||||||
|
error.value = "Error resolving market";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
resolveLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeBet = async () => {
|
||||||
|
if (!selectedOption.value || !betAmount.value) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await marketApiCreateBet({
|
||||||
|
path: {
|
||||||
|
market_uuid: props.market.uuid
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
option_uuid: selectedOption.value,
|
||||||
|
amount: betAmount.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.error) {
|
||||||
|
// Reload user bets to reflect the new bet
|
||||||
|
await marketStore.loadUserBets();
|
||||||
|
updateExistingBet();
|
||||||
|
betAmount.value = 0;
|
||||||
|
} else {
|
||||||
|
const err = response.error as any;
|
||||||
|
if (typeof err === 'object' && err?.detail) {
|
||||||
|
error.value = err.detail;
|
||||||
|
} else if (typeof err === 'string') {
|
||||||
|
error.value = err;
|
||||||
|
} else {
|
||||||
|
error.value = "Failed to place bet";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = "Error placing bet";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Update existing bet when component mounts or userBets changes
|
||||||
|
updateExistingBet();
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateExistingBet = () => {
|
||||||
|
if (userBets.value) {
|
||||||
|
existingBet.value = userBets.value.find(bet => bet.market?.uuid === props.market.uuid) || null;
|
||||||
|
if (existingBet.value) {
|
||||||
|
selectedOption.value = existingBet.value.option.uuid;
|
||||||
|
betAmount.value = existingBet.value.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="card-body pb-3">
|
||||||
|
<div class="flex justify-between items-start gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="card-title text-2xl mb-2">{{ market.title }}</h2>
|
||||||
|
<p class="text-sm text-base-content/70">{{ market.description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-end gap-2">
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div :class="['badge', statusColor, 'text-white']">
|
||||||
|
{{ market.status }}
|
||||||
|
</div>
|
||||||
|
<div v-if="market.multiplier > 1" class="badge badge-accent text-white font-bold">
|
||||||
|
{{ market.multiplier }}x
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="market.status === 'open' || market.status === 'closed'" class="text-sm text-base-content/60 text-right">
|
||||||
|
<div>{{ timeRemaining }}</div>
|
||||||
|
<div class="text-xs">until close</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider my-0"></div>
|
||||||
|
|
||||||
|
<!-- Admin Actions -->
|
||||||
|
<div v-if="userInfo?.is_superuser" class="card-body py-4 bg-base-100">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button v-if="market.status === 'open'" @click="closeMarket" :disabled="loading" class="btn btn-sm btn-warning">
|
||||||
|
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
|
||||||
|
<span v-else>Close Market</span>
|
||||||
|
</button>
|
||||||
|
<button v-if="market.status === 'closed'" @click="showResolveModal = true" class="btn btn-sm btn-success">
|
||||||
|
Resolve Market
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resolve Modal -->
|
||||||
|
<dialog v-if="showResolveModal" class="modal modal-open">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg mb-4">Resolve Market - Select Winner</h3>
|
||||||
|
|
||||||
|
<div class="space-y-2 mb-4">
|
||||||
|
<div v-for="option in market.options" :key="option.uuid" class="form-control">
|
||||||
|
<label class="label cursor-pointer border rounded-lg p-3 hover:bg-base-200 transition w-full flex justify-between">
|
||||||
|
<span class="label-text font-medium">{{ option.text }}</span>
|
||||||
|
<input type="radio" :value="option.uuid" v-model="selectedWinningOption" class="radio radio-primary" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-error mb-4">
|
||||||
|
<i class="mdi mdi-alert-circle"></i>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button @click="showResolveModal = false" class="btn" :disabled="resolveLoading">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button @click="resolveMarket" :disabled="!selectedWinningOption || resolveLoading" class="btn btn-primary">
|
||||||
|
<span v-if="resolveLoading" class="loading loading-spinner loading-sm"></span>
|
||||||
|
<span v-else>Resolve & Distribute Points</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop" @click="showResolveModal = false"></div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Options -->
|
||||||
|
<div class="card-body py-4">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div v-for="option in market.options" :key="option.uuid" class="form-control">
|
||||||
|
<label :class="[
|
||||||
|
'label cursor-pointer border rounded-lg p-3 hover:bg-base-200 transition h-full w-full',
|
||||||
|
existingBet && existingBet.option.uuid === option.uuid ? 'border-primary border-2 bg-primary/5' : ''
|
||||||
|
]">
|
||||||
|
<div class="flex flex-col gap-2 flex-1">
|
||||||
|
<span class="label-text font-medium">{{ option.text }}</span>
|
||||||
|
<div class="text-xs text-base-content/60">
|
||||||
|
<div>Pool: {{ option.total_bets }} pts</div>
|
||||||
|
<div v-if="option.total_bets > 0">Multiplier: {{ getMultiplier(option).toFixed(2) }}x</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="existingBet && existingBet.option.uuid === option.uuid" class="flex flex-col gap-1">
|
||||||
|
<span class="badge badge-primary badge-sm w-fit">
|
||||||
|
Your bet: {{ existingBet.amount }} pts
|
||||||
|
</span>
|
||||||
|
<span v-if="option.total_bets > 0" class="badge badge-success badge-sm w-fit">
|
||||||
|
Potential: {{ getPotentialGain(option) }} pts
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="radio" :value="option.uuid" v-model="selectedOption" class="radio radio-primary"
|
||||||
|
:disabled="market.status !== 'open' || !!(existingBet && existingBet.option.uuid !== option.uuid)" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bet Input -->
|
||||||
|
<div v-if="market.status === 'open' && userInfo?.is_authenticated && selectedOption" class="card-body py-4">
|
||||||
|
<div class="form-control gap-3">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">
|
||||||
|
<span v-if="existingBet">Increase bet (current: {{ existingBet.amount }} pts)</span>
|
||||||
|
<span v-else>Points to bet</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input v-model.number="betAmount" type="number"
|
||||||
|
:placeholder="existingBet ? `Enter amount to increase by` : 'Enter points'" class="input input-bordered"
|
||||||
|
min="1" :disabled="loading" />
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-error">
|
||||||
|
<i class="mdi mdi-alert-circle"></i>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="placeBet" :disabled="!canBet || loading" class="btn btn-primary">
|
||||||
|
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
|
||||||
|
<span v-else-if="existingBet">Increase Bet</span>
|
||||||
|
<span v-else>Place Bet</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Show existing bet (market closed/resolved) -->
|
||||||
|
<div v-else-if="market.status !== 'open' && existingBet" class="card-body py-4 bg-base-200">
|
||||||
|
<div class="text-sm">
|
||||||
|
<div class="font-semibold">Your Bet</div>
|
||||||
|
<div class="text-base-content/70 mt-1">
|
||||||
|
Option: <span class="font-semibold">{{ existingBet.option.text }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-base-content/70">
|
||||||
|
Amount: <span class="font-semibold">{{ existingBet.amount }} pts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result -->
|
||||||
|
<div v-else-if="market.status === 'resolved' && market.winning_option" class="card-body py-4 bg-base-200">
|
||||||
|
<div class="text-sm">
|
||||||
|
<div class="font-semibold"
|
||||||
|
:class="existingBet && existingBet.option.uuid === market.winning_option.uuid ? 'text-success' : 'text-error'">
|
||||||
|
<span v-if="existingBet && existingBet.option.uuid === market.winning_option.uuid">✓ You Won!</span>
|
||||||
|
<span v-else-if="existingBet">✗ You Lost</span>
|
||||||
|
<span v-else>Resolved</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-base-content/70 mt-1">
|
||||||
|
Winner: <span class="font-semibold">{{ market.winning_option.text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user