Compare commits
18 Commits
9f94fb3974
...
47812ffd09
| Author | SHA1 | Date | |
|---|---|---|---|
| 47812ffd09 | |||
| a5fe8aacaf | |||
| b437210eb3 | |||
| 5584e54b58 | |||
| 35ea54ecea | |||
| 821e453bc0 | |||
| 9fd0122a67 | |||
| e557fe2cda | |||
| 79e7cef3ba | |||
| a264336bd8 | |||
| 42e3571fab | |||
| 43b314bb20 | |||
| f1afb2096f | |||
| 62a81e57ad | |||
| 303b9e1c8a | |||
| f7c7eba4da | |||
| ce30539808 | |||
| 544112b204 |
@ -34,3 +34,6 @@ class CustomUserAdmin(UserAdmin):
|
||||
return obj.get_cas_groups_display()
|
||||
|
||||
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
|
||||
cas_attributes = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Market points balance
|
||||
points = models.IntegerField(default=1000)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} ({self.cas_user_id})"
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ from animations.schemas import (
|
||||
WinnerResponseOut,
|
||||
WinnerFileOut,
|
||||
)
|
||||
from submissions.models import PuzzleResponse, SteamCollectionItem, SteamCollection
|
||||
from opus_magnum.models import PuzzleResponse, SteamCollectionItem, SteamCollection
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
from ninja import ModelSchema, Schema
|
||||
from typing import List, Optional
|
||||
|
||||
from submissions.models import PuzzleResponse
|
||||
from submissions.schemas import SteamCollectionItemOut
|
||||
from opus_magnum.models import PuzzleResponse
|
||||
from opus_magnum.schemas import SteamCollectionItemOut
|
||||
|
||||
|
||||
class PuzzleResponseRankingOut(ModelSchema):
|
||||
|
||||
11
polylan_submitter/games/admin.py
Normal file
11
polylan_submitter/games/admin.py
Normal file
@ -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"]
|
||||
13
polylan_submitter/games/api.py
Normal file
13
polylan_submitter/games/api.py
Normal file
@ -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
|
||||
|
||||
|
||||
class SubmissionsConfig(AppConfig):
|
||||
class GamesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "submissions"
|
||||
name = "games"
|
||||
22
polylan_submitter/games/decorators.py
Normal file
22
polylan_submitter/games/decorators.py
Normal file
@ -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
|
||||
32
polylan_submitter/games/migrations/0001_initial.py
Normal file
32
polylan_submitter/games/migrations/0001_initial.py
Normal file
@ -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),
|
||||
]
|
||||
31
polylan_submitter/games/migrations/0003_game_path.py
Normal file
31
polylan_submitter/games/migrations/0003_game_path.py
Normal file
@ -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),
|
||||
]
|
||||
17
polylan_submitter/games/models.py
Normal file
17
polylan_submitter/games/models.py
Normal file
@ -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})"
|
||||
7
polylan_submitter/games/schemas.py
Normal file
7
polylan_submitter/games/schemas.py
Normal file
@ -0,0 +1,7 @@
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class GameOut(Schema):
|
||||
steam_app_id: int
|
||||
name: str
|
||||
path: str
|
||||
92
polylan_submitter/market/admin.py
Normal file
92
polylan_submitter/market/admin.py
Normal file
@ -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
|
||||
184
polylan_submitter/market/api.py
Normal file
184
polylan_submitter/market/api.py
Normal file
@ -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
|
||||
6
polylan_submitter/market/apps.py
Normal file
6
polylan_submitter/market/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MarketConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "market"
|
||||
187
polylan_submitter/market/migrations/0001_initial.py
Normal file
187
polylan_submitter/market/migrations/0001_initial.py
Normal file
@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
73
polylan_submitter/market/migrations/0002_userpointchange.py
Normal file
73
polylan_submitter/market/migrations/0002_userpointchange.py
Normal file
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
114
polylan_submitter/market/models.py
Normal file
114
polylan_submitter/market/models.py
Normal file
@ -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}"
|
||||
59
polylan_submitter/market/schemas.py
Normal file
59
polylan_submitter/market/schemas.py
Normal file
@ -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
|
||||
8
polylan_submitter/market/views.py
Normal file
8
polylan_submitter/market/views.py
Normal file
@ -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", {})
|
||||
@ -12,18 +12,22 @@ from django.db.models import (
|
||||
)
|
||||
from ninja import Router, File
|
||||
from ninja.files import UploadedFile
|
||||
from ninja.decorators import decorate_view
|
||||
|
||||
from noita.schemas import ResultsOut, LeaderboardOut
|
||||
from noita.services.objectives import parse_objectives_and_store
|
||||
from games.decorators import require_game_enabled
|
||||
|
||||
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
||||
from .schemas import NoitaSubmissionOut
|
||||
|
||||
|
||||
router = Router()
|
||||
NOITA_APP_ID = 881100
|
||||
|
||||
|
||||
@router.get("results", response=ResultsOut)
|
||||
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||
def get_results(request: HttpRequest):
|
||||
cache_key = f"api:noita:results:{request.user.id}"
|
||||
cached_data = cache.get(cache_key)
|
||||
@ -127,6 +131,7 @@ def get_results(request: HttpRequest):
|
||||
|
||||
|
||||
@router.get("leaderboard", response=LeaderboardOut)
|
||||
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||
def get_leaderboard(request: HttpRequest):
|
||||
"""
|
||||
Get the global leaderboard for all users ranked by total score.
|
||||
@ -232,6 +237,7 @@ def get_leaderboard(request: HttpRequest):
|
||||
|
||||
|
||||
@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(...)):
|
||||
"""
|
||||
Submit a Noita run file (log file, screenshot, or video).
|
||||
|
||||
@ -72,7 +72,6 @@ POINTS = {
|
||||
"NOLLA": 10,
|
||||
"CHAOTIC_TRANSMUTATION": 10,
|
||||
"DUPLICATE": 5,
|
||||
"OMEGA": 10,
|
||||
"BURST_2": 10,
|
||||
"BURST_3": 15,
|
||||
"BURST_4": 20,
|
||||
|
||||
6
polylan_submitter/openapi-ts.config.ts
Normal file
6
polylan_submitter/openapi-ts.config.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { defineConfig } from '@hey-api/openapi-ts';
|
||||
|
||||
export default defineConfig({
|
||||
input: `http://localhost:7777/api/openapi.json`,
|
||||
output: 'src/api/',
|
||||
});
|
||||
0
polylan_submitter/opus_magnum/__init__.py
Normal file
0
polylan_submitter/opus_magnum/__init__.py
Normal file
@ -1,7 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.utils import timezone
|
||||
from submissions.models import (
|
||||
from opus_magnum.models import (
|
||||
SteamAPIKey,
|
||||
SteamCollection,
|
||||
SteamCollectionItem,
|
||||
@ -9,7 +9,9 @@ from django.utils import timezone
|
||||
from django.shortcuts import get_object_or_404
|
||||
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,
|
||||
@ -28,9 +30,11 @@ from .schemas import (
|
||||
)
|
||||
|
||||
router = Router()
|
||||
OPUS_MAGNUM_APP_ID = 558990
|
||||
|
||||
|
||||
@router.get("/puzzles", response=List[SteamCollectionItemOut])
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def list_puzzles(request):
|
||||
"""Get list of available puzzles"""
|
||||
return SteamCollectionItem.objects.select_related("collection").filter(
|
||||
@ -39,6 +43,7 @@ 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)
|
||||
@ -46,6 +51,7 @@ def get_collection(request):
|
||||
|
||||
|
||||
@router.get("/submissions", response=List[SubmissionOut])
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
@paginate
|
||||
def list_submissions(request):
|
||||
"""Get paginated list of submissions"""
|
||||
@ -55,6 +61,7 @@ def list_submissions(request):
|
||||
|
||||
|
||||
@router.get("/submissions/{submission_id}", response=SubmissionOut)
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def get_submission(request, submission_id: str):
|
||||
"""Get detailed submission by ID"""
|
||||
return get_object_or_404(
|
||||
@ -66,6 +73,7 @@ def get_submission(request, submission_id: str):
|
||||
|
||||
|
||||
@router.post("/submissions", response=SubmissionOut)
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def create_submission(
|
||||
request, data: SubmissionIn, files: List[UploadedFile] = File(...)
|
||||
):
|
||||
@ -198,6 +206,7 @@ def create_submission(
|
||||
|
||||
|
||||
@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):
|
||||
"""Manually validate a puzzle response"""
|
||||
|
||||
@ -233,6 +242,7 @@ def validate_response(request, response_id: int, data: ValidationIn):
|
||||
|
||||
|
||||
@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):
|
||||
"""Try to auto validate a puzzle response"""
|
||||
|
||||
@ -248,6 +258,7 @@ def validate_auto(request, response_id: int):
|
||||
|
||||
|
||||
@router.get("/responses/needs-validation", response=List[PuzzleResponseOut])
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def list_responses_needing_validation(request):
|
||||
"""Get all responses that need manual validation"""
|
||||
|
||||
@ -263,6 +274,7 @@ def list_responses_needing_validation(request):
|
||||
|
||||
|
||||
@router.post("/submissions/{submission_id}/validate", response=SubmissionOut)
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def validate_submission(request, submission_id: str):
|
||||
"""Mark entire submission as validated"""
|
||||
|
||||
@ -291,6 +303,7 @@ def validate_submission(request, submission_id: str):
|
||||
|
||||
|
||||
@router.delete("/submissions/{submission_id}")
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def delete_submission(request, submission_id: str):
|
||||
"""Delete a submission (admin only)"""
|
||||
|
||||
@ -307,6 +320,7 @@ def delete_submission(request, submission_id: str):
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def get_stats(request):
|
||||
"""Get submission statistics"""
|
||||
|
||||
6
polylan_submitter/opus_magnum/apps.py
Normal file
6
polylan_submitter/opus_magnum/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OpusMagnumConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "opus_magnum"
|
||||
@ -3,8 +3,8 @@ Django management command to fetch Steam Workshop collections
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from submissions.utils import create_or_update_collection
|
||||
from submissions.models import SteamAPIKey, SteamCollection
|
||||
from opus_magnum.utils import create_or_update_collection
|
||||
from opus_magnum.models import SteamAPIKey, SteamCollection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@ -34,7 +34,7 @@ class Command(BaseCommand):
|
||||
|
||||
try:
|
||||
# Check if collection already exists
|
||||
from submissions.utils import SteamCollectionFetcher
|
||||
from opus_magnum.utils import SteamCollectionFetcher
|
||||
|
||||
fetcher = SteamCollectionFetcher(api_key.api_key)
|
||||
collection_id = fetcher.extract_collection_id(url)
|
||||
@ -1,6 +1,6 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from submissions.utils import verify_and_validate_ocr_date_for_submission
|
||||
from submissions.models import SubmissionFile
|
||||
from opus_magnum.utils import verify_and_validate_ocr_date_for_submission
|
||||
from opus_magnum.models import SubmissionFile
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@ -205,7 +205,7 @@ class Migration(migrations.Migration):
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="items",
|
||||
to="submissions.steamcollection",
|
||||
to="opus_magnum.steamcollection",
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -5,7 +5,7 @@ from django.db import migrations
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("submissions", "0001_initial"),
|
||||
("opus_magnum", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -5,7 +5,7 @@ from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("submissions", "0002_delete_collection"),
|
||||
("opus_magnum", "0002_delete_collection"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -1,7 +1,7 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-29 01:32
|
||||
|
||||
import django.db.models.deletion
|
||||
import submissions.models
|
||||
import opus_magnum.models
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
@ -9,7 +9,7 @@ from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("submissions", "0003_steamapikey"),
|
||||
("opus_magnum", "0003_steamapikey"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
@ -160,7 +160,7 @@ class Migration(migrations.Migration):
|
||||
help_text="The puzzle this response is for",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="responses",
|
||||
to="submissions.steamcollectionitem",
|
||||
to="opus_magnum.steamcollectionitem",
|
||||
),
|
||||
),
|
||||
(
|
||||
@ -168,7 +168,7 @@ class Migration(migrations.Migration):
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="responses",
|
||||
to="submissions.submission",
|
||||
to="opus_magnum.submission",
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -195,7 +195,7 @@ class Migration(migrations.Migration):
|
||||
"file",
|
||||
models.FileField(
|
||||
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(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="files",
|
||||
to="submissions.puzzleresponse",
|
||||
to="opus_magnum.puzzleresponse",
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -5,7 +5,7 @@ from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("submissions", "0004_submission_puzzleresponse_submissionfile"),
|
||||
("opus_magnum", "0004_submission_puzzleresponse_submissionfile"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -5,7 +5,7 @@ from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("submissions", "0005_alter_submission_notes"),
|
||||
("opus_magnum", "0005_alter_submission_notes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -5,7 +5,7 @@ from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("submissions", "0006_remove_puzzleresponse_ocr_confidence_score_and_more"),
|
||||
("opus_magnum", "0006_remove_puzzleresponse_ocr_confidence_score_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -5,7 +5,7 @@ from django.db import migrations
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("submissions", "0007_submission_manual_validation_requested"),
|
||||
("opus_magnum", "0007_submission_manual_validation_requested"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -7,7 +7,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("animations", "0001_initial"),
|
||||
("submissions", "0008_alter_puzzleresponse_unique_together"),
|
||||
("opus_magnum", "0008_alter_puzzleresponse_unique_together"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -5,7 +5,7 @@ from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("submissions", "0009_steamcollectionitem_points_factor"),
|
||||
("opus_magnum", "0009_steamcollectionitem_points_factor"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -5,7 +5,7 @@ from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("submissions", "0010_alter_puzzleresponse_validated_area_and_more"),
|
||||
("opus_magnum", "0010_alter_puzzleresponse_validated_area_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -6,7 +6,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
(
|
||||
"submissions",
|
||||
"opus_magnum",
|
||||
"0011_alter_puzzleresponse_area_alter_puzzleresponse_cost_and_more",
|
||||
),
|
||||
]
|
||||
@ -7,7 +7,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("animations", "0002_puzzlepointsvalue"),
|
||||
("submissions", "0012_alter_puzzleresponse_validated_area_and_more"),
|
||||
("opus_magnum", "0012_alter_puzzleresponse_validated_area_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -5,7 +5,7 @@ from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("submissions", "0013_steamcollectionitem_points_value"),
|
||||
("opus_magnum", "0013_steamcollectionitem_points_value"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -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),
|
||||
]
|
||||
@ -221,6 +221,7 @@ class UserInfoOut(Schema):
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
points: int = 0
|
||||
is_authenticated: bool
|
||||
is_staff: bool
|
||||
is_superuser: bool
|
||||
1
polylan_submitter/opus_magnum/tests.py
Normal file
1
polylan_submitter/opus_magnum/tests.py
Normal file
@ -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 requests
|
||||
from submissions.models import SteamCollection, SteamCollectionItem, SubmissionFile
|
||||
from opus_magnum.models import SteamCollection, SteamCollectionItem, SubmissionFile
|
||||
from datetime import datetime
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
@ -7,7 +7,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"schema": "openapi-ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
@ -21,6 +22,7 @@
|
||||
"vue": "^3.5.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "^0.97.2",
|
||||
"@mdi/font": "^7.4.47",
|
||||
"@types/node": "^24.6.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
|
||||
@ -36,6 +36,9 @@ importers:
|
||||
specifier: ^3.5.22
|
||||
version: 3.5.22(typescript@5.9.3)
|
||||
devDependencies:
|
||||
'@hey-api/openapi-ts':
|
||||
specifier: ^0.97.2
|
||||
version: 0.97.2(typescript@5.9.3)
|
||||
'@mdi/font':
|
||||
specifier: ^7.4.47
|
||||
version: 7.4.47
|
||||
@ -236,6 +239,31 @@ packages:
|
||||
cpu: [x64]
|
||||
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':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
@ -252,6 +280,13 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
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':
|
||||
resolution: {integrity: sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==}
|
||||
|
||||
@ -471,6 +506,9 @@ packages:
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/node@24.9.2':
|
||||
resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==}
|
||||
|
||||
@ -566,16 +604,54 @@ packages:
|
||||
alien-signals@3.0.3:
|
||||
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:
|
||||
resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==}
|
||||
|
||||
bmp-js@0.1.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
csstype@3.1.3:
|
||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
|
||||
@ -585,10 +661,32 @@ packages:
|
||||
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:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
dotenv@17.4.2:
|
||||
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
enhanced-resolve@5.18.3:
|
||||
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@ -605,6 +703,9 @@ packages:
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
exsolve@1.0.8:
|
||||
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@ -619,6 +720,13 @@ packages:
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
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:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
@ -632,9 +740,23 @@ packages:
|
||||
resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==}
|
||||
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:
|
||||
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:
|
||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||
|
||||
@ -642,10 +764,21 @@ packages:
|
||||
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
|
||||
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:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
||||
js-yaml@4.1.1:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
|
||||
lightningcss-android-arm64@1.30.2:
|
||||
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@ -739,6 +872,13 @@ packages:
|
||||
encoding:
|
||||
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:
|
||||
resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==}
|
||||
hasBin: true
|
||||
@ -746,9 +886,19 @@ packages:
|
||||
path-browserify@1.0.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||
|
||||
perfect-debounce@2.1.0:
|
||||
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@ -765,13 +915,30 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
pkg-types@2.3.1:
|
||||
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
|
||||
|
||||
postcss@8.5.6:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
@ -780,6 +947,23 @@ packages:
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
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:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -886,6 +1070,15 @@ packages:
|
||||
whatwg-url@5.0.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
|
||||
engines: {node: '>= 14.6'}
|
||||
@ -987,6 +1180,56 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.25.11':
|
||||
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':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@ -1006,6 +1249,10 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@jsdevtools/ono@7.1.3': {}
|
||||
|
||||
'@lukeed/ms@2.0.2': {}
|
||||
|
||||
'@mdi/font@7.4.47': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.29': {}
|
||||
@ -1153,6 +1400,8 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/node@24.9.2':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
@ -1281,22 +1530,76 @@ snapshots:
|
||||
|
||||
alien-signals@3.0.3: {}
|
||||
|
||||
ansi-colors@4.1.3: {}
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
birpc@2.6.1: {}
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
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: {}
|
||||
|
||||
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: {}
|
||||
|
||||
dotenv@17.4.2: {}
|
||||
|
||||
enhanced-resolve@5.18.3:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@ -1335,6 +1638,8 @@ snapshots:
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
exsolve@1.0.8: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
@ -1342,6 +1647,12 @@ snapshots:
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
get-tsconfig@4.14.0:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
giget@3.2.0: {}
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
hookable@5.5.3: {}
|
||||
@ -1350,14 +1661,32 @@ snapshots:
|
||||
|
||||
install@0.13.0: {}
|
||||
|
||||
is-docker@3.0.0: {}
|
||||
|
||||
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-what@5.5.0: {}
|
||||
|
||||
is-wsl@3.1.1:
|
||||
dependencies:
|
||||
is-inside-container: 1.0.0
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
lightningcss-android-arm64@1.30.2:
|
||||
optional: true
|
||||
|
||||
@ -1421,12 +1750,29 @@ snapshots:
|
||||
dependencies:
|
||||
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: {}
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
|
||||
path-key@3.1.1: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
perfect-debounce@1.0.0: {}
|
||||
|
||||
perfect-debounce@2.1.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
@ -1438,14 +1784,31 @@ snapshots:
|
||||
optionalDependencies:
|
||||
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:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.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: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rollup@4.52.5:
|
||||
@ -1476,6 +1839,16 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc': 4.52.5
|
||||
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: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
@ -1558,6 +1931,15 @@ snapshots:
|
||||
tr46: 0.0.3
|
||||
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:
|
||||
optional: true
|
||||
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
from ninja import NinjaAPI
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpRequest
|
||||
from submissions.api import router as submissions_router
|
||||
from submissions.schemas import UserInfoOut
|
||||
from opus_magnum.api import router as submissions_router
|
||||
from opus_magnum.schemas import UserInfoOut
|
||||
from animations.api import router as results_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
|
||||
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
|
||||
# api.auth = django_auth # Uncomment if you want global auth
|
||||
|
||||
# Include the submissions router
|
||||
api.add_router("/submissions/", submissions_router, tags=["submissions"])
|
||||
# Include the opus_magnum router
|
||||
api.add_router("/opus-magnum/", submissions_router, tags=["opus-magnum"])
|
||||
api.add_router("/results/", results_router, tags=["results"])
|
||||
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
|
||||
@ -63,20 +70,10 @@ def get_user_info(request):
|
||||
user = request.user
|
||||
|
||||
if user.is_authenticated:
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"email": user.email,
|
||||
"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,
|
||||
}
|
||||
return user
|
||||
|
||||
return {
|
||||
"is_authenticated": False,
|
||||
"is_staff": False,
|
||||
"is_superuser": False,
|
||||
}
|
||||
|
||||
@ -41,8 +41,10 @@ INSTALLED_APPS = [
|
||||
"django_vite",
|
||||
"accounts",
|
||||
"animations",
|
||||
"submissions",
|
||||
"opus_magnum",
|
||||
"noita",
|
||||
"games",
|
||||
"market",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@ -167,6 +169,9 @@ ALLOWED_SUBMISSION_TYPES = [
|
||||
"video/webm",
|
||||
]
|
||||
|
||||
# Market app settings
|
||||
MARKET_ENABLED = os.environ.get("MARKET_ENABLED", "true").lower() == "true"
|
||||
|
||||
# Authentication backends
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
@ -193,7 +198,7 @@ STATICFILES_DIRS = [
|
||||
from polylan_submitter.settingsLocal import * # noqa
|
||||
|
||||
|
||||
import sentry_sdk
|
||||
import sentry_sdk # noqa
|
||||
|
||||
sentry_sdk.init(
|
||||
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.urls.static import static
|
||||
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
|
||||
from games.decorators import require_game_enabled
|
||||
from market.views import market_home
|
||||
from .api import api
|
||||
|
||||
NOITA_APP_ID = 881100
|
||||
OPUS_MAGNUM_APP_ID = 558990
|
||||
|
||||
|
||||
@login_required
|
||||
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
|
||||
@require_game_enabled(OPUS_MAGNUM_APP_ID)
|
||||
def opus_magnum_home(request: HttpRequest):
|
||||
from submissions.models import SteamCollection
|
||||
from opus_magnum.models import SteamCollection
|
||||
|
||||
return render(
|
||||
request,
|
||||
@ -45,6 +59,7 @@ def opus_magnum_home(request: HttpRequest):
|
||||
|
||||
|
||||
@login_required
|
||||
@require_game_enabled(NOITA_APP_ID)
|
||||
def noita_home(request: HttpRequest):
|
||||
return render(request, "noita.html", {})
|
||||
|
||||
@ -54,6 +69,7 @@ urlpatterns = [
|
||||
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
|
||||
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
|
||||
path("api/", api.urls),
|
||||
path("market", market_home, name="market.home"),
|
||||
path("opus-magnum", opus_magnum_home, name="opus-magnum.home"),
|
||||
path("noita", noita_home, name="noita.home"),
|
||||
path("", home, name="home"),
|
||||
|
||||
@ -1,23 +1,18 @@
|
||||
<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(() => [
|
||||
{
|
||||
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",
|
||||
},
|
||||
]);
|
||||
interface Props {
|
||||
marketEnabled?: string | boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
marketEnabled: true,
|
||||
});
|
||||
|
||||
const games = ref<GamesApiListGamesResponse | undefined>();
|
||||
const loading = ref(true);
|
||||
const imageErrors = ref<Set<number>>(new Set());
|
||||
|
||||
const getHeaderImage = (appId: number) => {
|
||||
@ -31,6 +26,22 @@ const onImageError = (appId: number) => {
|
||||
const navigate = (path: string) => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@ -44,13 +55,38 @@ const navigate = (path: string) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex justify-center py-20">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
|
||||
<!-- Cards Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div v-for="game in games" :key="game.id" @click="navigate(game.path)"
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<!-- 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">
|
||||
<figure class="relative h-60 bg-base-300 overflow-hidden">
|
||||
<img v-if="!imageErrors.has(game.appId)" :src="getHeaderImage(game.appId)" :alt="game.title"
|
||||
@error="onImageError(game.appId)" class="w-full h-full object-cover" />
|
||||
<img v-if="!imageErrors.has(game.steam_app_id)" :src="getHeaderImage(game.steam_app_id)" :alt="game.name"
|
||||
@error="onImageError(game.steam_app_id)" class="w-full h-full object-cover" />
|
||||
<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">
|
||||
<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>
|
||||
</figure>
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl">{{ game.title }}</h2>
|
||||
<p class="text-base-content/70">{{ game.description }}</p>
|
||||
<h2 class="card-title text-2xl">{{ game.name }}</h2>
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button class="btn btn-primary">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
106
polylan_submitter/src/Market.vue
Normal file
106
polylan_submitter/src/Market.vue
Normal file
@ -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>
|
||||
@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
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,
|
||||
@ -12,32 +14,11 @@ import {
|
||||
type SortingState,
|
||||
} from "@tanstack/vue-table";
|
||||
|
||||
interface Objective {
|
||||
objectiv_id: string;
|
||||
display_string: string;
|
||||
first_seen_at: string | null;
|
||||
count: number;
|
||||
max_count: number;
|
||||
seed: string | null;
|
||||
points_per_objectiv: number;
|
||||
total_points: number;
|
||||
}
|
||||
|
||||
const userInfo = ref({
|
||||
username: "Player",
|
||||
rank: null as number | null,
|
||||
score: 0,
|
||||
runsSubmitted: 0,
|
||||
deathsCount: 0,
|
||||
isStaff: false,
|
||||
});
|
||||
const noitaStore = useNoitaStore();
|
||||
const { userInfo, objectives, leaderboard, isLoadingLeaderboard, isUploading } = storeToRefs(noitaStore);
|
||||
|
||||
const uploadedFiles = ref<File[]>([]);
|
||||
const isUploading = ref(false);
|
||||
const isDragover = ref(false);
|
||||
const objectives = ref<Objective[]>([]);
|
||||
const isLoadingLeaderboard = ref(false);
|
||||
const leaderboard = ref<any[]>([]);
|
||||
|
||||
const columnHelper = createColumnHelper<Objective>();
|
||||
const sorting = ref<SortingState>([]);
|
||||
@ -153,40 +134,13 @@ const handleDrop = (event: DragEvent) => {
|
||||
const submitRun = async () => {
|
||||
if (uploadedFiles.value.length === 0) return;
|
||||
|
||||
isUploading.value = true;
|
||||
try {
|
||||
for (const file of 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);
|
||||
}
|
||||
|
||||
await noitaStore.submitRun(uploadedFiles.value);
|
||||
uploadedFiles.value = [];
|
||||
alert("Run submitted successfully!");
|
||||
|
||||
// Refresh objectives, score, and rank after successful submission
|
||||
await Promise.all([
|
||||
fetchUserResults(),
|
||||
fetchLeaderboard(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error submitting run:", error);
|
||||
alert("Error submitting run. Please try again.");
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@ -194,94 +148,18 @@ const goHome = () => {
|
||||
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.deathsCount = results.deaths_count;
|
||||
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;
|
||||
userInfo.value.deathsCount = userRank.deaths_count;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching leaderboard:", error);
|
||||
} finally {
|
||||
isLoadingLeaderboard.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const clearCache = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/cache/clear", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
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"}`);
|
||||
}
|
||||
await noitaStore.clearCache();
|
||||
alert("Cache cleared successfully!");
|
||||
} catch (error) {
|
||||
console.error("Error clearing cache:", error);
|
||||
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(() => {
|
||||
loadUserData();
|
||||
noitaStore.loadUserData();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -7,7 +7,8 @@ import Results from "@/components/Results.vue";
|
||||
import Winners from "@/components/Winners.vue";
|
||||
import PuzzleResults from "@/components/PuzzleResults.vue";
|
||||
import TopUsersLeaderboard from "@/components/TopUsersLeaderboard.vue";
|
||||
import { apiService, errorHelpers } from "@/services/apiService";
|
||||
import { polylanSubmitterApiGetUserInfo, opusMagnumApiGetCollection } from "@/api";
|
||||
import { errorHelpers } from "@/services/apiService";
|
||||
import { usePuzzlesStore } from "@/stores/puzzles";
|
||||
import { useSubmissionsStore } from "@/stores/submissions";
|
||||
import type { PuzzleResponse, UserInfo, SteamCollection } from "@/types";
|
||||
@ -66,9 +67,9 @@ async function initialize() {
|
||||
|
||||
// Load user info
|
||||
console.log("Loading user info...");
|
||||
const userResponse = await apiService.getUserInfo();
|
||||
const userResponse = await polylanSubmitterApiGetUserInfo();
|
||||
if (userResponse.data) {
|
||||
userInfo.value = userResponse.data;
|
||||
userInfo.value = userResponse.data as UserInfo;
|
||||
console.log("User info loaded:", userResponse.data);
|
||||
} else if (userResponse.error) {
|
||||
console.warn("User info error:", userResponse.error);
|
||||
@ -76,9 +77,9 @@ async function initialize() {
|
||||
|
||||
// Load collection data
|
||||
console.log("Loading collection...");
|
||||
const collectionResponse = await apiService.getCollection();
|
||||
const collectionResponse = await opusMagnumApiGetCollection();
|
||||
if (collectionResponse.data) {
|
||||
collection.value = collectionResponse.data;
|
||||
collection.value = collectionResponse.data as SteamCollection;
|
||||
console.log("Collection loaded:", collectionResponse.data);
|
||||
} else if (collectionResponse.error) {
|
||||
console.warn("Collection error:", collectionResponse.error);
|
||||
|
||||
16
polylan_submitter/src/api/client.gen.ts
Normal file
16
polylan_submitter/src/api/client.gen.ts
Normal file
@ -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>());
|
||||
277
polylan_submitter/src/api/client/client.gen.ts
Normal file
277
polylan_submitter/src/api/client/client.gen.ts
Normal file
@ -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;
|
||||
};
|
||||
25
polylan_submitter/src/api/client/index.ts
Normal file
25
polylan_submitter/src/api/client/index.ts
Normal file
@ -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';
|
||||
217
polylan_submitter/src/api/client/types.gen.ts
Normal file
217
polylan_submitter/src/api/client/types.gen.ts
Normal file
@ -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'>);
|
||||
316
polylan_submitter/src/api/client/utils.gen.ts
Normal file
316
polylan_submitter/src/api/client/utils.gen.ts
Normal file
@ -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,
|
||||
});
|
||||
41
polylan_submitter/src/api/core/auth.gen.ts
Normal file
41
polylan_submitter/src/api/core/auth.gen.ts
Normal file
@ -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;
|
||||
};
|
||||
82
polylan_submitter/src/api/core/bodySerializer.gen.ts
Normal file
82
polylan_submitter/src/api/core/bodySerializer.gen.ts
Normal file
@ -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();
|
||||
},
|
||||
};
|
||||
169
polylan_submitter/src/api/core/params.gen.ts
Normal file
169
polylan_submitter/src/api/core/params.gen.ts
Normal file
@ -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;
|
||||
};
|
||||
171
polylan_submitter/src/api/core/pathSerializer.gen.ts
Normal file
171
polylan_submitter/src/api/core/pathSerializer.gen.ts
Normal file
@ -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;
|
||||
};
|
||||
117
polylan_submitter/src/api/core/queryKeySerializer.gen.ts
Normal file
117
polylan_submitter/src/api/core/queryKeySerializer.gen.ts
Normal file
@ -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;
|
||||
};
|
||||
242
polylan_submitter/src/api/core/serverSentEvents.gen.ts
Normal file
242
polylan_submitter/src/api/core/serverSentEvents.gen.ts
Normal file
@ -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 };
|
||||
}
|
||||
104
polylan_submitter/src/api/core/types.gen.ts
Normal file
104
polylan_submitter/src/api/core/types.gen.ts
Normal file
@ -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];
|
||||
};
|
||||
140
polylan_submitter/src/api/core/utils.gen.ts
Normal file
140
polylan_submitter/src/api/core/utils.gen.ts
Normal file
@ -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;
|
||||
}
|
||||
4
polylan_submitter/src/api/index.ts
Normal file
4
polylan_submitter/src/api/index.ts
Normal file
@ -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';
|
||||
241
polylan_submitter/src/api/sdk.gen.ts
Normal file
241
polylan_submitter/src/api/sdk.gen.ts
Normal file
@ -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
|
||||
}
|
||||
});
|
||||
1573
polylan_submitter/src/api/types.gen.ts
Normal file
1573
polylan_submitter/src/api/types.gen.ts
Normal file
File diff suppressed because it is too large
Load Diff
338
polylan_submitter/src/components/MarketCard.vue
Normal file
338
polylan_submitter/src/components/MarketCard.vue
Normal file
@ -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>
|
||||
182
polylan_submitter/src/components/UserBets.vue
Normal file
182
polylan_submitter/src/components/UserBets.vue
Normal file
@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useMarketStore } from "../stores/market";
|
||||
|
||||
defineEmits<{
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
const marketStore = useMarketStore();
|
||||
const { userBets } = storeToRefs(marketStore);
|
||||
const loading = computed(() => marketStore.isLoading);
|
||||
const isWonBetsExpanded = ref(false);
|
||||
|
||||
const totalBetAmount = computed(() => {
|
||||
return userBets.value.reduce((sum, bet) => sum + bet.amount, 0);
|
||||
});
|
||||
|
||||
const winningBets = computed(() => {
|
||||
return userBets.value.filter(bet => {
|
||||
const market = bet.market;
|
||||
return market?.status === "resolved" && market?.winning_option?.uuid === bet.option.uuid;
|
||||
}).reverse();
|
||||
});
|
||||
|
||||
const losingBets = computed(() => {
|
||||
return userBets.value.filter(bet => {
|
||||
const market = bet.market;
|
||||
return market?.status === "resolved" && market?.winning_option?.uuid !== bet.option.uuid;
|
||||
});
|
||||
});
|
||||
|
||||
const openBets = computed(() => {
|
||||
return userBets.value.filter(bet => bet.market?.status === "open");
|
||||
});
|
||||
|
||||
const totalWinnings = computed(() => {
|
||||
return winningBets.value.reduce((sum, bet) => sum + bet.amount, 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex justify-center py-20">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="userBets.length === 0" class="alert">
|
||||
<i class="mdi mdi-information mr-2"></i>
|
||||
<span>You haven't placed any bets yet</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<template v-else>
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="stat bg-base-100 rounded-lg shadow">
|
||||
<div class="stat-title text-sm">Total Bets</div>
|
||||
<div class="stat-value text-2xl">{{ userBets.length }}</div>
|
||||
<div class="stat-desc text-xs">{{ totalBetAmount }} points</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-100 rounded-lg shadow">
|
||||
<div class="stat-title text-sm">Active</div>
|
||||
<div class="stat-value text-2xl text-info">{{ openBets.length }}</div>
|
||||
<div class="stat-desc text-xs">Waiting for result</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-100 rounded-lg shadow">
|
||||
<div class="stat-title text-sm">Won</div>
|
||||
<div class="stat-value text-2xl text-success">{{ winningBets.length }}</div>
|
||||
<div class="stat-desc text-xs text-success">+{{ totalWinnings }} points</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-100 rounded-lg shadow">
|
||||
<div class="stat-title text-sm">Lost</div>
|
||||
<div class="stat-value text-2xl text-error">{{ losingBets.length }}</div>
|
||||
<div class="stat-desc text-xs">Better luck next time</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bets Sections -->
|
||||
<div class="space-y-6">
|
||||
<!-- Open Bets -->
|
||||
<div v-if="openBets.length > 0">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
|
||||
<i class="mdi mdi-progress-clock text-info"></i>
|
||||
Active Bets ({{ openBets.length }})
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="bet in openBets"
|
||||
:key="bet.uuid"
|
||||
class="card bg-base-100 shadow hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div class="card-body py-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-lg">{{ bet.market?.title }}</h4>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Bet on: <span class="font-medium">{{ bet.option.text }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-lg font-bold">{{ bet.amount }} pts</div>
|
||||
<div class="text-xs text-base-content/60 mt-1">
|
||||
Status: <span class="badge badge-info badge-sm">{{ bet.market?.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Winning Bets -->
|
||||
<div v-if="winningBets.length > 0">
|
||||
<button @click="isWonBetsExpanded = !isWonBetsExpanded" class="text-xl font-bold mb-4 flex items-center gap-2 cursor-pointer hover:opacity-70 transition-opacity">
|
||||
<i :class="['mdi', isWonBetsExpanded ? 'mdi-chevron-down' : 'mdi-chevron-right']"></i>
|
||||
<i class="mdi mdi-check-circle text-success"></i>
|
||||
Won Bets ({{ winningBets.length }})
|
||||
</button>
|
||||
<div v-if="isWonBetsExpanded" class="space-y-4">
|
||||
<div
|
||||
v-for="bet in winningBets"
|
||||
:key="bet.uuid"
|
||||
class="card bg-success/10 border border-success shadow"
|
||||
>
|
||||
<div class="card-body py-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-lg">{{ bet.market?.title }}</h4>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Correct! You bet on: <span class="font-medium text-success">{{ bet.option.text }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-lg font-bold text-success">+{{ bet.amount }} pts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Losing Bets -->
|
||||
<div v-if="losingBets.length > 0">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
|
||||
<i class="mdi mdi-close-circle text-error"></i>
|
||||
Lost Bets ({{ losingBets.length }})
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="bet in losingBets"
|
||||
:key="bet.uuid"
|
||||
class="card bg-error/10 border border-error shadow"
|
||||
>
|
||||
<div class="card-body py-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-lg">{{ bet.market?.title }}</h4>
|
||||
<p class="text-sm text-base-content/70">
|
||||
You bet on: <span class="font-medium">{{ bet.option.text }}</span>
|
||||
</p>
|
||||
<p class="text-sm text-base-content/60 mt-1">
|
||||
Winner: <span class="font-medium">{{ bet.market?.winning_option?.text }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-lg font-bold text-error">-{{ bet.amount }} pts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
10
polylan_submitter/src/market.ts
Normal file
10
polylan_submitter/src/market.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import Market from '@/Market.vue'
|
||||
import { pinia } from '@/stores'
|
||||
import '@/style.css'
|
||||
|
||||
const selector = "#app"
|
||||
const mountData = document.querySelector<HTMLElement>(selector)
|
||||
const app = createApp(Market, { ...mountData?.dataset })
|
||||
app.use(pinia)
|
||||
app.mount(selector)
|
||||
@ -1,8 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import Noita from '@/Noita.vue'
|
||||
import { pinia } from '@/stores'
|
||||
import '@/style.css'
|
||||
|
||||
const selector = "#app"
|
||||
const mountData = document.querySelector<HTMLElement>(selector)
|
||||
const app = createApp(Noita, { ...mountData?.dataset })
|
||||
app.use(pinia)
|
||||
app.mount(selector)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type {
|
||||
Game,
|
||||
SteamCollection,
|
||||
SteamCollectionItem,
|
||||
Submission,
|
||||
@ -6,7 +7,9 @@ import type {
|
||||
SubmissionFile,
|
||||
UserInfo,
|
||||
TournamentSubmissions,
|
||||
TournamentPuzzleResults
|
||||
TournamentPuzzleResults,
|
||||
Market,
|
||||
UserBet
|
||||
} from '../types'
|
||||
|
||||
// API Configuration
|
||||
@ -99,13 +102,18 @@ export class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
// Games endpoint
|
||||
async getGames(): Promise<ApiResponse<Game[]>> {
|
||||
return this.request<Game[]>('/games/')
|
||||
}
|
||||
|
||||
// Puzzle endpoints
|
||||
async getPuzzles(): Promise<ApiResponse<SteamCollectionItem[]>> {
|
||||
return this.request<SteamCollectionItem[]>('/submissions/puzzles')
|
||||
return this.request<SteamCollectionItem[]>('/opus-magnum/puzzles')
|
||||
}
|
||||
|
||||
async getCollection(): Promise<ApiResponse<SteamCollection>> {
|
||||
return this.request<SteamCollection>('/submissions/collection')
|
||||
return this.request<SteamCollection>('/opus-magnum/collection')
|
||||
}
|
||||
|
||||
async getTopSubmissions(limit = 5): Promise<ApiResponse<TournamentSubmissions>> {
|
||||
@ -119,12 +127,12 @@ export class ApiService {
|
||||
// Submission endpoints
|
||||
async getSubmissions(limit = 20, offset = 0): Promise<ApiResponse<PaginatedResponse<Submission>>> {
|
||||
return this.request<PaginatedResponse<Submission>>(
|
||||
`/submissions/submissions?limit=${limit}&offset=${offset}`
|
||||
`/opus-magnum/submissions?limit=${limit}&offset=${offset}`
|
||||
)
|
||||
}
|
||||
|
||||
async getSubmission(id: string): Promise<ApiResponse<Submission>> {
|
||||
return this.request<Submission>(`/submissions/submissions/${id}`)
|
||||
return this.request<Submission>(`/opus-magnum/submissions/${id}`)
|
||||
}
|
||||
|
||||
async createSubmission(
|
||||
@ -155,7 +163,7 @@ export class ApiService {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
return this.uploadRequest<Submission>('/submissions/submissions', formData)
|
||||
return this.uploadRequest<Submission>('/opus-magnum/submissions', formData)
|
||||
}
|
||||
|
||||
// Admin endpoints (require staff permissions)
|
||||
@ -167,37 +175,37 @@ export class ApiService {
|
||||
validated_area?: number
|
||||
}
|
||||
): Promise<ApiResponse<PuzzleResponse>> {
|
||||
return this.request<PuzzleResponse>(`/submissions/responses/${responseId}/validate`, {
|
||||
return this.request<PuzzleResponse>(`/opus-magnum/responses/${responseId}/validate`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(validationData),
|
||||
})
|
||||
}
|
||||
|
||||
async autoValidateResponses(responseId: number): Promise<ApiResponse<PuzzleResponse>> {
|
||||
return this.request<PuzzleResponse>(`/submissions/responses/${responseId}/validate/auto`, {
|
||||
return this.request<PuzzleResponse>(`/opus-magnum/responses/${responseId}/validate/auto`, {
|
||||
method: 'PUT',
|
||||
})
|
||||
}
|
||||
|
||||
async getResponsesNeedingValidation(): Promise<ApiResponse<PuzzleResponse[]>> {
|
||||
return this.request<PuzzleResponse[]>('/submissions/responses/needs-validation')
|
||||
return this.request<PuzzleResponse[]>('/opus-magnum/responses/needs-validation')
|
||||
}
|
||||
|
||||
async validateSubmission(submissionId: string): Promise<ApiResponse<Submission>> {
|
||||
return this.request<Submission>(`/submissions/submissions/${submissionId}/validate`, {
|
||||
return this.request<Submission>(`/opus-magnum/submissions/${submissionId}/validate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async deleteSubmission(submissionId: string): Promise<ApiResponse<{ detail: string }>> {
|
||||
return this.request<{ detail: string }>(`/submissions/submissions/${submissionId}`, {
|
||||
return this.request<{ detail: string }>(`/opus-magnum/submissions/${submissionId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
// Statistics endpoint
|
||||
async getStats(): Promise<ApiResponse<SubmissionStats>> {
|
||||
return this.request<SubmissionStats>('/submissions/stats')
|
||||
return this.request<SubmissionStats>('/opus-magnum/stats')
|
||||
}
|
||||
|
||||
// Health check
|
||||
@ -209,6 +217,38 @@ export class ApiService {
|
||||
async getUserInfo(): Promise<ApiResponse<UserInfo>> {
|
||||
return this.request<UserInfo>('/user')
|
||||
}
|
||||
|
||||
// Market endpoints
|
||||
async getMarkets(): Promise<ApiResponse<Market[]>> {
|
||||
return this.request<Market[]>('/market/')
|
||||
}
|
||||
|
||||
async placeBet(marketUuid: string, betData: {
|
||||
option_uuid: string
|
||||
amount: number
|
||||
}): Promise<ApiResponse<UserBet>> {
|
||||
return this.request<UserBet>(`/market/${marketUuid}/bets`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(betData),
|
||||
})
|
||||
}
|
||||
|
||||
async getUserBets(): Promise<ApiResponse<UserBet[]>> {
|
||||
return this.request<UserBet[]>('/market/user/bets')
|
||||
}
|
||||
|
||||
async closeMarket(marketUuid: string): Promise<ApiResponse<{ status: string }>> {
|
||||
return this.request<{ status: string }>(`/market/${marketUuid}/actions/close`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async resolveMarket(marketUuid: string, winningOptionUuid: string): Promise<ApiResponse<Market>> {
|
||||
return this.request<Market>(`/market/${marketUuid}/actions/resolve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ winning_option_uuid: winningOptionUuid }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
94
polylan_submitter/src/stores/market.ts
Normal file
94
polylan_submitter/src/stores/market.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { polylanSubmitterApiGetUserInfo, marketApiListMarkets, marketApiListUserBets } from '@/api'
|
||||
import type { Market } from '@/types'
|
||||
import type { UserInfoOut, UserBetSchema } from '@/api/types.gen'
|
||||
|
||||
export const useMarketStore = defineStore('market', () => {
|
||||
// State
|
||||
const markets = ref<Market[]>([])
|
||||
const userInfo = ref<UserInfoOut | undefined>()
|
||||
const userBets = ref<UserBetSchema[]>([])
|
||||
const isLoading = ref(true)
|
||||
const error = ref<string>('')
|
||||
|
||||
// Actions
|
||||
const loadUserInfo = async () => {
|
||||
try {
|
||||
const response = await polylanSubmitterApiGetUserInfo()
|
||||
if (response.data) {
|
||||
userInfo.value = response.data
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = 'Failed to load user info'
|
||||
console.error('Error loading user info:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMarkets = async () => {
|
||||
try {
|
||||
const response = await marketApiListMarkets()
|
||||
if (response.data) {
|
||||
markets.value = response.data as unknown as Market[]
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = 'Failed to load markets'
|
||||
console.error('Error loading markets:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const loadUserBets = async () => {
|
||||
try {
|
||||
const response = await marketApiListUserBets()
|
||||
if (response.data) {
|
||||
userBets.value = response.data
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = 'Failed to load user bets'
|
||||
console.error('Error loading user bets:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const initializeMarketPage = async () => {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
loadUserInfo(),
|
||||
loadMarkets(),
|
||||
])
|
||||
|
||||
// Load user bets if authenticated
|
||||
if (userInfo.value?.is_authenticated) {
|
||||
await loadUserBets()
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshPage = async () => {
|
||||
await Promise.all([
|
||||
loadUserInfo(),
|
||||
loadMarkets(),
|
||||
userInfo.value?.is_authenticated ? loadUserBets() : Promise.resolve(),
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
markets,
|
||||
userInfo,
|
||||
userBets,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
loadUserInfo,
|
||||
loadMarkets,
|
||||
loadUserBets,
|
||||
initializeMarketPage,
|
||||
refreshPage,
|
||||
}
|
||||
})
|
||||
167
polylan_submitter/src/stores/noita.ts
Normal file
167
polylan_submitter/src/stores/noita.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { ObjectivResultOut, LeaderboardEntryOut } from '@/api/types.gen'
|
||||
|
||||
export type Objective = ObjectivResultOut
|
||||
|
||||
interface UserInfo {
|
||||
username: string
|
||||
rank: number | null
|
||||
score: number
|
||||
runsSubmitted: number
|
||||
deathsCount: number
|
||||
isStaff: boolean
|
||||
}
|
||||
|
||||
export const useNoitaStore = defineStore('noita', () => {
|
||||
// State
|
||||
const userInfo = ref<UserInfo>({
|
||||
username: 'Player',
|
||||
rank: null,
|
||||
score: 0,
|
||||
runsSubmitted: 0,
|
||||
deathsCount: 0,
|
||||
isStaff: false,
|
||||
})
|
||||
const objectives = ref<Objective[]>([])
|
||||
const leaderboard = ref<LeaderboardEntryOut[]>([])
|
||||
const isLoadingLeaderboard = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const error = ref<string>('')
|
||||
|
||||
// Actions
|
||||
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.deathsCount = results.deaths_count
|
||||
userInfo.value.runsSubmitted = results.objectives.length
|
||||
objectives.value = results.objectives
|
||||
} catch (err) {
|
||||
error.value = 'Failed to fetch user results'
|
||||
console.error('Error fetching results:', err)
|
||||
}
|
||||
}
|
||||
|
||||
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) => entry.username === userInfo.value.username
|
||||
)
|
||||
|
||||
if (userRank) {
|
||||
userInfo.value.rank = userRank.rank
|
||||
userInfo.value.score = userRank.total_score
|
||||
userInfo.value.deathsCount = userRank.deaths_count
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = 'Failed to fetch leaderboard'
|
||||
console.error('Error fetching leaderboard:', err)
|
||||
} finally {
|
||||
isLoadingLeaderboard.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadUserData = async () => {
|
||||
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 (err) {
|
||||
console.error('Error fetching user info:', err)
|
||||
}
|
||||
|
||||
await Promise.all([fetchUserResults(), fetchLeaderboard()])
|
||||
}
|
||||
|
||||
const submitRun = async (files: File[]) => {
|
||||
if (files.length === 0) return
|
||||
|
||||
isUploading.value = true
|
||||
try {
|
||||
for (const file of files) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await fetch('/api/noita/submit', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.detail || 'Unknown error')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log('Submission successful:', result)
|
||||
}
|
||||
|
||||
// Refresh objectives, score, and rank after successful submission
|
||||
await Promise.all([fetchUserResults(), fetchLeaderboard()])
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
|
||||
error.value = `Error submitting run: ${errorMessage}`
|
||||
throw err
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearCache = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/cache/clear', {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.detail || 'Unknown error')
|
||||
}
|
||||
|
||||
await Promise.all([fetchUserResults(), fetchLeaderboard()])
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
|
||||
error.value = `Error clearing cache: ${errorMessage}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const refreshData = async () => {
|
||||
await Promise.all([fetchUserResults(), fetchLeaderboard()])
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
userInfo,
|
||||
objectives,
|
||||
leaderboard,
|
||||
isLoadingLeaderboard,
|
||||
isUploading,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
fetchUserResults,
|
||||
fetchLeaderboard,
|
||||
loadUserData,
|
||||
submitRun,
|
||||
clearCache,
|
||||
refreshData,
|
||||
}
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { opusMagnumApiListPuzzles } from '@/api'
|
||||
import type { SteamCollectionItem } from '@/types'
|
||||
import { apiService } from '@/services/apiService'
|
||||
|
||||
export const usePuzzlesStore = defineStore('puzzles', () => {
|
||||
// State
|
||||
@ -14,7 +14,7 @@ export const usePuzzlesStore = defineStore('puzzles', () => {
|
||||
|
||||
const findPuzzleByName = computed(() => (name: string): SteamCollectionItem | null => {
|
||||
if (!name) return null
|
||||
|
||||
|
||||
// First try exact match (case insensitive)
|
||||
const exactMatch = puzzles.value.find(
|
||||
puzzle => puzzle.title.toLowerCase() === name.toLowerCase()
|
||||
@ -26,27 +26,27 @@ export const usePuzzlesStore = defineStore('puzzles', () => {
|
||||
puzzle => puzzle.title.toLowerCase().includes(name.toLowerCase()) ||
|
||||
name.toLowerCase().includes(puzzle.title.toLowerCase())
|
||||
)
|
||||
|
||||
|
||||
return partialMatch || null
|
||||
})
|
||||
|
||||
// Actions
|
||||
const loadPuzzles = async () => {
|
||||
if (puzzles.value.length > 0) return // Already loaded
|
||||
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
const response = await apiService.getPuzzles()
|
||||
|
||||
const response = await opusMagnumApiListPuzzles()
|
||||
if (response.error) {
|
||||
error.value = response.error
|
||||
error.value = String(response.error)
|
||||
console.error('Failed to load puzzles:', response.error)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (response.data) {
|
||||
puzzles.value = response.data
|
||||
puzzles.value = response.data as unknown as SteamCollectionItem[]
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = 'Failed to load puzzles'
|
||||
|
||||
@ -1,3 +1,9 @@
|
||||
export interface Game {
|
||||
steam_app_id: number
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface SteamCollection {
|
||||
id: number
|
||||
steam_id: string
|
||||
@ -24,9 +30,9 @@ export interface SteamCollectionItem {
|
||||
title: string
|
||||
author_name: string
|
||||
description: string
|
||||
tags: string[]
|
||||
order_index: number
|
||||
collection: number
|
||||
tags?: string[]
|
||||
order_index?: number
|
||||
steam_url: string
|
||||
points_factor?: PointsFactor
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@ -163,3 +169,30 @@ export interface PuzzleResults {
|
||||
export interface TournamentPuzzleResults {
|
||||
results: PuzzleResults[]
|
||||
}
|
||||
|
||||
export interface MarketOption {
|
||||
uuid: string
|
||||
text: string
|
||||
total_bets: number
|
||||
}
|
||||
|
||||
export interface Market {
|
||||
uuid: string
|
||||
title: string
|
||||
description: string
|
||||
status: 'draft' | 'open' | 'closed' | 'resolved'
|
||||
end_date: string
|
||||
multiplier: number
|
||||
created_at: string
|
||||
options: MarketOption[]
|
||||
winning_option?: MarketOption | null
|
||||
userHasBet?: boolean
|
||||
}
|
||||
|
||||
export interface UserBet {
|
||||
uuid: string
|
||||
amount: number
|
||||
created_at: string
|
||||
option: MarketOption
|
||||
market: Market
|
||||
}
|
||||
|
||||
@ -1 +0,0 @@
|
||||
import{k as t,l as a,p as n,v as s}from"./style-C9QoPxDN.js";const c={key:0,class:"flex justify-center"},k={key:0,class:"badge badge-warning badge-lg"},d={key:1,class:"badge badge-lg"},l={key:2,class:"badge badge-lg"},o={key:3,class:"badge badge-lg"},g={key:1,class:"text-2xl text-base-content/50"},y=t({__name:"RankBadge",props:{rank:{}},setup(e){return(i,r)=>e.rank!==null?(n(),a("div",c,[e.rank===1?(n(),a("span",k," 🏆 #"+s(e.rank),1)):e.rank===2?(n(),a("span",d," 🥈 #"+s(e.rank),1)):e.rank===3?(n(),a("span",l," 🥉 #"+s(e.rank),1)):(n(),a("span",o," #"+s(e.rank),1))])):(n(),a("div",g," No rank yet "))}});export{y as _};
|
||||
@ -0,0 +1 @@
|
||||
import{d as v,r as l,q as g,a as o,o as r,b as e,j as f,F as x,g as h,t as y,f as _,E as w}from"./style-BkYIZIDm.js";import{g as k}from"./sdk.gen-CA3PL0uK.js";const j={class:"min-h-screen bg-base-300 flex items-center justify-center px-4"},S={class:"w-full max-w-6xl"},E={key:0,class:"flex justify-center py-20"},C={key:1,class:"grid grid-cols-1 md:grid-cols-2 gap-8"},N=["onClick"],$={class:"relative h-60 bg-base-300 overflow-hidden"},A=["src","alt","onError"],B={key:1,class:"w-full h-full bg-gradient-to-br from-blue-600 to-blue-400 flex items-center justify-center text-white"},L={class:"card-body"},P={class:"card-title text-2xl"},V=v({__name:"Home",setup(F){const i=l(),n=l(!0),d=l(new Set),u=s=>`https://cdn.akamai.steamstatic.com/steam/apps/${s}/header.jpg`,b=s=>{d.value.add(s)},c=s=>{window.location.href=s};return g(async()=>{const s=await k();s.data&&(i.value=s.data),n.value=!1}),(s,t)=>(r(),o("div",j,[e("div",S,[t[6]||(t[6]=e("div",{class:"text-center mb-12"},[e("h1",{class:"text-5xl font-bold mb-4"},"PolyLAN Submitter"),e("p",{class:"text-xl text-base-content/70"}," Choose a game and submit your best solutions ")],-1)),n.value?(r(),o("div",E,[...t[1]||(t[1]=[e("span",{class:"loading loading-spinner loading-lg"},null,-1)])])):(r(),o("div",C,[e("div",{onClick:t[0]||(t[0]=a=>c("/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"},[...t[2]||(t[2]=[f('<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>',2)])]),(r(!0),o(x,null,h(i.value,a=>(r(),o("div",{key:a.steam_app_id,onClick:p=>c(a.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"},[e("figure",$,[d.value.has(a.steam_app_id)?(r(),o("div",B,[...t[3]||(t[3]=[e("i",{class:"mdi mdi-gamepad-variant text-5xl"},null,-1)])])):(r(),o("img",{key:0,src:u(a.steam_app_id),alt:a.name,onError:p=>b(a.steam_app_id),class:"w-full h-full object-cover"},null,40,A)),t[4]||(t[4]=e("div",{class:"absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"},null,-1))]),e("div",L,[e("h2",P,y(a.name),1),t[5]||(t[5]=e("div",{class:"card-actions justify-end mt-4"},[e("button",{class:"btn btn-primary"},[e("i",{class:"mdi mdi-arrow-right mr-2"}),_(" Submit results ")])],-1))])],8,N))),128))])),t[7]||(t[7]=e("div",{class:"text-center mt-12 text-base-content/50"},[e("p",null,"Select a game above to begin submitting")],-1))])]))}}),m="#app",q=document.querySelector(m),D=w(V,{...q?.dataset});D.mount(m);
|
||||
@ -1 +0,0 @@
|
||||
import{k as b,c as v,r as g,l as a,p as n,s as t,F as h,y as x,v as i,x as f,O as _}from"./style-C9QoPxDN.js";const y={class:"min-h-screen bg-base-300 flex items-center justify-center px-4"},w={class:"w-full max-w-6xl"},k={class:"grid grid-cols-1 md:grid-cols-2 gap-8"},S=["onClick"],I={class:"relative h-60 bg-base-300 overflow-hidden"},j=["src","alt","onError"],E={key:1,class:"w-full h-full bg-gradient-to-br from-blue-600 to-blue-400 flex items-center justify-center text-white"},N={class:"card-body"},C={class:"card-title text-2xl"},B={class:"text-base-content/70"},O=b({__name:"Home",setup(A){const c=v(()=>[{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"}]),r=g(new Set),d=o=>`https://cdn.akamai.steamstatic.com/steam/apps/${o}/header.jpg`,u=o=>{r.value.add(o)},p=o=>{window.location.href=o};return(o,e)=>(n(),a("div",y,[t("div",w,[e[3]||(e[3]=t("div",{class:"text-center mb-12"},[t("h1",{class:"text-5xl font-bold mb-4"},"PolyLAN Submitter"),t("p",{class:"text-xl text-base-content/70"}," Choose a game and submit your best solutions ")],-1)),t("div",k,[(n(!0),a(h,null,x(c.value,s=>(n(),a("div",{key:s.id,onClick:m=>p(s.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"},[t("figure",I,[r.value.has(s.appId)?(n(),a("div",E,[...e[0]||(e[0]=[t("i",{class:"mdi mdi-gamepad-variant text-5xl"},null,-1)])])):(n(),a("img",{key:0,src:d(s.appId),alt:s.title,onError:m=>u(s.appId),class:"w-full h-full object-cover"},null,40,j)),e[1]||(e[1]=t("div",{class:"absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"},null,-1))]),t("div",N,[t("h2",C,i(s.title),1),t("p",B,i(s.description),1),e[2]||(e[2]=t("div",{class:"card-actions justify-end mt-4"},[t("button",{class:"btn btn-primary"},[t("i",{class:"mdi mdi-arrow-right mr-2"}),f(" Submit results ")])],-1))])],8,S))),128))]),e[4]||(e[4]=t("div",{class:"text-center mt-12 text-base-content/50"},[t("p",null,"Select a game above to begin submitting")],-1))])]))}}),l="#app",$=document.querySelector(l),z=_(O,{...$?.dataset});z.mount(l);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user