Compare commits
5 Commits
754b0b0803
...
6d996a4e2f
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d996a4e2f | |||
| f774ff3340 | |||
| 779393106d | |||
| 3e04f8312a | |||
| 7cfab20826 |
@ -1,5 +1,7 @@
|
||||
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)
|
||||
@ -23,13 +25,28 @@ class LogfileSubmissionAdmin(admin.ModelAdmin):
|
||||
("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)
|
||||
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")
|
||||
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)
|
||||
@ -41,3 +58,15 @@ class ObjectivPointAdmin(admin.ModelAdmin):
|
||||
("Objective Information", {"fields": ("objectiv_id", "display_string")}),
|
||||
("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,
|
||||
Case,
|
||||
When,
|
||||
Sum,
|
||||
Count,
|
||||
IntegerField,
|
||||
Subquery,
|
||||
OuterRef,
|
||||
Window,
|
||||
)
|
||||
from django.db.models.functions import Rank
|
||||
from ninja import Router, File
|
||||
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 .models import LogfileSubmission, Objectiv, ObjectivPoint
|
||||
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
||||
from .schemas import NoitaSubmissionOut
|
||||
|
||||
|
||||
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)
|
||||
def get_results(request: HttpRequest):
|
||||
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
|
||||
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
|
||||
user_objectives = Objectiv.objects.filter(user=request.user).annotate(
|
||||
user_objectives = user_objectives.annotate(
|
||||
# Get points per objective from ObjectivPoint
|
||||
points_per_objectiv=Subquery(
|
||||
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"),
|
||||
)
|
||||
|
||||
# Get total score
|
||||
total_score_result = user_objectives.aggregate(Sum("total_points"))[
|
||||
"total_points__sum"
|
||||
]
|
||||
total_score = total_score_result or 0
|
||||
# Annotate seed + first-seen-at
|
||||
user_objectives = user_objectives.annotate(
|
||||
seed=F("seed"),
|
||||
first_seen_at=F("first_seen_at"),
|
||||
)
|
||||
|
||||
# Build response with all objectives
|
||||
objectives_with_points = [
|
||||
{
|
||||
"objectiv_id": obj.objectiv_id,
|
||||
"count": obj.count,
|
||||
"points_per_objectiv": obj.points_per_objectiv or 0,
|
||||
"total_points": obj.total_points or 0,
|
||||
}
|
||||
for obj in user_objectives.order_by("-total_points")
|
||||
]
|
||||
# Build response with all objectives and compute total score
|
||||
objectives_with_points = []
|
||||
total_score = 0
|
||||
for obj in user_objectives.order_by("-total_points"):
|
||||
points = obj["total_points"] or 0
|
||||
objectives_with_points.append(
|
||||
{
|
||||
"objectiv_id": obj["objectiv_id"],
|
||||
"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 = {
|
||||
"total_score": total_score,
|
||||
"deaths_count": deaths_count,
|
||||
"objectives": objectives_with_points,
|
||||
}
|
||||
|
||||
@ -124,9 +133,11 @@ def get_leaderboard(request: HttpRequest):
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# Get all objectives with calculated points
|
||||
# Get all objectives with calculated points (grouped by objectiv_id and user)
|
||||
all_objectives = (
|
||||
Objectiv.objects.annotate(
|
||||
Objectiv.objects.values("user", "objectiv_id")
|
||||
.annotate(count=Count("id"))
|
||||
.annotate(
|
||||
# Fetch points from ObjectivPoint using Subquery
|
||||
points_per_objectiv=Subquery(
|
||||
ObjectivPoint.objects.filter(
|
||||
@ -161,46 +172,50 @@ def get_leaderboard(request: HttpRequest):
|
||||
)
|
||||
)
|
||||
|
||||
# Get user totals using subquery
|
||||
user_totals = (
|
||||
all_objectives.values("user")
|
||||
.annotate(total_score=Sum("total_points"))
|
||||
.values("user", "total_score")
|
||||
)
|
||||
# Build user totals by iterating through objectives
|
||||
user_totals_dict = {}
|
||||
for obj in all_objectives:
|
||||
user_id = obj["user"]
|
||||
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
|
||||
leaderboard = (
|
||||
User.objects.filter(objectiv__isnull=False)
|
||||
.distinct()
|
||||
.annotate(
|
||||
total_score=Subquery(
|
||||
user_totals.filter(user=OuterRef("id")).values("total_score")[:1],
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
users_with_scores = []
|
||||
for user_id, total_score in user_totals_dict.items():
|
||||
user = User.objects.get(id=user_id)
|
||||
objectives_count = (
|
||||
Objectiv.objects.filter(user_id=user_id)
|
||||
.values("objectiv_id")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
.annotate(objectives_count=Count("objectiv", distinct=True))
|
||||
.annotate(
|
||||
rank=Window(
|
||||
expression=Rank(),
|
||||
order_by=F("total_score").desc(),
|
||||
)
|
||||
)
|
||||
.values("rank", "username", "total_score", "objectives_count")
|
||||
.order_by("rank")
|
||||
)
|
||||
|
||||
data = {
|
||||
"leaderboard": [
|
||||
deaths_count = DeathCounter.objects.filter(user_id=user_id).count()
|
||||
users_with_scores.append(
|
||||
{
|
||||
"rank": entry["rank"],
|
||||
"username": entry["username"],
|
||||
"total_score": entry["total_score"] or 0,
|
||||
"objectives_count": entry["objectives_count"],
|
||||
"user_id": user_id,
|
||||
"username": user.username,
|
||||
"total_score": total_score,
|
||||
"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)
|
||||
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.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
import uuid
|
||||
|
||||
@ -49,7 +50,17 @@ class Objectiv(models.Model):
|
||||
|
||||
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):
|
||||
@ -57,3 +68,17 @@ class ObjectivPoint(models.Model):
|
||||
display_string = models.CharField(max_length=255)
|
||||
max_count = models.IntegerField(default=1)
|
||||
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 datetime import datetime
|
||||
from ninja import Schema, ModelSchema
|
||||
|
||||
from noita.models import Objectiv
|
||||
|
||||
|
||||
class ObjectivOut(ModelSchema):
|
||||
class Meta:
|
||||
model = Objectiv
|
||||
fields = ["objectiv_id", "count"]
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class NoitaSubmissionOut(Schema):
|
||||
@ -23,13 +15,15 @@ class NoitaSubmissionOut(Schema):
|
||||
|
||||
class ObjectivResultOut(Schema):
|
||||
objectiv_id: str
|
||||
count: int
|
||||
first_seen_at: datetime
|
||||
seed: str
|
||||
points_per_objectiv: int
|
||||
total_points: int
|
||||
|
||||
|
||||
class ResultsOut(Schema):
|
||||
total_score: int
|
||||
deaths_count: int
|
||||
objectives: list[ObjectivResultOut]
|
||||
|
||||
|
||||
@ -38,6 +32,7 @@ class LeaderboardEntryOut(Schema):
|
||||
username: str
|
||||
total_score: int
|
||||
objectives_count: int
|
||||
deaths_count: int
|
||||
|
||||
|
||||
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 collections import Counter
|
||||
|
||||
|
||||
def parse_objectives_from_logfile(logfile: LogfileSubmission) -> Counter:
|
||||
def parse_objectives_from_logfile(
|
||||
logfile: LogfileSubmission,
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""Parse a log file, and output a count for each ID."""
|
||||
file_data = logfile.file.read().decode()
|
||||
|
||||
ids = []
|
||||
entries: list[tuple[str, str, str]] = []
|
||||
for entry in parse_log(file_data):
|
||||
idx, _seed = resolve(entry["hash"], entry["ts"])
|
||||
idx, seed = resolve(entry["hash"], entry["ts"])
|
||||
|
||||
if idx:
|
||||
ids.append(idx)
|
||||
if idx and seed:
|
||||
entries.append((idx, str(seed), entry["ts"]))
|
||||
|
||||
return Counter(ids)
|
||||
return entries
|
||||
|
||||
|
||||
def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
||||
@ -25,16 +24,32 @@ def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
||||
if not logfile.user:
|
||||
return
|
||||
|
||||
counter = parse_objectives_from_logfile(logfile)
|
||||
|
||||
for idx, count in counter.items():
|
||||
objectives = []
|
||||
deaths = []
|
||||
for idx, seed, ts in parse_objectives_from_logfile(logfile):
|
||||
print(idx, seed, ts)
|
||||
if idx in {"-", "DEBUG", "polylan-mod"}:
|
||||
continue
|
||||
|
||||
obj, created = Objectiv.objects.get_or_create(
|
||||
objectiv_id=idx,
|
||||
user=logfile.user,
|
||||
if idx == "DEATH":
|
||||
deaths.append(DeathCounter(user=logfile.user, seed=seed, seen_at=ts))
|
||||
continue
|
||||
|
||||
objectives.append(
|
||||
Objectiv(
|
||||
objectiv_id=idx,
|
||||
user=logfile.user,
|
||||
first_seen_at=ts,
|
||||
seed=seed,
|
||||
submission=logfile,
|
||||
)
|
||||
)
|
||||
|
||||
obj.count += count
|
||||
obj.save(update_fields=["count"])
|
||||
Objectiv.objects.bulk_create(
|
||||
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",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"install": "^0.13.0",
|
||||
"pinia": "^3.0.3",
|
||||
"tailwindcss": "^4.1.16",
|
||||
|
||||
@ -17,6 +17,9 @@ importers:
|
||||
'@vueuse/core':
|
||||
specifier: ^14.0.0
|
||||
version: 14.0.0(vue@3.5.22(typescript@5.9.3))
|
||||
dayjs:
|
||||
specifier: ^1.11.20
|
||||
version: 1.11.20
|
||||
install:
|
||||
specifier: ^0.13.0
|
||||
version: 0.13.0
|
||||
@ -579,6 +582,9 @@ packages:
|
||||
daisyui@5.3.10:
|
||||
resolution: {integrity: sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==}
|
||||
|
||||
dayjs@1.11.20:
|
||||
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
@ -1287,6 +1293,8 @@ snapshots:
|
||||
|
||||
daisyui@5.3.10: {}
|
||||
|
||||
dayjs@1.11.20: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
enhanced-resolve@5.18.3:
|
||||
|
||||
@ -1,11 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
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 {
|
||||
objectiv_id: string;
|
||||
count: number;
|
||||
points_per_objectiv?: number;
|
||||
total_points?: number;
|
||||
first_seen_at: string;
|
||||
seed: string;
|
||||
points_per_objectiv: number;
|
||||
total_points: number;
|
||||
}
|
||||
|
||||
const userInfo = ref({
|
||||
@ -13,6 +25,7 @@ const userInfo = ref({
|
||||
rank: null as number | null,
|
||||
score: 0,
|
||||
runsSubmitted: 0,
|
||||
deathsCount: 0,
|
||||
isStaff: false,
|
||||
});
|
||||
|
||||
@ -20,62 +33,89 @@ const uploadedFiles = ref<File[]>([]);
|
||||
const isUploading = ref(false);
|
||||
const isDragover = ref(false);
|
||||
const objectives = ref<Objective[]>([]);
|
||||
const objectiveSearchQuery = ref("");
|
||||
const objectiveSortBy = ref<"id" | "count" | "points_per" | "total_points">("id");
|
||||
const objectiveSortDesc = ref(false);
|
||||
const isLoadingLeaderboard = ref(false);
|
||||
const leaderboard = ref<any[]>([]);
|
||||
const isLeaderboardModalOpen = ref(false);
|
||||
|
||||
const filteredObjectives = computed(() => {
|
||||
const query = objectiveSearchQuery.value.toLowerCase();
|
||||
const columnHelper = createColumnHelper<Objective>();
|
||||
const sorting = ref<SortingState>([]);
|
||||
const columnFilters = ref<ColumnFiltersState>([]);
|
||||
|
||||
let filtered = objectives.value;
|
||||
|
||||
if (query) {
|
||||
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 formatDate = (dateString: string) => {
|
||||
const date = dayjs(dateString);
|
||||
return date.format("MMM DD, YYYY HH:mm");
|
||||
};
|
||||
|
||||
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 input = event.target as HTMLInputElement;
|
||||
if (input.files) {
|
||||
@ -156,6 +196,7 @@ const fetchUserResults = async () => {
|
||||
|
||||
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) {
|
||||
@ -180,6 +221,7 @@ const fetchLeaderboard = async () => {
|
||||
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);
|
||||
@ -256,18 +298,18 @@ onMounted(() => {
|
||||
|
||||
<!-- Main Content -->
|
||||
<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 -->
|
||||
<div class="lg:col-span-1">
|
||||
<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">
|
||||
<i class="mdi mdi-trophy text-4xl"></i>
|
||||
<h2 class="text-2xl font-bold mt-2">Your Ranking</h2>
|
||||
<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-5xl"></i>
|
||||
<h2 class="text-3xl font-bold mt-3">Your Ranking</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="text-center mb-6">
|
||||
<p class="text-sm text-base-content/70">Player</p>
|
||||
<p class="text-3xl font-bold">{{ userInfo.username }}</p>
|
||||
<div class="card-body p-8">
|
||||
<div class="text-center mb-8">
|
||||
<p class="text-base text-base-content/70">Player</p>
|
||||
<p class="text-4xl font-bold mt-2">{{ userInfo.username }}</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
@ -276,30 +318,63 @@ onMounted(() => {
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-else class="space-y-6">
|
||||
<div class="text-center">
|
||||
<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">
|
||||
#{{ userInfo.rank }}
|
||||
</p>
|
||||
<p v-else class="text-2xl text-base-content/50">No rank yet</p>
|
||||
<p class="text-base text-base-content/70 mb-3">Current Rank</p>
|
||||
<RankBadge :rank="userInfo.rank" />
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-base-content/70 mb-1">Total Score</p>
|
||||
<p class="text-2xl font-bold">{{ userInfo.score.toLocaleString() }}</p>
|
||||
<p class="text-base text-base-content/70 mb-2">Total Score</p>
|
||||
<p class="text-3xl font-bold">{{ userInfo.score.toLocaleString() }}</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-base-content/70 mb-1">Objectives Completed</p>
|
||||
<p class="text-2xl font-bold">{{ userInfo.runsSubmitted }}</p>
|
||||
<p class="text-base text-base-content/70 mb-2">Objectives Completed</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>
|
||||
|
||||
<button @click="isLeaderboardModalOpen = true" class="btn btn-outline btn-sm w-full mt-6">
|
||||
<i class="mdi mdi-trophy mr-1"></i>
|
||||
View Full Leaderboard
|
||||
</button>
|
||||
<!-- Leaderboard Table -->
|
||||
<div class="mt-6">
|
||||
<h3 class="font-bold text-lg mb-3">Global Leaderboard</h3>
|
||||
<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">
|
||||
<i class="mdi mdi-cache-clear mr-1"></i>
|
||||
@ -310,7 +385,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- 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-body">
|
||||
<h2 class="card-title text-2xl mb-6">
|
||||
@ -387,8 +462,12 @@ onMounted(() => {
|
||||
|
||||
<div v-if="objectives.length > 0" class="space-y-4">
|
||||
<!-- Search Input -->
|
||||
<input v-model="objectiveSearchQuery" type="text" placeholder="Search objectives..."
|
||||
class="input input-bordered w-full" />
|
||||
<input :value="columnFilters.find((f) => f.id === 'objectiv_id')?.value ?? ''" @input="
|
||||
(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 -->
|
||||
<div class="text-sm text-base-content/70">
|
||||
@ -400,31 +479,49 @@ onMounted(() => {
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cursor-pointer hover:bg-base-300" @click="toggleObjectiveSort('id')">
|
||||
Objective ID
|
||||
<i v-if="objectiveSortBy === 'id'"
|
||||
:class="['mdi ml-2', objectiveSortDesc ? 'mdi-arrow-down' : 'mdi-arrow-up']"></i>
|
||||
</th>
|
||||
<th class="text-right cursor-pointer hover:bg-base-300"
|
||||
@click="toggleObjectiveSort('total_points')">
|
||||
Total Points
|
||||
<i v-if="objectiveSortBy === 'total_points'"
|
||||
:class="['mdi ml-2', objectiveSortDesc ? 'mdi-arrow-down' : 'mdi-arrow-up']"></i>
|
||||
<th v-for="header in table.getHeaderGroups()[0]?.headers" :key="header.id" :class="[
|
||||
'cursor-pointer hover:bg-base-300',
|
||||
header.column.columnDef.id === 'objectiv_id' ? 'text-left' : 'text-right',
|
||||
]" @click="header.column.toggleSorting()">
|
||||
<div class="flex items-center justify-between">
|
||||
<span v-if="header.column.columnDef.id === 'objectiv_id'">
|
||||
{{ header.isPlaceholder ? null : header.column.columnDef.header }}
|
||||
</span>
|
||||
<span v-else class="ml-auto">
|
||||
{{ 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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="obj in filteredObjectives" :key="obj.objectiv_id">
|
||||
<td class="font-medium">
|
||||
<a :href="`https://noita.wiki.gg/wiki/${obj.objectiv_id}`" target="_blank">
|
||||
{{ obj.objectiv_id }}
|
||||
<i class="mdi mdi-open-in-new"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-right font-bold text-success">
|
||||
{{ obj.total_points || 0 }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<tr v-for="row in filteredObjectives" :key="row.id">
|
||||
<td v-for="cell in row.getVisibleCells()" :key="cell.id" :class="[
|
||||
cell.column.id === 'objectiv_id'
|
||||
? 'font-medium'
|
||||
: 'text-right',
|
||||
cell.column.id === 'total_points' ? 'font-bold text-primary' : '',
|
||||
]">
|
||||
<template v-if="cell.column.id === 'objectiv_id'">
|
||||
<a :href="`https://noita.wiki.gg/wiki/${row.original.objectiv_id}`" target="_blank">
|
||||
{{ row.original.objectiv_id }}
|
||||
<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>
|
||||
</tr>
|
||||
</tbody>
|
||||
@ -442,63 +539,5 @@ onMounted(() => {
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@ -126,17 +126,10 @@ const goHome = () => {
|
||||
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</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 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
|
||||
>
|
||||
<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>
|
||||
@ -158,10 +151,7 @@ const goHome = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex justify-center items-center min-h-[400px]"
|
||||
>
|
||||
<div v-if="isLoading" class="flex justify-center items-center min-h-[400px]">
|
||||
<div class="text-center">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
|
||||
@ -210,12 +200,8 @@ const goHome = () => {
|
||||
|
||||
<!-- Puzzles Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<PuzzleCard
|
||||
v-for="puzzle in puzzlesStore.puzzles"
|
||||
:key="puzzle.id"
|
||||
:puzzle="puzzle"
|
||||
:responses="responsesByPuzzle[puzzle.id] || []"
|
||||
/>
|
||||
<PuzzleCard v-for="puzzle in puzzlesStore.puzzles" :key="puzzle.id" :puzzle="puzzle"
|
||||
:responses="responsesByPuzzle[puzzle.id] || []" />
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
@ -234,18 +220,12 @@ const goHome = () => {
|
||||
<div class="modal-box max-w-6xl">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="font-bold text-lg">Submit Solution</h3>
|
||||
<button
|
||||
@click="closeSubmissionModal"
|
||||
class="btn btn-sm btn-circle btn-ghost"
|
||||
>
|
||||
<button @click="closeSubmissionModal" class="btn btn-sm btn-circle btn-ghost">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SubmissionForm
|
||||
:puzzles="puzzlesStore.puzzles"
|
||||
:find-puzzle-by-name="findPuzzleByName"
|
||||
/>
|
||||
<SubmissionForm :puzzles="puzzlesStore.puzzles" :find-puzzle-by-name="findPuzzleByName" />
|
||||
</div>
|
||||
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
class="card bg-base-100 shadow-lg hover:shadow-2xl transition-shadow duration-300"
|
||||
:class="responses?.length == 0 ? 'shadow-red-900' : 'shadow-primary-300'"
|
||||
>
|
||||
<div class="card bg-base-100 shadow-lg hover:shadow-2xl transition-shadow duration-300"
|
||||
:class="responses?.length == 0 ? 'shadow-red-900' : 'shadow-primary-300'">
|
||||
<div class="card-body">
|
||||
<div class="flex items-start justify-between">
|
||||
<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">
|
||||
by {{ puzzle.author_name }}
|
||||
</p>
|
||||
@ -18,28 +18,34 @@
|
||||
<div class="badge badge-ghost badge-sm">ID: {{ puzzle.id }}</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="puzzle.description"
|
||||
class="text-sm text-base-content/80 mb-4"
|
||||
>
|
||||
<p v-if="puzzle.description" class="text-sm text-base-content/80 mb-4">
|
||||
{{ puzzle.description }}
|
||||
</p>
|
||||
|
||||
<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"
|
||||
>
|
||||
<!-- Points Factor Coefficients -->
|
||||
<div v-if="puzzle.points_factor" class="bg-base-200 p-3 rounded-lg 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">
|
||||
<div class="text-center">
|
||||
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cost }}</span>
|
||||
<p class="text-xs text-base-content/70">Cost</p>
|
||||
</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 }}
|
||||
</span>
|
||||
<span
|
||||
v-if="puzzle.tags.length > 3"
|
||||
class="badge badge-outline badge-xs"
|
||||
>
|
||||
<span v-if="puzzle.tags.length > 3" class="badge badge-outline badge-xs">
|
||||
+{{ puzzle.tags.length - 3 }} more
|
||||
</span>
|
||||
</div>
|
||||
@ -47,11 +53,8 @@
|
||||
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<div class="tooltip" data-tip="View on Steam Workshop">
|
||||
<a
|
||||
:href="`https://steamcommunity.com/workshop/filedetails/?id=${puzzle.steam_item_id}`"
|
||||
target="_blank"
|
||||
class="btn btn-ghost btn-sm btn-square"
|
||||
>
|
||||
<a :href="`https://steamcommunity.com/workshop/filedetails/?id=${puzzle.steam_item_id}`" target="_blank"
|
||||
class="btn btn-ghost btn-sm btn-square">
|
||||
<i class="mdi mdi-steam text-lg"></i>
|
||||
</a>
|
||||
</div>
|
||||
@ -59,11 +62,9 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<span class="text-sm font-medium"
|
||||
>Solutions ({{ responses.length }})</span
|
||||
>
|
||||
<span class="text-sm font-medium">Solutions ({{ responses.length }})</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -77,34 +78,21 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="response in responses"
|
||||
:key="response.id"
|
||||
class="hover"
|
||||
>
|
||||
<tr v-for="response in responses" :key="response.id" class="hover">
|
||||
<td>
|
||||
<span
|
||||
v-if="response.final_cost || response.cost"
|
||||
class="badge badge-success badge-xs"
|
||||
>
|
||||
<span v-if="response.final_cost || response.cost" class="badge badge-success badge-xs">
|
||||
{{ response.final_cost || response.cost }}
|
||||
</span>
|
||||
<span v-else class="text-base-content/50">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="response.final_cycles || response.cycles"
|
||||
class="badge badge-info badge-xs"
|
||||
>
|
||||
<span v-if="response.final_cycles || response.cycles" class="badge badge-info badge-xs">
|
||||
{{ response.final_cycles || response.cycles }}
|
||||
</span>
|
||||
<span v-else class="text-base-content/50">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="response.final_area || response.area"
|
||||
class="badge badge-warning badge-xs"
|
||||
>
|
||||
<span v-if="response.final_area || response.area" class="badge badge-warning badge-xs">
|
||||
{{ response.final_area || response.area }}
|
||||
</span>
|
||||
<span v-else class="text-base-content/50">-</span>
|
||||
@ -113,23 +101,14 @@
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="badge badge-ghost badge-xs">{{
|
||||
response.files?.length || 0
|
||||
}}</span>
|
||||
<div
|
||||
v-if="response.files?.length"
|
||||
class="tooltip"
|
||||
:data-tip="
|
||||
response.files
|
||||
.map((f) => f.original_filename || f.file?.name)
|
||||
.join(', ')
|
||||
"
|
||||
>
|
||||
}}</span>
|
||||
<div v-if="response.files?.length" class="tooltip" :data-tip="response.files
|
||||
.map((f) => f.original_filename || f.file?.name)
|
||||
.join(', ')
|
||||
">
|
||||
<i class="mdi mdi-information-outline text-xs"></i>
|
||||
</div>
|
||||
<div
|
||||
v-if="response.needs_manual_validation"
|
||||
class="tooltip"
|
||||
data-tip="Needs manual validation"
|
||||
>
|
||||
<div v-if="response.needs_manual_validation" class="tooltip" data-tip="Needs manual validation">
|
||||
<i class="mdi mdi-alert-circle text-xs text-warning"></i>
|
||||
</div>
|
||||
</div>
|
||||
@ -141,11 +120,9 @@
|
||||
</div>
|
||||
|
||||
<!-- No responses state -->
|
||||
<div
|
||||
v-else
|
||||
<div 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"
|
||||
@click="openSubmissionModal"
|
||||
>
|
||||
@click="openSubmissionModal">
|
||||
<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-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">
|
||||
import { ref, onMounted } from "vue";
|
||||
import RankBadge from "./RankBadge.vue";
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
@ -8,9 +9,16 @@ interface User {
|
||||
last_name?: string;
|
||||
}
|
||||
|
||||
interface PointsFactor {
|
||||
cost: number;
|
||||
cycles: number;
|
||||
area: number;
|
||||
}
|
||||
|
||||
interface Puzzle {
|
||||
id: number;
|
||||
title: string;
|
||||
points_factor?: PointsFactor;
|
||||
}
|
||||
|
||||
interface PuzzleResponse {
|
||||
@ -90,7 +98,6 @@ const getPuzzleRanking = (puzzleId: number) => {
|
||||
|
||||
const ranking = resultsData.value.ranking_by_puzzle[puzzleId] || [];
|
||||
return ranking.map((response) => {
|
||||
console.log(response)
|
||||
const user = resultsData.value!.users.find((u) => u.id === response.user_id);
|
||||
return {
|
||||
username: user?.username || "Unknown",
|
||||
@ -100,7 +107,7 @@ const getPuzzleRanking = (puzzleId: number) => {
|
||||
points: response.points,
|
||||
rank_points: response.rank_points || 0,
|
||||
};
|
||||
});
|
||||
}).reverse();
|
||||
};
|
||||
|
||||
const togglePuzzleExpanded = (puzzleId: number) => {
|
||||
@ -185,7 +192,7 @@ onMounted(() => {
|
||||
<div class="text-center">
|
||||
<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">
|
||||
#{{ userInfo.rank }}
|
||||
<RankBadge :rank="userInfo.rank" />
|
||||
</p>
|
||||
<p v-else class="text-2xl text-base-content/50">No rank yet</p>
|
||||
</div>
|
||||
@ -222,167 +229,202 @@ onMounted(() => {
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!resultsData" class="text-center py-8">
|
||||
<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 v-else-if="!resultsData" class="text-center py-8">
|
||||
<p class="text-base-content/70">No results available yet</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Player</th>
|
||||
<th class="text-right">Puzzles Solved</th>
|
||||
<th class="text-right">Total Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(user, index) in getOverallRanking()" :key="user.username">
|
||||
<td class="font-bold">
|
||||
<span v-if="index === 0" class="badge badge-warning badge-lg">
|
||||
🏆 #1
|
||||
</span>
|
||||
<span v-else-if="index === 1" class="badge badge-lg">
|
||||
🥈 #2
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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 v-else class="space-y-6">
|
||||
<!-- Top 3 Podium -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div v-for="(response, index) in getPuzzleRanking(puzzle.id).slice(0, 3)" :key="index"
|
||||
class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<div class="text-xs text-base-content/70 font-bold">
|
||||
{{ index === 0 ? '🏆 1st Place' : index === 1 ? '🥈 2nd Place' : '🥉 3rd Place' }}
|
||||
</div>
|
||||
<h4 class="font-bold text-lg">{{ response.username }}</h4>
|
||||
<div class="divider my-2"></div>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span>Cost</span>
|
||||
<span class="badge badge-sm">{{ response.cost || 'N/A' }}</span>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Player</th>
|
||||
<th class="text-right">Puzzles Solved</th>
|
||||
<th class="text-right">Total Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(user, index) in getOverallRanking()" :key="user.username">
|
||||
<td class="font-bold">
|
||||
<RankBadge :rank="index + 1" />
|
||||
</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 class="flex justify-between">
|
||||
<span>Cycles</span>
|
||||
<span class="badge badge-sm">{{ response.cycles || 'N/A' }}</span>
|
||||
<div class="text-center">
|
||||
<span class="text-2xl 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="flex justify-between">
|
||||
<span>Area</span>
|
||||
<span class="badge badge-sm">{{ response.area || 'N/A' }}</span>
|
||||
</div>
|
||||
<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 class="text-center">
|
||||
<span class="text-2xl 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>
|
||||
</div>
|
||||
|
||||
<!-- Full Ranking 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</th>
|
||||
<th class="text-center">Cycles</th>
|
||||
<th class="text-center">Area</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>
|
||||
<!-- Top 3 Podium -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div v-for="(response, index) in getPuzzleRanking(puzzle.id).slice(0, 3)" :key="index"
|
||||
class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<div class="text-xs text-base-content/70 font-bold">
|
||||
{{ index === 0 ? '🏆 1st Place' : index === 1 ? '🥈 2nd Place' : '🥉 3rd Place' }}
|
||||
</div>
|
||||
<h4 class="font-bold text-lg">{{ response.username }}</h4>
|
||||
<div class="divider my-2"></div>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span>Cost<span v-if="puzzle.points_factor" class="text-xs text-base-content/60">
|
||||
(x{{ puzzle.points_factor.cost }})
|
||||
</span></span>
|
||||
<span class="badge badge-sm">{{ response.cost || 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Cycles<span v-if="puzzle.points_factor" class="text-xs text-base-content/60">
|
||||
(x{{ puzzle.points_factor.cycles }})
|
||||
</span></span>
|
||||
<span class="badge badge-sm">{{ response.cycles || 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Area<span v-if="puzzle.points_factor" class="text-xs text-base-content/60">
|
||||
(x{{ puzzle.points_factor.area }})
|
||||
</span></span>
|
||||
<span class="badge badge-sm">{{ response.area || 'N/A' }}</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Full Ranking 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>
|
||||
@ -390,8 +432,6 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -11,6 +11,12 @@ export interface SteamCollection {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PointsFactor {
|
||||
cost: number
|
||||
cycles: number
|
||||
area: number
|
||||
}
|
||||
|
||||
export interface SteamCollectionItem {
|
||||
id: number
|
||||
steam_item_id: string
|
||||
@ -20,6 +26,7 @@ export interface SteamCollectionItem {
|
||||
tags: string[]
|
||||
order_index: number
|
||||
collection: number
|
||||
points_factor?: PointsFactor
|
||||
created_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": {
|
||||
"file": "assets/style-DK-qmJDU.css",
|
||||
"src": "_style-DK-qmJDU.css"
|
||||
"_RankBadge.vue_vue_type_script_setup_true_lang-DXi0jahW.js": {
|
||||
"file": "assets/RankBadge.vue_vue_type_script_setup_true_lang-DXi0jahW.js",
|
||||
"name": "RankBadge.vue_vue_type_script_setup_true_lang",
|
||||
"imports": [
|
||||
"_style-CgW_ewEM.js"
|
||||
]
|
||||
},
|
||||
"_style-iP6anD9B.js": {
|
||||
"file": "assets/style-iP6anD9B.js",
|
||||
"_style-CgCQPOku.css": {
|
||||
"file": "assets/style-CgCQPOku.css",
|
||||
"src": "_style-CgCQPOku.css"
|
||||
},
|
||||
"_style-CgW_ewEM.js": {
|
||||
"file": "assets/style-CgW_ewEM.js",
|
||||
"name": "style",
|
||||
"css": [
|
||||
"assets/style-DK-qmJDU.css"
|
||||
"assets/style-CgCQPOku.css"
|
||||
],
|
||||
"assets": [
|
||||
"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/home.ts": {
|
||||
"file": "assets/home-C3AkoPCZ.js",
|
||||
"file": "assets/home-Cnotf5sq.js",
|
||||
"name": "home",
|
||||
"src": "src/home.ts",
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"_style-iP6anD9B.js"
|
||||
"_style-CgW_ewEM.js"
|
||||
]
|
||||
},
|
||||
"src/noita.ts": {
|
||||
"file": "assets/noita-Cj8fTuxL.js",
|
||||
"file": "assets/noita-BxC854hz.js",
|
||||
"name": "noita",
|
||||
"src": "src/noita.ts",
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"_style-iP6anD9B.js"
|
||||
"_style-CgW_ewEM.js",
|
||||
"_RankBadge.vue_vue_type_script_setup_true_lang-DXi0jahW.js"
|
||||
]
|
||||
},
|
||||
"src/opus-magnum.ts": {
|
||||
"file": "assets/opus_magnum-a6P58qyI.js",
|
||||
"file": "assets/opus_magnum-CgTJgCB5.js",
|
||||
"name": "opus_magnum",
|
||||
"src": "src/opus-magnum.ts",
|
||||
"isEntry": true,
|
||||
"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
|
||||
class PuzzlePointsFactorOut(Schema):
|
||||
"""Schema for puzzle points factor output"""
|
||||
|
||||
cost: int
|
||||
cycles: int
|
||||
area: int
|
||||
|
||||
|
||||
class SteamCollectionItemOut(ModelSchema):
|
||||
"""Schema for Steam collection item output"""
|
||||
|
||||
steam_url: str
|
||||
points_factor: Optional[PuzzlePointsFactorOut] = None
|
||||
|
||||
class Meta:
|
||||
model = SteamCollectionItem
|
||||
@ -151,6 +160,16 @@ class SteamCollectionItemOut(ModelSchema):
|
||||
"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
|
||||
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