Compare commits

..

5 Commits

31 changed files with 882 additions and 525 deletions

View File

@ -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",)}),
)

View File

@ -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

View File

@ -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",
),
),
]

View 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,
),
]

View File

@ -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,
),
]

View 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),
]

View 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,
),
]

View File

@ -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"
),
),
]

View File

@ -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",
)
]

View File

@ -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):

View File

@ -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)

View File

@ -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",

View File

@ -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:

View File

@ -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>

View File

@ -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>

View File

@ -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">

View 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>

View File

@ -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>

View File

@ -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
}

View File

@ -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 _};

View File

@ -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);

View File

@ -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

View File

@ -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"
]
}
}

View File

@ -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):

View File

@ -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"}