Compare commits
5 Commits
754b0b0803
...
6d996a4e2f
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d996a4e2f | |||
| f774ff3340 | |||
| 779393106d | |||
| 3e04f8312a | |||
| 7cfab20826 |
@ -1,5 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import LogfileSubmission, Objectiv, ObjectivPoint
|
|
||||||
|
from noita.services.objectives import parse_objectives_from_logfile
|
||||||
|
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
||||||
|
|
||||||
|
|
||||||
@admin.register(LogfileSubmission)
|
@admin.register(LogfileSubmission)
|
||||||
@ -23,13 +25,28 @@ class LogfileSubmissionAdmin(admin.ModelAdmin):
|
|||||||
("Processing", {"fields": ("processed",)}),
|
("Processing", {"fields": ("processed",)}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
actions = ["validate_submission"]
|
||||||
|
|
||||||
|
def validate_submission(self, request, queryset):
|
||||||
|
for logfile in queryset:
|
||||||
|
parse_objectives_from_logfile(logfile)
|
||||||
|
|
||||||
|
self.message_user(request, f"{queryset.count()} submissions validated.")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Objectiv)
|
@admin.register(Objectiv)
|
||||||
class ObjectivAdmin(admin.ModelAdmin):
|
class ObjectivAdmin(admin.ModelAdmin):
|
||||||
list_display = ("objectiv_id", "user", "count")
|
list_display = ("objectiv_id", "user", "first_seen_at", "get_user_objectiv_count")
|
||||||
list_filter = ("objectiv_id", "user")
|
list_filter = ("objectiv_id", "user")
|
||||||
search_fields = ("objectiv_id", "user__username")
|
search_fields = ("objectiv_id", "user__username")
|
||||||
readonly_fields = ("user",)
|
readonly_fields = ("user", "first_seen_at")
|
||||||
|
|
||||||
|
def get_user_objectiv_count(self, obj):
|
||||||
|
return Objectiv.objects.filter(
|
||||||
|
objectiv_id=obj.objectiv_id, user=obj.user
|
||||||
|
).count()
|
||||||
|
|
||||||
|
get_user_objectiv_count.short_description = "Count"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ObjectivPoint)
|
@admin.register(ObjectivPoint)
|
||||||
@ -41,3 +58,15 @@ class ObjectivPointAdmin(admin.ModelAdmin):
|
|||||||
("Objective Information", {"fields": ("objectiv_id", "display_string")}),
|
("Objective Information", {"fields": ("objectiv_id", "display_string")}),
|
||||||
("Scoring", {"fields": ("max_count", "point")}),
|
("Scoring", {"fields": ("max_count", "point")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DeathCounter)
|
||||||
|
class DeathCounterAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user_id", "seed", "seen_at")
|
||||||
|
list_filter = ("user_id", "seed")
|
||||||
|
search_fields = ("user_id", "seed")
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
("User Information", {"fields": ("user_id",)}),
|
||||||
|
("Scoring", {"fields": ("seed",)}),
|
||||||
|
)
|
||||||
|
|||||||
@ -5,32 +5,24 @@ from django.db.models import (
|
|||||||
F,
|
F,
|
||||||
Case,
|
Case,
|
||||||
When,
|
When,
|
||||||
Sum,
|
|
||||||
Count,
|
Count,
|
||||||
IntegerField,
|
IntegerField,
|
||||||
Subquery,
|
Subquery,
|
||||||
OuterRef,
|
OuterRef,
|
||||||
Window,
|
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Rank
|
|
||||||
from ninja import Router, File
|
from ninja import Router, File
|
||||||
from ninja.files import UploadedFile
|
from ninja.files import UploadedFile
|
||||||
|
|
||||||
from noita.schemas import ObjectivOut, ResultsOut, LeaderboardOut
|
from noita.schemas import ResultsOut, LeaderboardOut
|
||||||
from noita.services.objectives import parse_objectives_and_store
|
from noita.services.objectives import parse_objectives_and_store
|
||||||
|
|
||||||
from .models import LogfileSubmission, Objectiv, ObjectivPoint
|
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
||||||
from .schemas import NoitaSubmissionOut
|
from .schemas import NoitaSubmissionOut
|
||||||
|
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
@router.get("objectives", response=list[ObjectivOut])
|
|
||||||
def get_my_objectives(request: HttpRequest):
|
|
||||||
return Objectiv.objects.order_by("-count").filter(user=request.user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("results", response=ResultsOut)
|
@router.get("results", response=ResultsOut)
|
||||||
def get_results(request: HttpRequest):
|
def get_results(request: HttpRequest):
|
||||||
cache_key = f"api:noita:results:{request.user.id}"
|
cache_key = f"api:noita:results:{request.user.id}"
|
||||||
@ -44,8 +36,15 @@ def get_results(request: HttpRequest):
|
|||||||
Calculates points as: ObjectivPoint.point * min(max_count, count) for each objective
|
Calculates points as: ObjectivPoint.point * min(max_count, count) for each objective
|
||||||
Uses Django ORM annotate for efficient queryset computation.
|
Uses Django ORM annotate for efficient queryset computation.
|
||||||
"""
|
"""
|
||||||
|
# Group objectives by objectiv_id and count occurrences
|
||||||
|
user_objectives = (
|
||||||
|
Objectiv.objects.filter(user=request.user)
|
||||||
|
.values("objectiv_id")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
)
|
||||||
|
|
||||||
# Fetch points from ObjectivPoint using Subquery
|
# Fetch points from ObjectivPoint using Subquery
|
||||||
user_objectives = Objectiv.objects.filter(user=request.user).annotate(
|
user_objectives = user_objectives.annotate(
|
||||||
# Get points per objective from ObjectivPoint
|
# Get points per objective from ObjectivPoint
|
||||||
points_per_objectiv=Subquery(
|
points_per_objectiv=Subquery(
|
||||||
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values(
|
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values(
|
||||||
@ -81,25 +80,35 @@ def get_results(request: HttpRequest):
|
|||||||
total_points=F("points_per_objectiv") * F("capped_count"),
|
total_points=F("points_per_objectiv") * F("capped_count"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get total score
|
# Annotate seed + first-seen-at
|
||||||
total_score_result = user_objectives.aggregate(Sum("total_points"))[
|
user_objectives = user_objectives.annotate(
|
||||||
"total_points__sum"
|
seed=F("seed"),
|
||||||
]
|
first_seen_at=F("first_seen_at"),
|
||||||
total_score = total_score_result or 0
|
)
|
||||||
|
|
||||||
# Build response with all objectives
|
# Build response with all objectives and compute total score
|
||||||
objectives_with_points = [
|
objectives_with_points = []
|
||||||
{
|
total_score = 0
|
||||||
"objectiv_id": obj.objectiv_id,
|
for obj in user_objectives.order_by("-total_points"):
|
||||||
"count": obj.count,
|
points = obj["total_points"] or 0
|
||||||
"points_per_objectiv": obj.points_per_objectiv or 0,
|
objectives_with_points.append(
|
||||||
"total_points": obj.total_points or 0,
|
{
|
||||||
}
|
"objectiv_id": obj["objectiv_id"],
|
||||||
for obj in user_objectives.order_by("-total_points")
|
"count": obj["count"],
|
||||||
]
|
"points_per_objectiv": obj["points_per_objectiv"] or 0,
|
||||||
|
"total_points": points,
|
||||||
|
"first_seen_at": obj["first_seen_at"],
|
||||||
|
"seed": obj["seed"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
total_score += points
|
||||||
|
|
||||||
|
# Count deaths for the user
|
||||||
|
deaths_count = DeathCounter.objects.filter(user=request.user).count()
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"total_score": total_score,
|
"total_score": total_score,
|
||||||
|
"deaths_count": deaths_count,
|
||||||
"objectives": objectives_with_points,
|
"objectives": objectives_with_points,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,9 +133,11 @@ def get_leaderboard(request: HttpRequest):
|
|||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
# Get all objectives with calculated points
|
# Get all objectives with calculated points (grouped by objectiv_id and user)
|
||||||
all_objectives = (
|
all_objectives = (
|
||||||
Objectiv.objects.annotate(
|
Objectiv.objects.values("user", "objectiv_id")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.annotate(
|
||||||
# Fetch points from ObjectivPoint using Subquery
|
# Fetch points from ObjectivPoint using Subquery
|
||||||
points_per_objectiv=Subquery(
|
points_per_objectiv=Subquery(
|
||||||
ObjectivPoint.objects.filter(
|
ObjectivPoint.objects.filter(
|
||||||
@ -161,46 +172,50 @@ def get_leaderboard(request: HttpRequest):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get user totals using subquery
|
# Build user totals by iterating through objectives
|
||||||
user_totals = (
|
user_totals_dict = {}
|
||||||
all_objectives.values("user")
|
for obj in all_objectives:
|
||||||
.annotate(total_score=Sum("total_points"))
|
user_id = obj["user"]
|
||||||
.values("user", "total_score")
|
points = obj["total_points"] or 0
|
||||||
)
|
if user_id not in user_totals_dict:
|
||||||
|
user_totals_dict[user_id] = 0
|
||||||
|
user_totals_dict[user_id] += points
|
||||||
|
|
||||||
# Get unique users and their scores, then apply ranking
|
# Get unique users and their scores, then apply ranking
|
||||||
leaderboard = (
|
users_with_scores = []
|
||||||
User.objects.filter(objectiv__isnull=False)
|
for user_id, total_score in user_totals_dict.items():
|
||||||
.distinct()
|
user = User.objects.get(id=user_id)
|
||||||
.annotate(
|
objectives_count = (
|
||||||
total_score=Subquery(
|
Objectiv.objects.filter(user_id=user_id)
|
||||||
user_totals.filter(user=OuterRef("id")).values("total_score")[:1],
|
.values("objectiv_id")
|
||||||
output_field=IntegerField(),
|
.distinct()
|
||||||
)
|
.count()
|
||||||
)
|
)
|
||||||
.annotate(objectives_count=Count("objectiv", distinct=True))
|
deaths_count = DeathCounter.objects.filter(user_id=user_id).count()
|
||||||
.annotate(
|
users_with_scores.append(
|
||||||
rank=Window(
|
|
||||||
expression=Rank(),
|
|
||||||
order_by=F("total_score").desc(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.values("rank", "username", "total_score", "objectives_count")
|
|
||||||
.order_by("rank")
|
|
||||||
)
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"leaderboard": [
|
|
||||||
{
|
{
|
||||||
"rank": entry["rank"],
|
"user_id": user_id,
|
||||||
"username": entry["username"],
|
"username": user.username,
|
||||||
"total_score": entry["total_score"] or 0,
|
"total_score": total_score,
|
||||||
"objectives_count": entry["objectives_count"],
|
"objectives_count": objectives_count,
|
||||||
|
"deaths_count": deaths_count,
|
||||||
}
|
}
|
||||||
for entry in leaderboard
|
)
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
# Sort by score and add rank
|
||||||
|
users_with_scores.sort(key=lambda x: x["total_score"], reverse=True)
|
||||||
|
leaderboard = [
|
||||||
|
{
|
||||||
|
"rank": idx + 1,
|
||||||
|
"username": entry["username"],
|
||||||
|
"total_score": entry["total_score"],
|
||||||
|
"objectives_count": entry["objectives_count"],
|
||||||
|
"deaths_count": entry["deaths_count"],
|
||||||
|
}
|
||||||
|
for idx, entry in enumerate(users_with_scores)
|
||||||
|
]
|
||||||
|
|
||||||
|
data = {"leaderboard": leaderboard}
|
||||||
cache.set("api:noita:leaderboard", data, 300)
|
cache.set("api:noita:leaderboard", data, 300)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-11 08:20
|
||||||
|
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0006_objectivpoint"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="count",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="first_seen_at",
|
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="objectiv",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("objectiv_id", "user", "first_seen_at"),
|
||||||
|
name="unique_objectiv_per_user_timestamp",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
polylan_submitter/noita/migrations/0008_objectiv_seed.py
Normal file
18
polylan_submitter/noita/migrations/0008_objectiv_seed.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-11 08:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0007_remove_objectiv_count_objectiv_first_seen_at_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="seed",
|
||||||
|
field=models.CharField(default="", max_length=32),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-11 08:31
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0008_objectiv_seed"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="objectiv",
|
||||||
|
name="submission",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
default=1,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="noita.logfilesubmission",
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
34
polylan_submitter/noita/migrations/0010_deathcounter.py
Normal file
34
polylan_submitter/noita/migrations/0010_deathcounter.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-14 23:36
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def fw_func(apps, _schema_editor):
|
||||||
|
Objectiv = apps.get_model("noita", "Objectiv")
|
||||||
|
Objectiv.objects.filter(objectiv_id="DEATH").delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0009_objectiv_submission"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DeathCounter",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("seed", models.CharField(max_length=32)),
|
||||||
|
("seen_at", models.DateTimeField()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.RunPython(fw_func, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
25
polylan_submitter/noita/migrations/0011_deathcounter_user.py
Normal file
25
polylan_submitter/noita/migrations/0011_deathcounter_user.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-14 23:43
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0010_deathcounter"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="deathcounter",
|
||||||
|
name="user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
default=1,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-14 23:45
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0011_deathcounter_user"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="deathcounter",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("user_id", "seen_at"), name="unique_death_per_seen_at"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,5 +1,6 @@
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@ -49,7 +50,17 @@ class Objectiv(models.Model):
|
|||||||
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
|
||||||
count = models.IntegerField(default=1)
|
first_seen_at = models.DateTimeField(default=timezone.now)
|
||||||
|
seed = models.CharField(max_length=32)
|
||||||
|
submission = models.ForeignKey("LogfileSubmission", on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["objectiv_id", "user", "first_seen_at"],
|
||||||
|
name="unique_objectiv_per_user_timestamp",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ObjectivPoint(models.Model):
|
class ObjectivPoint(models.Model):
|
||||||
@ -57,3 +68,17 @@ class ObjectivPoint(models.Model):
|
|||||||
display_string = models.CharField(max_length=255)
|
display_string = models.CharField(max_length=255)
|
||||||
max_count = models.IntegerField(default=1)
|
max_count = models.IntegerField(default=1)
|
||||||
point = models.IntegerField(default=0)
|
point = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class DeathCounter(models.Model):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
seed = models.CharField(max_length=32)
|
||||||
|
seen_at = models.DateTimeField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user_id", "seen_at"],
|
||||||
|
name="unique_death_per_seen_at",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|||||||
@ -1,14 +1,6 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from ninja import Schema, ModelSchema
|
from ninja import Schema
|
||||||
|
|
||||||
from noita.models import Objectiv
|
|
||||||
|
|
||||||
|
|
||||||
class ObjectivOut(ModelSchema):
|
|
||||||
class Meta:
|
|
||||||
model = Objectiv
|
|
||||||
fields = ["objectiv_id", "count"]
|
|
||||||
|
|
||||||
|
|
||||||
class NoitaSubmissionOut(Schema):
|
class NoitaSubmissionOut(Schema):
|
||||||
@ -23,13 +15,15 @@ class NoitaSubmissionOut(Schema):
|
|||||||
|
|
||||||
class ObjectivResultOut(Schema):
|
class ObjectivResultOut(Schema):
|
||||||
objectiv_id: str
|
objectiv_id: str
|
||||||
count: int
|
first_seen_at: datetime
|
||||||
|
seed: str
|
||||||
points_per_objectiv: int
|
points_per_objectiv: int
|
||||||
total_points: int
|
total_points: int
|
||||||
|
|
||||||
|
|
||||||
class ResultsOut(Schema):
|
class ResultsOut(Schema):
|
||||||
total_score: int
|
total_score: int
|
||||||
|
deaths_count: int
|
||||||
objectives: list[ObjectivResultOut]
|
objectives: list[ObjectivResultOut]
|
||||||
|
|
||||||
|
|
||||||
@ -38,6 +32,7 @@ class LeaderboardEntryOut(Schema):
|
|||||||
username: str
|
username: str
|
||||||
total_score: int
|
total_score: int
|
||||||
objectives_count: int
|
objectives_count: int
|
||||||
|
deaths_count: int
|
||||||
|
|
||||||
|
|
||||||
class LeaderboardOut(Schema):
|
class LeaderboardOut(Schema):
|
||||||
|
|||||||
@ -1,22 +1,21 @@
|
|||||||
from noita.models import LogfileSubmission, Objectiv
|
from noita.models import LogfileSubmission, Objectiv, DeathCounter
|
||||||
from noita.services.decode import parse_log, resolve
|
from noita.services.decode import parse_log, resolve
|
||||||
|
|
||||||
|
|
||||||
from collections import Counter
|
def parse_objectives_from_logfile(
|
||||||
|
logfile: LogfileSubmission,
|
||||||
|
) -> list[tuple[str, str, str]]:
|
||||||
def parse_objectives_from_logfile(logfile: LogfileSubmission) -> Counter:
|
|
||||||
"""Parse a log file, and output a count for each ID."""
|
"""Parse a log file, and output a count for each ID."""
|
||||||
file_data = logfile.file.read().decode()
|
file_data = logfile.file.read().decode()
|
||||||
|
|
||||||
ids = []
|
entries: list[tuple[str, str, str]] = []
|
||||||
for entry in parse_log(file_data):
|
for entry in parse_log(file_data):
|
||||||
idx, _seed = resolve(entry["hash"], entry["ts"])
|
idx, seed = resolve(entry["hash"], entry["ts"])
|
||||||
|
|
||||||
if idx:
|
if idx and seed:
|
||||||
ids.append(idx)
|
entries.append((idx, str(seed), entry["ts"]))
|
||||||
|
|
||||||
return Counter(ids)
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
||||||
@ -25,16 +24,32 @@ def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
|||||||
if not logfile.user:
|
if not logfile.user:
|
||||||
return
|
return
|
||||||
|
|
||||||
counter = parse_objectives_from_logfile(logfile)
|
objectives = []
|
||||||
|
deaths = []
|
||||||
for idx, count in counter.items():
|
for idx, seed, ts in parse_objectives_from_logfile(logfile):
|
||||||
|
print(idx, seed, ts)
|
||||||
if idx in {"-", "DEBUG", "polylan-mod"}:
|
if idx in {"-", "DEBUG", "polylan-mod"}:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
obj, created = Objectiv.objects.get_or_create(
|
if idx == "DEATH":
|
||||||
objectiv_id=idx,
|
deaths.append(DeathCounter(user=logfile.user, seed=seed, seen_at=ts))
|
||||||
user=logfile.user,
|
continue
|
||||||
|
|
||||||
|
objectives.append(
|
||||||
|
Objectiv(
|
||||||
|
objectiv_id=idx,
|
||||||
|
user=logfile.user,
|
||||||
|
first_seen_at=ts,
|
||||||
|
seed=seed,
|
||||||
|
submission=logfile,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
obj.count += count
|
Objectiv.objects.bulk_create(
|
||||||
obj.save(update_fields=["count"])
|
objectives,
|
||||||
|
update_conflicts=True,
|
||||||
|
update_fields=["seed", "submission"],
|
||||||
|
unique_fields=["objectiv_id", "user", "first_seen_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
DeathCounter.objects.bulk_create(deaths, ignore_conflicts=True)
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vueuse/core": "^14.0.0",
|
"@vueuse/core": "^14.0.0",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
"install": "^0.13.0",
|
"install": "^0.13.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
|
|||||||
@ -17,6 +17,9 @@ importers:
|
|||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^14.0.0
|
specifier: ^14.0.0
|
||||||
version: 14.0.0(vue@3.5.22(typescript@5.9.3))
|
version: 14.0.0(vue@3.5.22(typescript@5.9.3))
|
||||||
|
dayjs:
|
||||||
|
specifier: ^1.11.20
|
||||||
|
version: 1.11.20
|
||||||
install:
|
install:
|
||||||
specifier: ^0.13.0
|
specifier: ^0.13.0
|
||||||
version: 0.13.0
|
version: 0.13.0
|
||||||
@ -579,6 +582,9 @@ packages:
|
|||||||
daisyui@5.3.10:
|
daisyui@5.3.10:
|
||||||
resolution: {integrity: sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==}
|
resolution: {integrity: sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==}
|
||||||
|
|
||||||
|
dayjs@1.11.20:
|
||||||
|
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
|
||||||
|
|
||||||
detect-libc@2.1.2:
|
detect-libc@2.1.2:
|
||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -1287,6 +1293,8 @@ snapshots:
|
|||||||
|
|
||||||
daisyui@5.3.10: {}
|
daisyui@5.3.10: {}
|
||||||
|
|
||||||
|
dayjs@1.11.20: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
enhanced-resolve@5.18.3:
|
enhanced-resolve@5.18.3:
|
||||||
|
|||||||
@ -1,11 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import RankBadge from "@/components/RankBadge.vue";
|
||||||
|
import {
|
||||||
|
createColumnHelper,
|
||||||
|
useVueTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type ColumnFiltersState,
|
||||||
|
type SortingState,
|
||||||
|
} from "@tanstack/vue-table";
|
||||||
|
|
||||||
interface Objective {
|
interface Objective {
|
||||||
objectiv_id: string;
|
objectiv_id: string;
|
||||||
count: number;
|
first_seen_at: string;
|
||||||
points_per_objectiv?: number;
|
seed: string;
|
||||||
total_points?: number;
|
points_per_objectiv: number;
|
||||||
|
total_points: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userInfo = ref({
|
const userInfo = ref({
|
||||||
@ -13,6 +25,7 @@ const userInfo = ref({
|
|||||||
rank: null as number | null,
|
rank: null as number | null,
|
||||||
score: 0,
|
score: 0,
|
||||||
runsSubmitted: 0,
|
runsSubmitted: 0,
|
||||||
|
deathsCount: 0,
|
||||||
isStaff: false,
|
isStaff: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -20,62 +33,89 @@ const uploadedFiles = ref<File[]>([]);
|
|||||||
const isUploading = ref(false);
|
const isUploading = ref(false);
|
||||||
const isDragover = ref(false);
|
const isDragover = ref(false);
|
||||||
const objectives = ref<Objective[]>([]);
|
const objectives = ref<Objective[]>([]);
|
||||||
const objectiveSearchQuery = ref("");
|
|
||||||
const objectiveSortBy = ref<"id" | "count" | "points_per" | "total_points">("id");
|
|
||||||
const objectiveSortDesc = ref(false);
|
|
||||||
const isLoadingLeaderboard = ref(false);
|
const isLoadingLeaderboard = ref(false);
|
||||||
const leaderboard = ref<any[]>([]);
|
const leaderboard = ref<any[]>([]);
|
||||||
const isLeaderboardModalOpen = ref(false);
|
|
||||||
|
|
||||||
const filteredObjectives = computed(() => {
|
const columnHelper = createColumnHelper<Objective>();
|
||||||
const query = objectiveSearchQuery.value.toLowerCase();
|
const sorting = ref<SortingState>([]);
|
||||||
|
const columnFilters = ref<ColumnFiltersState>([]);
|
||||||
|
|
||||||
let filtered = objectives.value;
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = dayjs(dateString);
|
||||||
if (query) {
|
return date.format("MMM DD, YYYY HH:mm");
|
||||||
filtered = filtered.filter(
|
|
||||||
(obj) =>
|
|
||||||
obj.objectiv_id.toLowerCase().includes(query) ||
|
|
||||||
obj.count.toString().includes(query)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sorted = [...filtered].sort((a, b) => {
|
|
||||||
let aValue: number | string;
|
|
||||||
let bValue: number | string;
|
|
||||||
|
|
||||||
switch (objectiveSortBy.value) {
|
|
||||||
case "points_per":
|
|
||||||
aValue = a.points_per_objectiv || 0;
|
|
||||||
bValue = b.points_per_objectiv || 0;
|
|
||||||
break;
|
|
||||||
case "total_points":
|
|
||||||
aValue = a.total_points || 0;
|
|
||||||
bValue = b.total_points || 0;
|
|
||||||
break;
|
|
||||||
case "id":
|
|
||||||
default:
|
|
||||||
aValue = a.objectiv_id.toLowerCase();
|
|
||||||
bValue = b.objectiv_id.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aValue < bValue) return objectiveSortDesc.value ? 1 : -1;
|
|
||||||
if (aValue > bValue) return objectiveSortDesc.value ? -1 : 1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
return sorted;
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleObjectiveSort = (column: "id" | "points_per" | "total_points") => {
|
|
||||||
if (objectiveSortBy.value === column) {
|
|
||||||
objectiveSortDesc.value = !objectiveSortDesc.value;
|
|
||||||
} else {
|
|
||||||
objectiveSortBy.value = column;
|
|
||||||
objectiveSortDesc.value = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDateTooltip = (dateString: string) => {
|
||||||
|
const date = dayjs(dateString);
|
||||||
|
return date.format("dddd, MMMM D, YYYY [at] h:mm A");
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor("objectiv_id", {
|
||||||
|
header: "Objective ID",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("total_points", {
|
||||||
|
header: "Total Points",
|
||||||
|
cell: (info) => info.getValue() || 0,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("first_seen_at", {
|
||||||
|
header: "First seen",
|
||||||
|
cell: (info) => formatDate(info.getValue()),
|
||||||
|
sortingFn: (rowA, rowB) => {
|
||||||
|
const dateA = dayjs(rowA.original.first_seen_at);
|
||||||
|
const dateB = dayjs(rowB.original.first_seen_at);
|
||||||
|
return dateA.isBefore(dateB) ? -1 : dateA.isAfter(dateB) ? 1 : 0;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("seed", {
|
||||||
|
header: "Seed",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = computed(() =>
|
||||||
|
useVueTable({
|
||||||
|
get data() {
|
||||||
|
return objectives.value;
|
||||||
|
},
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
get sorting() {
|
||||||
|
return sorting.value;
|
||||||
|
},
|
||||||
|
get columnFilters() {
|
||||||
|
return columnFilters.value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onSortingChange: (updater) => {
|
||||||
|
sorting.value =
|
||||||
|
typeof updater === "function" ? updater(sorting.value) : updater;
|
||||||
|
},
|
||||||
|
onColumnFiltersChange: (updater) => {
|
||||||
|
columnFilters.value =
|
||||||
|
typeof updater === "function" ? updater(columnFilters.value) : updater;
|
||||||
|
},
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
filterFns: {
|
||||||
|
fuzzy: (row, columnId, value) => {
|
||||||
|
const itemData = row.getValue(columnId);
|
||||||
|
const searchValue = value.toLowerCase();
|
||||||
|
if (columnId === "first_seen_at") {
|
||||||
|
const dateStr = itemData as string;
|
||||||
|
const formatted = dayjs(dateStr).format("MMM DD, YYYY HH:mm");
|
||||||
|
return formatted.toLowerCase().includes(searchValue);
|
||||||
|
}
|
||||||
|
return String(itemData).toLowerCase().includes(searchValue);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredObjectives = computed(() => table.value.getRowModel().rows);
|
||||||
|
|
||||||
const handleFileUpload = (event: Event) => {
|
const handleFileUpload = (event: Event) => {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
if (input.files) {
|
if (input.files) {
|
||||||
@ -156,6 +196,7 @@ const fetchUserResults = async () => {
|
|||||||
|
|
||||||
const results = await response.json();
|
const results = await response.json();
|
||||||
userInfo.value.score = results.total_score;
|
userInfo.value.score = results.total_score;
|
||||||
|
userInfo.value.deathsCount = results.deaths_count;
|
||||||
userInfo.value.runsSubmitted = results.objectives.length;
|
userInfo.value.runsSubmitted = results.objectives.length;
|
||||||
objectives.value = results.objectives;
|
objectives.value = results.objectives;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -180,6 +221,7 @@ const fetchLeaderboard = async () => {
|
|||||||
if (userRank) {
|
if (userRank) {
|
||||||
userInfo.value.rank = userRank.rank;
|
userInfo.value.rank = userRank.rank;
|
||||||
userInfo.value.score = userRank.total_score;
|
userInfo.value.score = userRank.total_score;
|
||||||
|
userInfo.value.deathsCount = userRank.deaths_count;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching leaderboard:", error);
|
console.error("Error fetching leaderboard:", error);
|
||||||
@ -256,18 +298,18 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<!-- Left Column: User Ranking -->
|
<!-- Left Column: User Ranking -->
|
||||||
<div class="lg:col-span-1">
|
<div class="lg:col-span-1">
|
||||||
<div class="card bg-base-100 shadow-lg sticky top-8">
|
<div class="card bg-base-100 shadow-lg sticky top-8">
|
||||||
<div class="bg-gradient-to-br from-purple-600 to-purple-400 p-6 text-white rounded-t-2xl">
|
<div class="bg-gradient-to-br from-purple-600 to-purple-400 p-8 text-white rounded-t-2xl">
|
||||||
<i class="mdi mdi-trophy text-4xl"></i>
|
<i class="mdi mdi-trophy text-5xl"></i>
|
||||||
<h2 class="text-2xl font-bold mt-2">Your Ranking</h2>
|
<h2 class="text-3xl font-bold mt-3">Your Ranking</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body p-8">
|
||||||
<div class="text-center mb-6">
|
<div class="text-center mb-8">
|
||||||
<p class="text-sm text-base-content/70">Player</p>
|
<p class="text-base text-base-content/70">Player</p>
|
||||||
<p class="text-3xl font-bold">{{ userInfo.username }}</p>
|
<p class="text-4xl font-bold mt-2">{{ userInfo.username }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
@ -276,30 +318,63 @@ onMounted(() => {
|
|||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-6">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-base-content/70 mb-1">Current Rank</p>
|
<p class="text-base text-base-content/70 mb-3">Current Rank</p>
|
||||||
<p v-if="userInfo.rank !== null" class="text-4xl font-bold text-primary">
|
<RankBadge :rank="userInfo.rank" />
|
||||||
#{{ userInfo.rank }}
|
|
||||||
</p>
|
|
||||||
<p v-else class="text-2xl text-base-content/50">No rank yet</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-base-content/70 mb-1">Total Score</p>
|
<p class="text-base text-base-content/70 mb-2">Total Score</p>
|
||||||
<p class="text-2xl font-bold">{{ userInfo.score.toLocaleString() }}</p>
|
<p class="text-3xl font-bold">{{ userInfo.score.toLocaleString() }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-base-content/70 mb-1">Objectives Completed</p>
|
<p class="text-base text-base-content/70 mb-2">Objectives Completed</p>
|
||||||
<p class="text-2xl font-bold">{{ userInfo.runsSubmitted }}</p>
|
<p class="text-3xl font-bold">{{ userInfo.runsSubmitted }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-base text-base-content/70 mb-2">Deaths</p>
|
||||||
|
<p class="text-3xl font-bold text-error">{{ userInfo.deathsCount }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button @click="isLeaderboardModalOpen = true" class="btn btn-outline btn-sm w-full mt-6">
|
<!-- Leaderboard Table -->
|
||||||
<i class="mdi mdi-trophy mr-1"></i>
|
<div class="mt-6">
|
||||||
View Full Leaderboard
|
<h3 class="font-bold text-lg mb-3">Global Leaderboard</h3>
|
||||||
</button>
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-sm w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Rank</th>
|
||||||
|
<th>Player</th>
|
||||||
|
<th class="text-right">Score</th>
|
||||||
|
<th class="text-right">Objectives</th>
|
||||||
|
<th class="text-right">Deaths</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="entry in leaderboard" :key="entry.username"
|
||||||
|
:class="{ 'bg-primary/20': entry.username === userInfo.username }">
|
||||||
|
<td class="font-bold">
|
||||||
|
<RankBadge :rank="entry.rank" />
|
||||||
|
</td>
|
||||||
|
<td class="text-sm">
|
||||||
|
{{ entry.username }}
|
||||||
|
<span v-if="entry.username === userInfo.username" class="badge badge-primary badge-sm ml-1">
|
||||||
|
You
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right text-sm font-bold text-primary">{{ entry.total_score.toLocaleString() }}
|
||||||
|
</td>
|
||||||
|
<td class="text-right text-sm">{{ entry.objectives_count }}</td>
|
||||||
|
<td class="text-right text-sm text-error">{{ entry.deaths_count }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button v-if="userInfo.isStaff" @click="clearCache" class="btn btn-error btn-sm w-full mt-3">
|
<button v-if="userInfo.isStaff" @click="clearCache" class="btn btn-error btn-sm w-full mt-3">
|
||||||
<i class="mdi mdi-cache-clear mr-1"></i>
|
<i class="mdi mdi-cache-clear mr-1"></i>
|
||||||
@ -310,7 +385,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Column: Upload -->
|
<!-- Right Column: Upload -->
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-1">
|
||||||
<div class="card bg-base-100 shadow-lg">
|
<div class="card bg-base-100 shadow-lg">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl mb-6">
|
<h2 class="card-title text-2xl mb-6">
|
||||||
@ -387,8 +462,12 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div v-if="objectives.length > 0" class="space-y-4">
|
<div v-if="objectives.length > 0" class="space-y-4">
|
||||||
<!-- Search Input -->
|
<!-- Search Input -->
|
||||||
<input v-model="objectiveSearchQuery" type="text" placeholder="Search objectives..."
|
<input :value="columnFilters.find((f) => f.id === 'objectiv_id')?.value ?? ''" @input="
|
||||||
class="input input-bordered w-full" />
|
(e) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
table.getColumn('objectiv_id')?.setFilterValue(target.value);
|
||||||
|
}
|
||||||
|
" type="text" placeholder="Search objectives..." class="input input-bordered w-full" />
|
||||||
|
|
||||||
<!-- Results Summary -->
|
<!-- Results Summary -->
|
||||||
<div class="text-sm text-base-content/70">
|
<div class="text-sm text-base-content/70">
|
||||||
@ -400,31 +479,49 @@ onMounted(() => {
|
|||||||
<table class="table table-zebra w-full">
|
<table class="table table-zebra w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="cursor-pointer hover:bg-base-300" @click="toggleObjectiveSort('id')">
|
<th v-for="header in table.getHeaderGroups()[0]?.headers" :key="header.id" :class="[
|
||||||
Objective ID
|
'cursor-pointer hover:bg-base-300',
|
||||||
<i v-if="objectiveSortBy === 'id'"
|
header.column.columnDef.id === 'objectiv_id' ? 'text-left' : 'text-right',
|
||||||
:class="['mdi ml-2', objectiveSortDesc ? 'mdi-arrow-down' : 'mdi-arrow-up']"></i>
|
]" @click="header.column.toggleSorting()">
|
||||||
</th>
|
<div class="flex items-center justify-between">
|
||||||
<th class="text-right cursor-pointer hover:bg-base-300"
|
<span v-if="header.column.columnDef.id === 'objectiv_id'">
|
||||||
@click="toggleObjectiveSort('total_points')">
|
{{ header.isPlaceholder ? null : header.column.columnDef.header }}
|
||||||
Total Points
|
</span>
|
||||||
<i v-if="objectiveSortBy === 'total_points'"
|
<span v-else class="ml-auto">
|
||||||
:class="['mdi ml-2', objectiveSortDesc ? 'mdi-arrow-down' : 'mdi-arrow-up']"></i>
|
{{ header.isPlaceholder ? null : header.column.columnDef.header }}
|
||||||
|
</span>
|
||||||
|
<i v-if="header.column.getIsSorted()" :class="[
|
||||||
|
'mdi ml-2',
|
||||||
|
header.column.getIsSorted() === 'desc'
|
||||||
|
? 'mdi-arrow-down'
|
||||||
|
: 'mdi-arrow-up',
|
||||||
|
]"></i>
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="obj in filteredObjectives" :key="obj.objectiv_id">
|
<tr v-for="row in filteredObjectives" :key="row.id">
|
||||||
<td class="font-medium">
|
<td v-for="cell in row.getVisibleCells()" :key="cell.id" :class="[
|
||||||
<a :href="`https://noita.wiki.gg/wiki/${obj.objectiv_id}`" target="_blank">
|
cell.column.id === 'objectiv_id'
|
||||||
{{ obj.objectiv_id }}
|
? 'font-medium'
|
||||||
<i class="mdi mdi-open-in-new"></i>
|
: 'text-right',
|
||||||
</a>
|
cell.column.id === 'total_points' ? 'font-bold text-primary' : '',
|
||||||
</td>
|
]">
|
||||||
<td class="text-right font-bold text-success">
|
<template v-if="cell.column.id === 'objectiv_id'">
|
||||||
{{ obj.total_points || 0 }}
|
<a :href="`https://noita.wiki.gg/wiki/${row.original.objectiv_id}`" target="_blank">
|
||||||
</td>
|
{{ row.original.objectiv_id }}
|
||||||
<td class="text-right">
|
<i class="mdi mdi-open-in-new"></i>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="cell.column.id === 'first_seen_at'">
|
||||||
|
<span :title="getDateTooltip(row.original.first_seen_at)">
|
||||||
|
{{ formatDate(row.original.first_seen_at) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ cell.renderValue() }}
|
||||||
|
</template>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -442,63 +539,5 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Leaderboard Modal -->
|
|
||||||
<div v-if="isLeaderboardModalOpen" class="modal modal-open">
|
|
||||||
<div class="modal-box max-w-4xl">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="font-bold text-lg">
|
|
||||||
<i class="mdi mdi-trophy text-yellow-500 mr-2"></i>
|
|
||||||
Global Leaderboard
|
|
||||||
</h3>
|
|
||||||
<button @click="isLeaderboardModalOpen = false" class="btn btn-sm btn-circle btn-ghost">
|
|
||||||
<i class="mdi mdi-close"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Leaderboard Table -->
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="table table-zebra w-full">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Rank</th>
|
|
||||||
<th>Username</th>
|
|
||||||
<th class="text-right">Objectives</th>
|
|
||||||
<th class="text-right">Score</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="entry in leaderboard" :key="entry.username"
|
|
||||||
:class="{ 'bg-primary/20': entry.username === userInfo.username }">
|
|
||||||
<td class="font-bold">
|
|
||||||
<span v-if="entry.rank === 1" class="badge badge-warning badge-lg">
|
|
||||||
🏆 #{{ entry.rank }}
|
|
||||||
</span>
|
|
||||||
<span v-else-if="entry.rank === 2" class="badge badge-lg">
|
|
||||||
🥈 #{{ entry.rank }}
|
|
||||||
</span>
|
|
||||||
<span v-else-if="entry.rank === 3" class="badge badge-lg">
|
|
||||||
🥉 #{{ entry.rank }}
|
|
||||||
</span>
|
|
||||||
<span v-else>#{{ entry.rank }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="font-medium">
|
|
||||||
{{ entry.username }}
|
|
||||||
<span v-if="entry.username === userInfo.username" class="badge badge-primary badge-sm ml-2">
|
|
||||||
You
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-right">{{ entry.objectives_count }}</td>
|
|
||||||
<td class="text-right font-bold">{{ entry.total_score.toLocaleString() }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="leaderboard.length === 0" class="text-center py-8">
|
|
||||||
<p class="text-base-content/70">No entries yet</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-backdrop" @click="isLeaderboardModalOpen = false"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -126,17 +126,10 @@ const goHome = () => {
|
|||||||
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
|
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div
|
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
|
||||||
v-if="userInfo?.is_authenticated"
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<span class="font-medium">{{ userInfo.username }}</span>
|
<span class="font-medium">{{ userInfo.username }}</span>
|
||||||
<span
|
<span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">Admin</span>
|
||||||
v-if="userInfo.is_superuser"
|
|
||||||
class="badge badge-warning badge-xs ml-1"
|
|
||||||
>Admin</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-sm text-base-content/70">Not logged in</div>
|
<div v-else class="text-sm text-base-content/70">Not logged in</div>
|
||||||
@ -158,10 +151,7 @@ const goHome = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div v-if="isLoading" class="flex justify-center items-center min-h-[400px]">
|
||||||
v-if="isLoading"
|
|
||||||
class="flex justify-center items-center min-h-[400px]"
|
|
||||||
>
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
|
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
|
||||||
@ -210,12 +200,8 @@ const goHome = () => {
|
|||||||
|
|
||||||
<!-- Puzzles Grid -->
|
<!-- Puzzles Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<PuzzleCard
|
<PuzzleCard v-for="puzzle in puzzlesStore.puzzles" :key="puzzle.id" :puzzle="puzzle"
|
||||||
v-for="puzzle in puzzlesStore.puzzles"
|
:responses="responsesByPuzzle[puzzle.id] || []" />
|
||||||
:key="puzzle.id"
|
|
||||||
:puzzle="puzzle"
|
|
||||||
:responses="responsesByPuzzle[puzzle.id] || []"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
@ -234,18 +220,12 @@ const goHome = () => {
|
|||||||
<div class="modal-box max-w-6xl">
|
<div class="modal-box max-w-6xl">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h3 class="font-bold text-lg">Submit Solution</h3>
|
<h3 class="font-bold text-lg">Submit Solution</h3>
|
||||||
<button
|
<button @click="closeSubmissionModal" class="btn btn-sm btn-circle btn-ghost">
|
||||||
@click="closeSubmissionModal"
|
|
||||||
class="btn btn-sm btn-circle btn-ghost"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-close"></i>
|
<i class="mdi mdi-close"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SubmissionForm
|
<SubmissionForm :puzzles="puzzlesStore.puzzles" :find-puzzle-by-name="findPuzzleByName" />
|
||||||
:puzzles="puzzlesStore.puzzles"
|
|
||||||
:find-puzzle-by-name="findPuzzleByName"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
|
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="card bg-base-100 shadow-lg hover:shadow-2xl transition-shadow duration-300"
|
||||||
class="card bg-base-100 shadow-lg hover:shadow-2xl transition-shadow duration-300"
|
:class="responses?.length == 0 ? 'shadow-red-900' : 'shadow-primary-300'">
|
||||||
:class="responses?.length == 0 ? 'shadow-red-900' : 'shadow-primary-300'"
|
|
||||||
>
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h3 class="card-title text-lg font-bold">{{ puzzle.title }}</h3>
|
<h3 class="card-title text-lg font-bold" :class="responses?.length == 0 ? 'text-error' : 'text-primary'">
|
||||||
|
{{ puzzle.title }}
|
||||||
|
</h3>
|
||||||
<p class="text-sm text-base-content/70 mb-2">
|
<p class="text-sm text-base-content/70 mb-2">
|
||||||
by {{ puzzle.author_name }}
|
by {{ puzzle.author_name }}
|
||||||
</p>
|
</p>
|
||||||
@ -18,28 +18,34 @@
|
|||||||
<div class="badge badge-ghost badge-sm">ID: {{ puzzle.id }}</div>
|
<div class="badge badge-ghost badge-sm">ID: {{ puzzle.id }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p v-if="puzzle.description" class="text-sm text-base-content/80 mb-4">
|
||||||
v-if="puzzle.description"
|
|
||||||
class="text-sm text-base-content/80 mb-4"
|
|
||||||
>
|
|
||||||
{{ puzzle.description }}
|
{{ puzzle.description }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<!-- Points Factor Coefficients -->
|
||||||
v-if="puzzle.tags && puzzle.tags.length > 0"
|
<div v-if="puzzle.points_factor" class="bg-base-200 p-3 rounded-lg mb-4">
|
||||||
class="flex flex-wrap gap-1 mb-4"
|
<p class="text-xs text-base-content/70 font-semibold mb-2">Points Coefficients</p>
|
||||||
>
|
<div class="grid grid-cols-3 gap-2">
|
||||||
<span
|
<div class="text-center">
|
||||||
v-for="tag in puzzle.tags.slice(0, 3)"
|
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cost }}</span>
|
||||||
:key="tag"
|
<p class="text-xs text-base-content/70">Cost</p>
|
||||||
class="badge badge-outline badge-xs"
|
</div>
|
||||||
>
|
<div class="text-center">
|
||||||
|
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cycles }}</span>
|
||||||
|
<p class="text-xs text-base-content/70">Cycles</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.area }}</span>
|
||||||
|
<p class="text-xs text-base-content/70">Area</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="puzzle.tags && puzzle.tags.length > 0" class="flex flex-wrap gap-1 mb-4">
|
||||||
|
<span v-for="tag in puzzle.tags.slice(0, 3)" :key="tag" class="badge badge-outline badge-xs">
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span v-if="puzzle.tags.length > 3" class="badge badge-outline badge-xs">
|
||||||
v-if="puzzle.tags.length > 3"
|
|
||||||
class="badge badge-outline badge-xs"
|
|
||||||
>
|
|
||||||
+{{ puzzle.tags.length - 3 }} more
|
+{{ puzzle.tags.length - 3 }} more
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -47,11 +53,8 @@
|
|||||||
|
|
||||||
<div class="flex flex-col items-end gap-2">
|
<div class="flex flex-col items-end gap-2">
|
||||||
<div class="tooltip" data-tip="View on Steam Workshop">
|
<div class="tooltip" data-tip="View on Steam Workshop">
|
||||||
<a
|
<a :href="`https://steamcommunity.com/workshop/filedetails/?id=${puzzle.steam_item_id}`" target="_blank"
|
||||||
:href="`https://steamcommunity.com/workshop/filedetails/?id=${puzzle.steam_item_id}`"
|
class="btn btn-ghost btn-sm btn-square">
|
||||||
target="_blank"
|
|
||||||
class="btn btn-ghost btn-sm btn-square"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-steam text-lg"></i>
|
<i class="mdi mdi-steam text-lg"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -59,11 +62,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Responses Table -->
|
<!-- Responses Table -->
|
||||||
<div v-if="responses && responses.length > 0" class="mt-6">
|
<div v-if="responses && responses.length > 0" class="mt-1">
|
||||||
<div class="divider">
|
<div class="divider">
|
||||||
<span class="text-sm font-medium"
|
<span class="text-sm font-medium">Solutions ({{ responses.length }})</span>
|
||||||
>Solutions ({{ responses.length }})</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -77,34 +78,21 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr v-for="response in responses" :key="response.id" class="hover">
|
||||||
v-for="response in responses"
|
|
||||||
:key="response.id"
|
|
||||||
class="hover"
|
|
||||||
>
|
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span v-if="response.final_cost || response.cost" class="badge badge-success badge-xs">
|
||||||
v-if="response.final_cost || response.cost"
|
|
||||||
class="badge badge-success badge-xs"
|
|
||||||
>
|
|
||||||
{{ response.final_cost || response.cost }}
|
{{ response.final_cost || response.cost }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-base-content/50">-</span>
|
<span v-else class="text-base-content/50">-</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span v-if="response.final_cycles || response.cycles" class="badge badge-info badge-xs">
|
||||||
v-if="response.final_cycles || response.cycles"
|
|
||||||
class="badge badge-info badge-xs"
|
|
||||||
>
|
|
||||||
{{ response.final_cycles || response.cycles }}
|
{{ response.final_cycles || response.cycles }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-base-content/50">-</span>
|
<span v-else class="text-base-content/50">-</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span v-if="response.final_area || response.area" class="badge badge-warning badge-xs">
|
||||||
v-if="response.final_area || response.area"
|
|
||||||
class="badge badge-warning badge-xs"
|
|
||||||
>
|
|
||||||
{{ response.final_area || response.area }}
|
{{ response.final_area || response.area }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-base-content/50">-</span>
|
<span v-else class="text-base-content/50">-</span>
|
||||||
@ -113,23 +101,14 @@
|
|||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="badge badge-ghost badge-xs">{{
|
<span class="badge badge-ghost badge-xs">{{
|
||||||
response.files?.length || 0
|
response.files?.length || 0
|
||||||
}}</span>
|
}}</span>
|
||||||
<div
|
<div v-if="response.files?.length" class="tooltip" :data-tip="response.files
|
||||||
v-if="response.files?.length"
|
.map((f) => f.original_filename || f.file?.name)
|
||||||
class="tooltip"
|
.join(', ')
|
||||||
:data-tip="
|
">
|
||||||
response.files
|
|
||||||
.map((f) => f.original_filename || f.file?.name)
|
|
||||||
.join(', ')
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-information-outline text-xs"></i>
|
<i class="mdi mdi-information-outline text-xs"></i>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-if="response.needs_manual_validation" class="tooltip" data-tip="Needs manual validation">
|
||||||
v-if="response.needs_manual_validation"
|
|
||||||
class="tooltip"
|
|
||||||
data-tip="Needs manual validation"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-alert-circle text-xs text-warning"></i>
|
<i class="mdi mdi-alert-circle text-xs text-warning"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -141,11 +120,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No responses state -->
|
<!-- No responses state -->
|
||||||
<div
|
<div v-else
|
||||||
v-else
|
|
||||||
class="mt-6 text-center py-4 border-2 border-dashed border-base-300 rounded-lg hover:border-primary transition-colors duration-300 cursor-pointer"
|
class="mt-6 text-center py-4 border-2 border-dashed border-base-300 rounded-lg hover:border-primary transition-colors duration-300 cursor-pointer"
|
||||||
@click="openSubmissionModal"
|
@click="openSubmissionModal">
|
||||||
>
|
|
||||||
<i class="mdi mdi-upload text-2xl text-base-content/40"></i>
|
<i class="mdi mdi-upload text-2xl text-base-content/40"></i>
|
||||||
<p class="text-sm text-base-content/60 mt-2">No solutions yet</p>
|
<p class="text-sm text-base-content/60 mt-2">No solutions yet</p>
|
||||||
<p class="text-xs text-base-content/40">
|
<p class="text-xs text-base-content/40">
|
||||||
|
|||||||
25
polylan_submitter/src/components/RankBadge.vue
Normal file
25
polylan_submitter/src/components/RankBadge.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
rank: number | null;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="rank !== null" class="flex justify-center">
|
||||||
|
<span v-if="rank === 1" class="badge badge-warning badge-lg">
|
||||||
|
🏆 #{{ rank }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="rank === 2" class="badge badge-lg">
|
||||||
|
🥈 #{{ rank }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="rank === 3" class="badge badge-lg">
|
||||||
|
🥉 #{{ rank }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="badge badge-lg">
|
||||||
|
#{{ rank }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-2xl text-base-content/50">
|
||||||
|
No rank yet
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
|
import RankBadge from "./RankBadge.vue";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
@ -8,9 +9,16 @@ interface User {
|
|||||||
last_name?: string;
|
last_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PointsFactor {
|
||||||
|
cost: number;
|
||||||
|
cycles: number;
|
||||||
|
area: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface Puzzle {
|
interface Puzzle {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
points_factor?: PointsFactor;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PuzzleResponse {
|
interface PuzzleResponse {
|
||||||
@ -90,7 +98,6 @@ const getPuzzleRanking = (puzzleId: number) => {
|
|||||||
|
|
||||||
const ranking = resultsData.value.ranking_by_puzzle[puzzleId] || [];
|
const ranking = resultsData.value.ranking_by_puzzle[puzzleId] || [];
|
||||||
return ranking.map((response) => {
|
return ranking.map((response) => {
|
||||||
console.log(response)
|
|
||||||
const user = resultsData.value!.users.find((u) => u.id === response.user_id);
|
const user = resultsData.value!.users.find((u) => u.id === response.user_id);
|
||||||
return {
|
return {
|
||||||
username: user?.username || "Unknown",
|
username: user?.username || "Unknown",
|
||||||
@ -100,7 +107,7 @@ const getPuzzleRanking = (puzzleId: number) => {
|
|||||||
points: response.points,
|
points: response.points,
|
||||||
rank_points: response.rank_points || 0,
|
rank_points: response.rank_points || 0,
|
||||||
};
|
};
|
||||||
});
|
}).reverse();
|
||||||
};
|
};
|
||||||
|
|
||||||
const togglePuzzleExpanded = (puzzleId: number) => {
|
const togglePuzzleExpanded = (puzzleId: number) => {
|
||||||
@ -185,7 +192,7 @@ onMounted(() => {
|
|||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-base-content/70 mb-1">Current Rank</p>
|
<p class="text-sm text-base-content/70 mb-1">Current Rank</p>
|
||||||
<p v-if="userInfo.rank !== null" class="text-4xl font-bold text-primary">
|
<p v-if="userInfo.rank !== null" class="text-4xl font-bold text-primary">
|
||||||
#{{ userInfo.rank }}
|
<RankBadge :rank="userInfo.rank" />
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-2xl text-base-content/50">No rank yet</p>
|
<p v-else class="text-2xl text-base-content/50">No rank yet</p>
|
||||||
</div>
|
</div>
|
||||||
@ -222,167 +229,202 @@ onMounted(() => {
|
|||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="!resultsData" class="text-center py-8">
|
<div v-else-if="!resultsData" class="text-center py-8">
|
||||||
<p class="text-base-content/70">No results available yet</p>
|
<p class="text-base-content/70">No results available yet</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-6">
|
|
||||||
<!-- Tabs -->
|
|
||||||
<div class="tabs tabs-boxed">
|
|
||||||
<button @click="selectedTab = 'overall'" :class="[
|
|
||||||
'tab',
|
|
||||||
selectedTab === 'overall' ? 'tab-active' : '',
|
|
||||||
]">
|
|
||||||
<i class="mdi mdi-chart-line mr-2"></i>
|
|
||||||
Overall Ranking
|
|
||||||
</button>
|
|
||||||
<button @click="selectedTab = 'byPuzzle'" :class="[
|
|
||||||
'tab',
|
|
||||||
selectedTab === 'byPuzzle' ? 'tab-active' : '',
|
|
||||||
]">
|
|
||||||
<i class="mdi mdi-puzzle mr-2"></i>
|
|
||||||
By Puzzle
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Overall Ranking -->
|
|
||||||
<div v-show="selectedTab === 'overall'" class="space-y-4">
|
|
||||||
<div v-if="getOverallRanking().length === 0" class="text-center py-8">
|
|
||||||
<p class="text-base-content/70">No submissions yet</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
<div v-else class="space-y-6">
|
||||||
<table class="table table-zebra w-full">
|
<!-- Tabs -->
|
||||||
<thead>
|
<div class="tabs tabs-boxed">
|
||||||
<tr>
|
<button @click="selectedTab = 'overall'" :class="[
|
||||||
<th>Rank</th>
|
'tab',
|
||||||
<th>Player</th>
|
selectedTab === 'overall' ? 'tab-active' : '',
|
||||||
<th class="text-right">Puzzles Solved</th>
|
]">
|
||||||
<th class="text-right">Total Points</th>
|
<i class="mdi mdi-chart-line mr-2"></i>
|
||||||
</tr>
|
Overall Ranking
|
||||||
</thead>
|
</button>
|
||||||
<tbody>
|
<button @click="selectedTab = 'byPuzzle'" :class="[
|
||||||
<tr v-for="(user, index) in getOverallRanking()" :key="user.username">
|
'tab',
|
||||||
<td class="font-bold">
|
selectedTab === 'byPuzzle' ? 'tab-active' : '',
|
||||||
<span v-if="index === 0" class="badge badge-warning badge-lg">
|
]">
|
||||||
🏆 #1
|
<i class="mdi mdi-puzzle mr-2"></i>
|
||||||
</span>
|
By Puzzle
|
||||||
<span v-else-if="index === 1" class="badge badge-lg">
|
</button>
|
||||||
🥈 #2
|
</div>
|
||||||
</span>
|
|
||||||
<span v-else-if="index === 2" class="badge badge-lg">
|
|
||||||
🥉 #3
|
|
||||||
</span>
|
|
||||||
<span v-else>#{{ index + 1 }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="font-medium">{{ user.username }}</td>
|
|
||||||
<td class="text-right">{{ user.puzzlesSolved }}</td>
|
|
||||||
<td class="text-right font-bold">{{ user.totalPoints }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- By Puzzle Ranking -->
|
<!-- Overall Ranking -->
|
||||||
<div v-show="selectedTab === 'byPuzzle'" class="space-y-6">
|
<div v-show="selectedTab === 'overall'" class="space-y-4">
|
||||||
<div v-for="puzzle in resultsData.puzzles" :key="puzzle.id" class="card bg-base-100 border border-base-300">
|
<div v-if="getOverallRanking().length === 0" class="text-center py-8">
|
||||||
<button @click="togglePuzzleExpanded(puzzle.id)"
|
<p class="text-base-content/70">No submissions yet</p>
|
||||||
class="btn btn-ghost btn-lg w-full justify-start text-lg font-bold hover:bg-primary/20 rounded-b-none">
|
|
||||||
<i :class="['mdi mr-2', expandedPuzzleId === puzzle.id ? 'mdi-chevron-down' : 'mdi-chevron-right']"></i>
|
|
||||||
{{ puzzle.title }}
|
|
||||||
<span class="ml-auto badge badge-sm">
|
|
||||||
{{ getPuzzleRanking(puzzle.id).length }} submissions
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Expanded Details -->
|
|
||||||
<div v-if="expandedPuzzleId === puzzle.id" class="card-body">
|
|
||||||
<div v-if="getPuzzleRanking(puzzle.id).length === 0" class="text-center py-8">
|
|
||||||
<p class="text-base-content/70 text-lg">No submissions yet</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-6">
|
<div v-else class="overflow-x-auto">
|
||||||
<!-- Top 3 Podium -->
|
<table class="table table-zebra w-full">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<thead>
|
||||||
<div v-for="(response, index) in getPuzzleRanking(puzzle.id).slice(0, 3)" :key="index"
|
<tr>
|
||||||
class="card bg-base-200">
|
<th>Rank</th>
|
||||||
<div class="card-body p-4">
|
<th>Player</th>
|
||||||
<div class="text-xs text-base-content/70 font-bold">
|
<th class="text-right">Puzzles Solved</th>
|
||||||
{{ index === 0 ? '🏆 1st Place' : index === 1 ? '🥈 2nd Place' : '🥉 3rd Place' }}
|
<th class="text-right">Total Points</th>
|
||||||
</div>
|
</tr>
|
||||||
<h4 class="font-bold text-lg">{{ response.username }}</h4>
|
</thead>
|
||||||
<div class="divider my-2"></div>
|
<tbody>
|
||||||
<div class="space-y-2 text-sm">
|
<tr v-for="(user, index) in getOverallRanking()" :key="user.username">
|
||||||
<div class="flex justify-between">
|
<td class="font-bold">
|
||||||
<span>Cost</span>
|
<RankBadge :rank="index + 1" />
|
||||||
<span class="badge badge-sm">{{ response.cost || 'N/A' }}</span>
|
</td>
|
||||||
|
<td class="font-medium">{{ user.username }}</td>
|
||||||
|
<td class="text-right">{{ user.puzzlesSolved }}</td>
|
||||||
|
<td class="text-right font-bold">{{ user.totalPoints }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- By Puzzle Ranking -->
|
||||||
|
<div v-show="selectedTab === 'byPuzzle'" class="space-y-6">
|
||||||
|
<div v-for="puzzle in resultsData.puzzles" :key="puzzle.id"
|
||||||
|
class="card bg-base-100 border border-base-300">
|
||||||
|
<button @click="togglePuzzleExpanded(puzzle.id)"
|
||||||
|
class="btn btn-ghost btn-lg w-full justify-start text-lg font-bold hover:bg-primary/20 rounded-b-none">
|
||||||
|
<i
|
||||||
|
:class="['mdi mr-2', expandedPuzzleId === puzzle.id ? 'mdi-chevron-down' : 'mdi-chevron-right']"></i>
|
||||||
|
{{ puzzle.title }}
|
||||||
|
<span class="ml-auto badge badge-sm">
|
||||||
|
{{ getPuzzleRanking(puzzle.id).length }} submissions
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Expanded Details -->
|
||||||
|
<div v-if="expandedPuzzleId === puzzle.id" class="card-body">
|
||||||
|
<div v-if="getPuzzleRanking(puzzle.id).length === 0" class="text-center py-8">
|
||||||
|
<p class="text-base-content/70 text-lg">No submissions yet</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<!-- Points Factor Info -->
|
||||||
|
<div v-if="puzzle.points_factor" class="bg-base-200 p-4 rounded-lg">
|
||||||
|
<p class="text-sm text-base-content/70 mb-3 font-semibold">Points Coefficients</p>
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<span class="text-2xl font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cost
|
||||||
|
}}</span>
|
||||||
|
<p class="text-xs text-base-content/70">Cost</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="text-center">
|
||||||
<span>Cycles</span>
|
<span class="text-2xl font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cycles
|
||||||
<span class="badge badge-sm">{{ response.cycles || 'N/A' }}</span>
|
}}</span>
|
||||||
|
<p class="text-xs text-base-content/70">Cycles</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="text-center">
|
||||||
<span>Area</span>
|
<span class="text-2xl font-bold text-primary"><small>x</small>{{ puzzle.points_factor.area
|
||||||
<span class="badge badge-sm">{{ response.area || 'N/A' }}</span>
|
}}</span>
|
||||||
</div>
|
<p class="text-xs text-base-content/70">Area</p>
|
||||||
<div class="flex justify-between pt-2 border-t">
|
|
||||||
<span>Total (with coef.)</span>
|
|
||||||
<span class="badge badge-sm">{{ response.points || 'N/A' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between pt-2 border-t">
|
|
||||||
<span class="font-bold">Points</span>
|
|
||||||
<span class="badge badge-primary">{{ response.rank_points }} pts</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Full Ranking Table -->
|
<!-- Top 3 Podium -->
|
||||||
<div class="overflow-x-auto">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<table class="table table-zebra w-full table-sm">
|
<div v-for="(response, index) in getPuzzleRanking(puzzle.id).slice(0, 3)" :key="index"
|
||||||
<thead>
|
class="card bg-base-200">
|
||||||
<tr>
|
<div class="card-body p-4">
|
||||||
<th class="w-12">Rank</th>
|
<div class="text-xs text-base-content/70 font-bold">
|
||||||
<th>Player</th>
|
{{ index === 0 ? '🏆 1st Place' : index === 1 ? '🥈 2nd Place' : '🥉 3rd Place' }}
|
||||||
<th class="text-center">Cost</th>
|
</div>
|
||||||
<th class="text-center">Cycles</th>
|
<h4 class="font-bold text-lg">{{ response.username }}</h4>
|
||||||
<th class="text-center">Area</th>
|
<div class="divider my-2"></div>
|
||||||
<th class="text-center">Total (with coef.)</th>
|
<div class="space-y-2 text-sm">
|
||||||
<th class="text-right">Points</th>
|
<div class="flex justify-between">
|
||||||
</tr>
|
<span>Cost<span v-if="puzzle.points_factor" class="text-xs text-base-content/60">
|
||||||
</thead>
|
(x{{ puzzle.points_factor.cost }})
|
||||||
<tbody>
|
</span></span>
|
||||||
<tr v-for="(response, index) in getPuzzleRanking(puzzle.id)" :key="index"
|
<span class="badge badge-sm">{{ response.cost || 'N/A' }}</span>
|
||||||
:class="{ 'bg-primary/10': index < 3 }">
|
</div>
|
||||||
<td class="font-bold">
|
<div class="flex justify-between">
|
||||||
<span v-if="index === 0" class="badge badge-warning">🏆</span>
|
<span>Cycles<span v-if="puzzle.points_factor" class="text-xs text-base-content/60">
|
||||||
<span v-else-if="index === 1" class="badge">🥈</span>
|
(x{{ puzzle.points_factor.cycles }})
|
||||||
<span v-else-if="index === 2" class="badge">🥉</span>
|
</span></span>
|
||||||
<span v-else>#{{ index + 1 }}</span>
|
<span class="badge badge-sm">{{ response.cycles || 'N/A' }}</span>
|
||||||
</td>
|
</div>
|
||||||
<td class="font-medium">{{ response.username }}</td>
|
<div class="flex justify-between">
|
||||||
<td class="text-center">
|
<span>Area<span v-if="puzzle.points_factor" class="text-xs text-base-content/60">
|
||||||
<span v-if="response.cost" class="badge badge-sm">{{ response.cost }}</span>
|
(x{{ puzzle.points_factor.area }})
|
||||||
<span v-else class="text-base-content/40">—</span>
|
</span></span>
|
||||||
</td>
|
<span class="badge badge-sm">{{ response.area || 'N/A' }}</span>
|
||||||
<td class="text-center">
|
</div>
|
||||||
<span v-if="response.cycles" class="badge badge-sm">{{ response.cycles }}</span>
|
<div class="flex justify-between pt-2 border-t">
|
||||||
<span v-else class="text-base-content/40">—</span>
|
<span>Total (with coef.)</span>
|
||||||
</td>
|
<span class="badge badge-sm">{{ response.points || 'N/A' }}</span>
|
||||||
<td class="text-center">
|
</div>
|
||||||
<span v-if="response.area" class="badge badge-sm">{{ response.area }}</span>
|
<div class="flex justify-between pt-2 border-t">
|
||||||
<span v-else class="text-base-content/40">—</span>
|
<span class="font-bold">Points</span>
|
||||||
</td>
|
<span class="badge badge-primary">{{ response.rank_points }} pts</span>
|
||||||
<td class="text-center">
|
</div>
|
||||||
<span v-if="response.points" class="badge badge-sm">{{ response.points }}</span>
|
</div>
|
||||||
<span v-else class="text-base-content/40">—</span>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td class="text-right font-bold text-primary text-lg">{{ response.rank_points }}</td>
|
</div>
|
||||||
</tr>
|
|
||||||
</tbody>
|
<!-- Full Ranking Table -->
|
||||||
</table>
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra w-full table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-12">Rank</th>
|
||||||
|
<th>Player</th>
|
||||||
|
<th class="text-center">
|
||||||
|
Cost
|
||||||
|
<span v-if="puzzle.points_factor" class="text-xs text-base-content/60 block">(x{{
|
||||||
|
puzzle.points_factor.cost }})</span>
|
||||||
|
</th>
|
||||||
|
<th class="text-center">
|
||||||
|
Cycles
|
||||||
|
<span v-if="puzzle.points_factor" class="text-xs text-base-content/60 block">(x{{
|
||||||
|
puzzle.points_factor.cycles }})</span>
|
||||||
|
</th>
|
||||||
|
<th class="text-center">
|
||||||
|
Area
|
||||||
|
<span v-if="puzzle.points_factor" class="text-xs text-base-content/60 block">(x{{
|
||||||
|
puzzle.points_factor.area }})</span>
|
||||||
|
</th>
|
||||||
|
<th class="text-center">Total (with coef.)</th>
|
||||||
|
<th class="text-right">Points</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(response, index) in getPuzzleRanking(puzzle.id)" :key="index"
|
||||||
|
:class="{ 'bg-primary/10': index < 3 }">
|
||||||
|
<td class="font-bold">
|
||||||
|
<span v-if="index === 0" class="badge badge-warning">🏆</span>
|
||||||
|
<span v-else-if="index === 1" class="badge">🥈</span>
|
||||||
|
<span v-else-if="index === 2" class="badge">🥉</span>
|
||||||
|
<span v-else>#{{ index + 1 }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="font-medium">{{ response.username }}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span v-if="response.cost" class="badge badge-sm">{{ response.cost }}</span>
|
||||||
|
<span v-else class="text-base-content/40">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span v-if="response.cycles" class="badge badge-sm">{{ response.cycles }}</span>
|
||||||
|
<span v-else class="text-base-content/40">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span v-if="response.area" class="badge badge-sm">{{ response.area }}</span>
|
||||||
|
<span v-else class="text-base-content/40">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span v-if="response.points" class="badge badge-sm">{{ response.points }}</span>
|
||||||
|
<span v-else class="text-base-content/40">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right font-bold text-primary text-lg">{{ response.rank_points }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -390,8 +432,6 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -11,6 +11,12 @@ export interface SteamCollection {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PointsFactor {
|
||||||
|
cost: number
|
||||||
|
cycles: number
|
||||||
|
area: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface SteamCollectionItem {
|
export interface SteamCollectionItem {
|
||||||
id: number
|
id: number
|
||||||
steam_item_id: string
|
steam_item_id: string
|
||||||
@ -20,6 +26,7 @@ export interface SteamCollectionItem {
|
|||||||
tags: string[]
|
tags: string[]
|
||||||
order_index: number
|
order_index: number
|
||||||
collection: number
|
collection: number
|
||||||
|
points_factor?: PointsFactor
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
import{k as t,l as a,p as n,v as s}from"./style-CgW_ewEM.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 _};
|
||||||
@ -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,x,v as i,A as f,O as _}from"./style-iP6anD9B.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"},A={class:"text-base-content/70"},B=b({__name:"Home",setup(z){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",A,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",O=document.querySelector(l),$=_(B,{...O?.dataset});$.mount(l);
|
|
||||||
@ -0,0 +1 @@
|
|||||||
|
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-CgW_ewEM.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);
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,13 +1,20 @@
|
|||||||
{
|
{
|
||||||
"_style-DK-qmJDU.css": {
|
"_RankBadge.vue_vue_type_script_setup_true_lang-DXi0jahW.js": {
|
||||||
"file": "assets/style-DK-qmJDU.css",
|
"file": "assets/RankBadge.vue_vue_type_script_setup_true_lang-DXi0jahW.js",
|
||||||
"src": "_style-DK-qmJDU.css"
|
"name": "RankBadge.vue_vue_type_script_setup_true_lang",
|
||||||
|
"imports": [
|
||||||
|
"_style-CgW_ewEM.js"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"_style-iP6anD9B.js": {
|
"_style-CgCQPOku.css": {
|
||||||
"file": "assets/style-iP6anD9B.js",
|
"file": "assets/style-CgCQPOku.css",
|
||||||
|
"src": "_style-CgCQPOku.css"
|
||||||
|
},
|
||||||
|
"_style-CgW_ewEM.js": {
|
||||||
|
"file": "assets/style-CgW_ewEM.js",
|
||||||
"name": "style",
|
"name": "style",
|
||||||
"css": [
|
"css": [
|
||||||
"assets/style-DK-qmJDU.css"
|
"assets/style-CgCQPOku.css"
|
||||||
],
|
],
|
||||||
"assets": [
|
"assets": [
|
||||||
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
||||||
@ -33,30 +40,32 @@
|
|||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
||||||
},
|
},
|
||||||
"src/home.ts": {
|
"src/home.ts": {
|
||||||
"file": "assets/home-C3AkoPCZ.js",
|
"file": "assets/home-Cnotf5sq.js",
|
||||||
"name": "home",
|
"name": "home",
|
||||||
"src": "src/home.ts",
|
"src": "src/home.ts",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_style-iP6anD9B.js"
|
"_style-CgW_ewEM.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/noita.ts": {
|
"src/noita.ts": {
|
||||||
"file": "assets/noita-Cj8fTuxL.js",
|
"file": "assets/noita-BxC854hz.js",
|
||||||
"name": "noita",
|
"name": "noita",
|
||||||
"src": "src/noita.ts",
|
"src": "src/noita.ts",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_style-iP6anD9B.js"
|
"_style-CgW_ewEM.js",
|
||||||
|
"_RankBadge.vue_vue_type_script_setup_true_lang-DXi0jahW.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/opus-magnum.ts": {
|
"src/opus-magnum.ts": {
|
||||||
"file": "assets/opus_magnum-a6P58qyI.js",
|
"file": "assets/opus_magnum-CgTJgCB5.js",
|
||||||
"name": "opus_magnum",
|
"name": "opus_magnum",
|
||||||
"src": "src/opus-magnum.ts",
|
"src": "src/opus-magnum.ts",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_style-iP6anD9B.js"
|
"_style-CgW_ewEM.js",
|
||||||
|
"_RankBadge.vue_vue_type_script_setup_true_lang-DXi0jahW.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -132,10 +132,19 @@ class ValidationIn(Schema):
|
|||||||
|
|
||||||
|
|
||||||
# Collection Schemas
|
# Collection Schemas
|
||||||
|
class PuzzlePointsFactorOut(Schema):
|
||||||
|
"""Schema for puzzle points factor output"""
|
||||||
|
|
||||||
|
cost: int
|
||||||
|
cycles: int
|
||||||
|
area: int
|
||||||
|
|
||||||
|
|
||||||
class SteamCollectionItemOut(ModelSchema):
|
class SteamCollectionItemOut(ModelSchema):
|
||||||
"""Schema for Steam collection item output"""
|
"""Schema for Steam collection item output"""
|
||||||
|
|
||||||
steam_url: str
|
steam_url: str
|
||||||
|
points_factor: Optional[PuzzlePointsFactorOut] = None
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SteamCollectionItem
|
model = SteamCollectionItem
|
||||||
@ -151,6 +160,16 @@ class SteamCollectionItemOut(ModelSchema):
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_points_factor(obj) -> Optional[PuzzlePointsFactorOut]:
|
||||||
|
if obj.points_factor:
|
||||||
|
return PuzzlePointsFactorOut(
|
||||||
|
cost=obj.points_factor.cost,
|
||||||
|
cycles=obj.points_factor.cycles,
|
||||||
|
area=obj.points_factor.area,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Error Schemas
|
# Error Schemas
|
||||||
class ErrorOut(Schema):
|
class ErrorOut(Schema):
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
{"root":["./src/home.ts","./src/noita.ts","./src/opus-magnum.ts","./src/services/apiService.ts","./src/services/ocrService.ts","./src/stores/index.ts","./src/stores/puzzles.ts","./src/stores/submissions.ts","./src/stores/uploads.ts","./src/types/index.ts","./src/Home.vue","./src/Noita.vue","./src/OpusMagnum.vue","./src/components/AdminPanel.vue","./src/components/FileUpload.vue","./src/components/PuzzleCard.vue","./src/components/Results.vue","./src/components/SubmissionForm.vue"],"version":"5.9.3"}
|
{"root":["./src/home.ts","./src/noita.ts","./src/opus-magnum.ts","./src/services/apiService.ts","./src/services/ocrService.ts","./src/stores/index.ts","./src/stores/puzzles.ts","./src/stores/submissions.ts","./src/stores/uploads.ts","./src/types/index.ts","./src/Home.vue","./src/Noita.vue","./src/OpusMagnum.vue","./src/components/AdminPanel.vue","./src/components/FileUpload.vue","./src/components/PuzzleCard.vue","./src/components/RankBadge.vue","./src/components/Results.vue","./src/components/SubmissionForm.vue"],"version":"5.9.3"}
|
||||||
Loading…
Reference in New Issue
Block a user